diff --git a/extension/src/vault/vault.ts b/extension/src/vault/vault.ts index c828537..b41829c 100644 --- a/extension/src/vault/vault.ts +++ b/extension/src/vault/vault.ts @@ -11,7 +11,7 @@ import type { import { registerHost } from '../shared/state'; import { lookupErrorCopy, type ErrorCta } from '../shared/error-copy'; import { - GLYPH_TRASH, GLYPH_DEVICES, GLYPH_SETTINGS, GLYPH_LOCK, + GLYPH_TRASH, GLYPH_SETTINGS, GLYPH_LOCK, GLYPH_TYPE_LOGIN, GLYPH_TYPE_SECURE_NOTE, GLYPH_TYPE_TOTP, GLYPH_TYPE_CARD, GLYPH_TYPE_IDENTITY, GLYPH_TYPE_KEY, GLYPH_TYPE_DOCUMENT, } from '../shared/glyphs'; @@ -75,15 +75,24 @@ function typeIcon(t: ItemType): string { } function typeLabel(t: ItemType): string { - switch (t) { - case 'login': return 'Logins'; - case 'secure_note': return 'Secure Notes'; - case 'identity': return 'Identities'; - case 'card': return 'Cards'; - case 'key': return 'Keys'; - case 'document': return 'Documents'; - case 'totp': return 'TOTP'; - } + const labels: Record = { + login: 'Login', + secure_note: 'Secure Note', + identity: 'Identity', + card: 'Card', + key: 'SSH / API Key', + document: 'Document', + totp: 'TOTP', + }; + return labels[t]; +} + +function relativeTime(unixSec: number): string { + const diffS = Math.floor(Date.now() / 1000) - unixSec; + if (diffS < 60) return 'just now'; + if (diffS < 3600) return `${Math.floor(diffS / 60)}m ago`; + if (diffS < 86400) return `${Math.floor(diffS / 3600)}h ago`; + return `${Math.floor(diffS / 86400)}d ago`; } // --------------------------------------------------------------------------- @@ -142,6 +151,8 @@ interface VaultState { selectedIndex: number; searchQuery: string; activeGroup: string | null; + drawerOpen: boolean; + bottomSheetOpen: boolean; vaultSettings: VaultSettings | null; generatorDefaults: GeneratorRequest | null; error: string | null; @@ -161,6 +172,8 @@ const state: VaultState = { selectedIndex: 0, searchQuery: '', activeGroup: null, + drawerOpen: false, + bottomSheetOpen: false, vaultSettings: null, generatorDefaults: null, error: null, @@ -184,7 +197,8 @@ registerHost({ navigate: (view: string, extras?: any) => { Object.assign(state, { view, error: null, loading: false, ...extras }); setHash(view as VaultView); - renderSidebarList(); + renderSidebarCategories(); + renderListPane(); renderPane(); }, sendMessage, @@ -253,39 +267,74 @@ function renderLockScreen(app: HTMLElement): void { } // --------------------------------------------------------------------------- -// Shell (sidebar + pane) +// Shell (3-column: sidebar + list pane + drawer) // --------------------------------------------------------------------------- function renderShell(app: HTMLElement): void { - // Only create the shell structure if it's not present yet - if (!app.querySelector('.vault-sidebar')) { + if (!app.querySelector('.vault-shell')) { app.innerHTML = ` -
-
- - Relicario +
+
+
+ + Relicario +
+ + +
+ + + + +
- -
-
- - - - - -
-
-
- select an item +
+
+
+
`; wireSidebar(); + wireBottomSheet(); } - renderSidebarList(); - renderPane(); + renderSidebarCategories(); + renderListPane(); + if (state.drawerOpen && state.selectedItem) { + renderDrawer(state.selectedItem); + } +} + +// --------------------------------------------------------------------------- +// Bottom sheet (stub — wired in Task 11) +// --------------------------------------------------------------------------- + +function wireBottomSheet(): void { + // wired in Task 11 +} + +// --------------------------------------------------------------------------- +// Drawer stubs (implemented in Task 10) +// --------------------------------------------------------------------------- + +function closeDrawer(): void { + state.drawerOpen = false; + document.getElementById('vault-drawer')?.classList.remove('vault-drawer--open'); +} + +function renderDrawer(_item: unknown): void { + // implemented in Task 10 +} + +// --------------------------------------------------------------------------- +// Item selection stub (implemented in Task 10) +// --------------------------------------------------------------------------- + +async function selectItemForDrawer(id: string): Promise { + // implemented in Task 10 + state.selectedId = id; } // --------------------------------------------------------------------------- @@ -297,7 +346,8 @@ function wireSidebar(): void { const searchInput = document.getElementById('vault-search') as HTMLInputElement | null; searchInput?.addEventListener('input', () => { state.searchQuery = searchInput.value; - renderSidebarList(); + renderSidebarCategories(); + renderListPane(); }); // Nav buttons @@ -350,7 +400,7 @@ function isEditableTarget(target: EventTarget | null): boolean { } // --------------------------------------------------------------------------- -// Sidebar list +// Sidebar category nav // --------------------------------------------------------------------------- function getFilteredEntries(): Array<[ItemId, ManifestEntry]> { @@ -371,70 +421,96 @@ function getFilteredEntries(): Array<[ItemId, ManifestEntry]> { return filtered; } -function renderSidebarList(): void { - const container = document.getElementById('vault-sidebar-list'); +function renderSidebarCategories(): void { + const container = document.getElementById('vault-categories'); if (!container) return; const filtered = getFilteredEntries(); - - // Group by type - const groups = new Map>(); - for (const entry of filtered) { - const t = entry[1].type; - if (!groups.has(t)) groups.set(t, []); - groups.get(t)!.push(entry); - } - - if (filtered.length === 0) { - container.innerHTML = '
no items
'; - return; - } - - let html = ''; - // Stable type ordering const typeOrder: ItemType[] = ['login', 'secure_note', 'identity', 'card', 'key', 'document', 'totp']; + + const allCount = filtered.length; + const isAllActive = !state.activeGroup && state.view === 'list'; + + let html = ` + + `; + for (const t of typeOrder) { - const items = groups.get(t); - if (!items || items.length === 0) continue; - html += `
${typeIcon(t)} ${escapeHtml(typeLabel(t))}
`; - for (const [id, e] of items) { - const sel = id === state.selectedId ? ' selected' : ''; - const meta = e.icon_hint ? escapeHtml(e.icon_hint) : ''; - html += ` -
- ${escapeHtml(e.title)} - ${meta ? `` : ''} -
- `; - } + const count = filtered.filter(([, e]) => e.type === t).length; + if (count === 0 && allCount > 0) continue; + const isActive = state.activeGroup === t; + html += ` + + `; } container.innerHTML = html; - // Wire clicks - container.querySelectorAll('.vault-entry').forEach((el) => { - el.addEventListener('click', async () => { - const id = (el as HTMLElement).dataset.id!; - await selectItem(id); + container.querySelectorAll('.vault-category-row').forEach((btn) => { + btn.addEventListener('click', () => { + state.activeGroup = btn.dataset.group || null; + state.drawerOpen = false; + state.selectedId = null; + state.selectedItem = null; + renderSidebarCategories(); + renderListPane(); + closeDrawer(); }); }); } -async function selectItem(id: ItemId): Promise { - state.loading = true; - const resp = await sendMessage({ type: 'get_item', id }); - if (resp.ok) { - const data = resp.data as { item: Item }; - state.selectedId = id; - state.selectedItem = data.item; - state.loading = false; - setHash('detail', id); - renderSidebarList(); - renderPane(); - } else { - state.loading = false; - state.error = (resp as { error: string }).error; +// --------------------------------------------------------------------------- +// List pane +// --------------------------------------------------------------------------- + +function renderListPane(): void { + const pane = document.getElementById('vault-list-pane'); + if (!pane) return; + + const group = state.activeGroup as ItemType | null; + let items = getFilteredEntries(); + if (group) items = items.filter(([, e]) => e.type === group); + + if (items.length === 0) { + pane.innerHTML = ` +
+ +
${state.searchQuery ? `No results for "${escapeHtml(state.searchQuery)}"` : 'No items yet'}
+
${state.searchQuery ? 'Try a shorter search term.' : 'Click + new item to get started.'}
+
+ `; + return; } + + pane.innerHTML = items.map(([id, e]) => { + const sel = id === state.selectedId ? ' vault-list-row--selected' : ''; + const subtitle = (e as any).icon_hint ?? (e.tags?.length > 0 ? e.tags.join(', ') : ''); + const modifiedAgo = e.modified ? relativeTime(e.modified) : ''; + return ` +
+ +
+
${escapeHtml(e.title)}
+ ${subtitle ? `
${escapeHtml(subtitle)}
` : ''} +
+ ${modifiedAgo ? `
${escapeHtml(modifiedAgo)}
` : ''} +
+ `; + }).join(''); + + pane.querySelectorAll('.vault-list-row').forEach((row) => { + row.addEventListener('click', async () => { + await selectItemForDrawer(row.dataset.id!); + }); + }); } // --------------------------------------------------------------------------- @@ -450,8 +526,8 @@ const SAVE_HINT = isMac ? '⌘+S to save' : 'Ctrl+S to save'; function renderFormWrapped(app: HTMLElement, mode: 'add' | 'edit'): void { const itemType = state.selectedItem?.type ?? state.newType ?? 'login'; - const typeLabel = itemType.replace('_', ' '); - const titleText = mode === 'add' ? `new ${typeLabel}` : `edit ${typeLabel}`; + const typeLabelText = itemType.replace('_', ' '); + const titleText = mode === 'add' ? `new ${typeLabelText}` : `edit ${typeLabelText}`; const wrapper = document.createElement('div'); wrapper.className = 'form-pane'; wrapper.innerHTML = ` @@ -678,7 +754,8 @@ document.addEventListener('DOMContentLoaded', async () => { if ((route.view === 'detail' || route.view === 'edit') && route.id) { if (state.selectedId === route.id && state.selectedItem) { renderPane(); - renderSidebarList(); + renderSidebarCategories(); + renderListPane(); return; } // Need to fetch the item @@ -689,7 +766,30 @@ document.addEventListener('DOMContentLoaded', async () => { // For non-item views, just re-render the pane state.selectedId = null; state.selectedItem = null; - renderSidebarList(); + renderSidebarCategories(); + renderListPane(); renderPane(); }); }); + +// --------------------------------------------------------------------------- +// Legacy selectItem — used by hash-change deep linking +// --------------------------------------------------------------------------- + +async function selectItem(id: ItemId): Promise { + state.loading = true; + const resp = await sendMessage({ type: 'get_item', id }); + if (resp.ok) { + const data = resp.data as { item: Item }; + state.selectedId = id; + state.selectedItem = data.item; + state.loading = false; + setHash('detail', id); + renderSidebarCategories(); + renderListPane(); + renderPane(); + } else { + state.loading = false; + state.error = (resp as { error: string }).error; + } +}