feat(ext/vault): 3-column shell — sidebar category nav + list pane
This commit is contained in:
@@ -11,7 +11,7 @@ import type {
|
|||||||
import { registerHost } from '../shared/state';
|
import { registerHost } from '../shared/state';
|
||||||
import { lookupErrorCopy, type ErrorCta } from '../shared/error-copy';
|
import { lookupErrorCopy, type ErrorCta } from '../shared/error-copy';
|
||||||
import {
|
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_LOGIN, GLYPH_TYPE_SECURE_NOTE, GLYPH_TYPE_TOTP,
|
||||||
GLYPH_TYPE_CARD, GLYPH_TYPE_IDENTITY, GLYPH_TYPE_KEY, GLYPH_TYPE_DOCUMENT,
|
GLYPH_TYPE_CARD, GLYPH_TYPE_IDENTITY, GLYPH_TYPE_KEY, GLYPH_TYPE_DOCUMENT,
|
||||||
} from '../shared/glyphs';
|
} from '../shared/glyphs';
|
||||||
@@ -75,15 +75,24 @@ function typeIcon(t: ItemType): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function typeLabel(t: ItemType): string {
|
function typeLabel(t: ItemType): string {
|
||||||
switch (t) {
|
const labels: Record<ItemType, string> = {
|
||||||
case 'login': return 'Logins';
|
login: 'Login',
|
||||||
case 'secure_note': return 'Secure Notes';
|
secure_note: 'Secure Note',
|
||||||
case 'identity': return 'Identities';
|
identity: 'Identity',
|
||||||
case 'card': return 'Cards';
|
card: 'Card',
|
||||||
case 'key': return 'Keys';
|
key: 'SSH / API Key',
|
||||||
case 'document': return 'Documents';
|
document: 'Document',
|
||||||
case 'totp': return 'TOTP';
|
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;
|
selectedIndex: number;
|
||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
activeGroup: string | null;
|
activeGroup: string | null;
|
||||||
|
drawerOpen: boolean;
|
||||||
|
bottomSheetOpen: boolean;
|
||||||
vaultSettings: VaultSettings | null;
|
vaultSettings: VaultSettings | null;
|
||||||
generatorDefaults: GeneratorRequest | null;
|
generatorDefaults: GeneratorRequest | null;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
@@ -161,6 +172,8 @@ const state: VaultState = {
|
|||||||
selectedIndex: 0,
|
selectedIndex: 0,
|
||||||
searchQuery: '',
|
searchQuery: '',
|
||||||
activeGroup: null,
|
activeGroup: null,
|
||||||
|
drawerOpen: false,
|
||||||
|
bottomSheetOpen: false,
|
||||||
vaultSettings: null,
|
vaultSettings: null,
|
||||||
generatorDefaults: null,
|
generatorDefaults: null,
|
||||||
error: null,
|
error: null,
|
||||||
@@ -184,7 +197,8 @@ registerHost({
|
|||||||
navigate: (view: string, extras?: any) => {
|
navigate: (view: string, extras?: any) => {
|
||||||
Object.assign(state, { view, error: null, loading: false, ...extras });
|
Object.assign(state, { view, error: null, loading: false, ...extras });
|
||||||
setHash(view as VaultView);
|
setHash(view as VaultView);
|
||||||
renderSidebarList();
|
renderSidebarCategories();
|
||||||
|
renderListPane();
|
||||||
renderPane();
|
renderPane();
|
||||||
},
|
},
|
||||||
sendMessage,
|
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 {
|
function renderShell(app: HTMLElement): void {
|
||||||
// Only create the shell structure if it's not present yet
|
if (!app.querySelector('.vault-shell')) {
|
||||||
if (!app.querySelector('.vault-sidebar')) {
|
|
||||||
app.innerHTML = `
|
app.innerHTML = `
|
||||||
<div class="vault-sidebar">
|
<div class="vault-shell">
|
||||||
<div class="vault-sidebar__header">
|
<div class="vault-sidebar">
|
||||||
<img class="brand-logo" src="icons/relicario-logo-16.svg" alt="">
|
<div class="vault-sidebar__header">
|
||||||
<span class="brand">Relicario</span>
|
<img class="brand-logo" src="icons/relicario-logo-16.svg" alt="">
|
||||||
|
<span class="brand">Relicario</span>
|
||||||
|
</div>
|
||||||
|
<div class="vault-sidebar__search">
|
||||||
|
<input type="text" id="vault-search" placeholder="/ search…" />
|
||||||
|
</div>
|
||||||
|
<nav class="vault-sidebar__categories" id="vault-categories" aria-label="Item types"></nav>
|
||||||
|
<div class="vault-sidebar__nav">
|
||||||
|
<button class="vault-sidebar__nav-item" data-nav="add" title="New item">+ new item</button>
|
||||||
|
<button class="vault-sidebar__nav-item" data-nav="trash" title="Trash">${GLYPH_TRASH} <span class="vault-sidebar__nav-label">trash</span></button>
|
||||||
|
<button class="vault-sidebar__nav-item" data-nav="settings" title="Settings">${GLYPH_SETTINGS} <span class="vault-sidebar__nav-label">settings</span></button>
|
||||||
|
<button class="vault-sidebar__nav-item" data-nav="lock" title="Lock">${GLYPH_LOCK} <span class="vault-sidebar__nav-label">lock</span></button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="vault-sidebar__search">
|
<div class="vault-list-pane" id="vault-list-pane"></div>
|
||||||
<input type="text" id="vault-search" placeholder="/ search..." />
|
<div class="vault-drawer" id="vault-drawer"></div>
|
||||||
</div>
|
<div class="vault-bottom-sheet-scrim" id="vault-sheet-scrim"></div>
|
||||||
<div class="vault-sidebar__list" id="vault-sidebar-list"></div>
|
<div class="vault-bottom-sheet" id="vault-bottom-sheet"></div>
|
||||||
<div class="vault-sidebar__nav">
|
|
||||||
<button class="vault-sidebar__nav-item" data-nav="add">+ new item</button>
|
|
||||||
<button class="vault-sidebar__nav-item" data-nav="trash">${GLYPH_TRASH} trash</button>
|
|
||||||
<button class="vault-sidebar__nav-item" data-nav="devices">${GLYPH_DEVICES} devices</button>
|
|
||||||
<button class="vault-sidebar__nav-item" data-nav="settings">${GLYPH_SETTINGS} settings</button>
|
|
||||||
<button class="vault-sidebar__nav-item" data-nav="lock">${GLYPH_LOCK} lock</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="vault-pane vault-pane--empty" id="vault-pane">
|
|
||||||
select an item
|
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
wireSidebar();
|
wireSidebar();
|
||||||
|
wireBottomSheet();
|
||||||
}
|
}
|
||||||
|
|
||||||
renderSidebarList();
|
renderSidebarCategories();
|
||||||
renderPane();
|
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<void> {
|
||||||
|
// implemented in Task 10
|
||||||
|
state.selectedId = id;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -297,7 +346,8 @@ function wireSidebar(): void {
|
|||||||
const searchInput = document.getElementById('vault-search') as HTMLInputElement | null;
|
const searchInput = document.getElementById('vault-search') as HTMLInputElement | null;
|
||||||
searchInput?.addEventListener('input', () => {
|
searchInput?.addEventListener('input', () => {
|
||||||
state.searchQuery = searchInput.value;
|
state.searchQuery = searchInput.value;
|
||||||
renderSidebarList();
|
renderSidebarCategories();
|
||||||
|
renderListPane();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Nav buttons
|
// Nav buttons
|
||||||
@@ -350,7 +400,7 @@ function isEditableTarget(target: EventTarget | null): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Sidebar list
|
// Sidebar category nav
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function getFilteredEntries(): Array<[ItemId, ManifestEntry]> {
|
function getFilteredEntries(): Array<[ItemId, ManifestEntry]> {
|
||||||
@@ -371,70 +421,96 @@ function getFilteredEntries(): Array<[ItemId, ManifestEntry]> {
|
|||||||
return filtered;
|
return filtered;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderSidebarList(): void {
|
function renderSidebarCategories(): void {
|
||||||
const container = document.getElementById('vault-sidebar-list');
|
const container = document.getElementById('vault-categories');
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
const filtered = getFilteredEntries();
|
const filtered = getFilteredEntries();
|
||||||
|
|
||||||
// Group by type
|
|
||||||
const groups = new Map<ItemType, Array<[ItemId, ManifestEntry]>>();
|
|
||||||
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 = '<div class="empty">no items</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let html = '';
|
|
||||||
// Stable type ordering
|
|
||||||
const typeOrder: ItemType[] = ['login', 'secure_note', 'identity', 'card', 'key', 'document', 'totp'];
|
const typeOrder: ItemType[] = ['login', 'secure_note', 'identity', 'card', 'key', 'document', 'totp'];
|
||||||
|
|
||||||
|
const allCount = filtered.length;
|
||||||
|
const isAllActive = !state.activeGroup && state.view === 'list';
|
||||||
|
|
||||||
|
let html = `
|
||||||
|
<button class="vault-category-row ${isAllActive ? 'vault-category-row--active' : ''}" data-group="">
|
||||||
|
<span class="vault-category-row__icon">◈</span>
|
||||||
|
<span class="vault-category-row__label vault-sidebar__category-label">All items</span>
|
||||||
|
<span class="vault-category-row__count vault-sidebar__category-count">${allCount}</span>
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
|
||||||
for (const t of typeOrder) {
|
for (const t of typeOrder) {
|
||||||
const items = groups.get(t);
|
const count = filtered.filter(([, e]) => e.type === t).length;
|
||||||
if (!items || items.length === 0) continue;
|
if (count === 0 && allCount > 0) continue;
|
||||||
html += `<div class="vault-group-header">${typeIcon(t)} ${escapeHtml(typeLabel(t))}</div>`;
|
const isActive = state.activeGroup === t;
|
||||||
for (const [id, e] of items) {
|
html += `
|
||||||
const sel = id === state.selectedId ? ' selected' : '';
|
<button class="vault-category-row ${isActive ? 'vault-category-row--active' : ''}" data-group="${t}">
|
||||||
const meta = e.icon_hint ? escapeHtml(e.icon_hint) : '';
|
<span class="vault-category-row__icon">${typeIcon(t)}</span>
|
||||||
html += `
|
<span class="vault-category-row__label vault-sidebar__category-label">${typeLabel(t)}</span>
|
||||||
<div class="vault-entry${sel}" data-id="${escapeHtml(id)}">
|
<span class="vault-category-row__count vault-sidebar__category-count">${count}</span>
|
||||||
<span class="vault-entry__title">${escapeHtml(e.title)}</span>
|
</button>
|
||||||
${meta ? `<span class="vault-entry__meta">${meta}</span>` : ''}
|
`;
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
container.innerHTML = html;
|
container.innerHTML = html;
|
||||||
|
|
||||||
// Wire clicks
|
container.querySelectorAll<HTMLButtonElement>('.vault-category-row').forEach((btn) => {
|
||||||
container.querySelectorAll('.vault-entry').forEach((el) => {
|
btn.addEventListener('click', () => {
|
||||||
el.addEventListener('click', async () => {
|
state.activeGroup = btn.dataset.group || null;
|
||||||
const id = (el as HTMLElement).dataset.id!;
|
state.drawerOpen = false;
|
||||||
await selectItem(id);
|
state.selectedId = null;
|
||||||
|
state.selectedItem = null;
|
||||||
|
renderSidebarCategories();
|
||||||
|
renderListPane();
|
||||||
|
closeDrawer();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function selectItem(id: ItemId): Promise<void> {
|
// ---------------------------------------------------------------------------
|
||||||
state.loading = true;
|
// List pane
|
||||||
const resp = await sendMessage({ type: 'get_item', id });
|
// ---------------------------------------------------------------------------
|
||||||
if (resp.ok) {
|
|
||||||
const data = resp.data as { item: Item };
|
function renderListPane(): void {
|
||||||
state.selectedId = id;
|
const pane = document.getElementById('vault-list-pane');
|
||||||
state.selectedItem = data.item;
|
if (!pane) return;
|
||||||
state.loading = false;
|
|
||||||
setHash('detail', id);
|
const group = state.activeGroup as ItemType | null;
|
||||||
renderSidebarList();
|
let items = getFilteredEntries();
|
||||||
renderPane();
|
if (group) items = items.filter(([, e]) => e.type === group);
|
||||||
} else {
|
|
||||||
state.loading = false;
|
if (items.length === 0) {
|
||||||
state.error = (resp as { error: string }).error;
|
pane.innerHTML = `
|
||||||
|
<div class="empty-state">
|
||||||
|
<span class="empty-state__icon" aria-hidden="true">${state.searchQuery ? '⊘' : '◈'}</span>
|
||||||
|
<div class="empty-state__title">${state.searchQuery ? `No results for "${escapeHtml(state.searchQuery)}"` : 'No items yet'}</div>
|
||||||
|
<div class="empty-state__hint">${state.searchQuery ? 'Try a shorter search term.' : 'Click + new item to get started.'}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
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 `
|
||||||
|
<div class="vault-list-row${sel}" data-id="${escapeHtml(id)}">
|
||||||
|
<div class="vault-list-row__icon" aria-hidden="true">${typeIcon(e.type)}</div>
|
||||||
|
<div class="vault-list-row__text">
|
||||||
|
<div class="vault-list-row__title">${escapeHtml(e.title)}</div>
|
||||||
|
${subtitle ? `<div class="vault-list-row__subtitle">${escapeHtml(subtitle)}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
${modifiedAgo ? `<div class="vault-list-row__age">${escapeHtml(modifiedAgo)}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
pane.querySelectorAll<HTMLElement>('.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 {
|
function renderFormWrapped(app: HTMLElement, mode: 'add' | 'edit'): void {
|
||||||
const itemType = state.selectedItem?.type ?? state.newType ?? 'login';
|
const itemType = state.selectedItem?.type ?? state.newType ?? 'login';
|
||||||
const typeLabel = itemType.replace('_', ' ');
|
const typeLabelText = itemType.replace('_', ' ');
|
||||||
const titleText = mode === 'add' ? `new ${typeLabel}` : `edit ${typeLabel}`;
|
const titleText = mode === 'add' ? `new ${typeLabelText}` : `edit ${typeLabelText}`;
|
||||||
const wrapper = document.createElement('div');
|
const wrapper = document.createElement('div');
|
||||||
wrapper.className = 'form-pane';
|
wrapper.className = 'form-pane';
|
||||||
wrapper.innerHTML = `
|
wrapper.innerHTML = `
|
||||||
@@ -678,7 +754,8 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
if ((route.view === 'detail' || route.view === 'edit') && route.id) {
|
if ((route.view === 'detail' || route.view === 'edit') && route.id) {
|
||||||
if (state.selectedId === route.id && state.selectedItem) {
|
if (state.selectedId === route.id && state.selectedItem) {
|
||||||
renderPane();
|
renderPane();
|
||||||
renderSidebarList();
|
renderSidebarCategories();
|
||||||
|
renderListPane();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Need to fetch the item
|
// Need to fetch the item
|
||||||
@@ -689,7 +766,30 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
// For non-item views, just re-render the pane
|
// For non-item views, just re-render the pane
|
||||||
state.selectedId = null;
|
state.selectedId = null;
|
||||||
state.selectedItem = null;
|
state.selectedItem = null;
|
||||||
renderSidebarList();
|
renderSidebarCategories();
|
||||||
|
renderListPane();
|
||||||
renderPane();
|
renderPane();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Legacy selectItem — used by hash-change deep linking
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function selectItem(id: ItemId): Promise<void> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user