Introduces vault-context.ts (VaultView/HashRoute/VaultState types, the VaultController contract, and the pure helpers escapeHtml/typeIcon/typeLabel/ getFilteredEntries). Extracts the shell concerns — render entry, lock screen, 3-column shell scaffolding, type picker panel, color-scheme apply, and the session_expired listener — into vault-shell.ts. vault.ts now assembles the ctx object and delegates shell rendering through it. No behavior change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
256 lines
10 KiB
TypeScript
256 lines
10 KiB
TypeScript
// Vault-tab shell: render entry point, lock screen, 3-column shell
|
|
// scaffolding, the right-side type-picker panel, color-scheme apply, and the
|
|
// session_expired listener. Each function receives the VaultController (`ctx`)
|
|
// and reaches sibling concerns through it; pure helpers come from
|
|
// vault-context. vault.ts owns the state singleton and assembles the ctx.
|
|
|
|
import type { ItemType } from '../shared/types';
|
|
import { lookupErrorCopy } from '../shared/error-copy';
|
|
import {
|
|
GLYPH_TRASH, GLYPH_DEVICES, GLYPH_SETTINGS, GLYPH_LOCK, GLYPH_HISTORY,
|
|
} from '../shared/glyphs';
|
|
import { applyColorScheme } from '../shared/color-scheme';
|
|
import {
|
|
type VaultController, escapeHtml, typeIcon,
|
|
} from './vault-context';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Type picker (right side panel)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const PICKER_TYPES: Array<{ type: ItemType; label: string }> = [
|
|
{ type: 'login', label: 'Login' },
|
|
{ type: 'secure_note', label: 'Secure Note' },
|
|
{ type: 'totp', label: 'TOTP' },
|
|
{ type: 'card', label: 'Card' },
|
|
{ type: 'identity', label: 'Identity' },
|
|
{ type: 'key', label: 'SSH / API Key' },
|
|
{ type: 'document', label: 'Document' },
|
|
];
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Render entry point
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function render(ctx: VaultController): void {
|
|
const app = document.getElementById('vault-app');
|
|
if (!app) return;
|
|
|
|
if (!ctx.state.unlocked) {
|
|
renderLockScreen(ctx, app);
|
|
} else {
|
|
renderShell(ctx, app);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Lock screen
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function renderErrorBlock(code: string | null | undefined): string {
|
|
if (!code) return '';
|
|
const copy = lookupErrorCopy(code);
|
|
const ctaHtml = copy.cta
|
|
? `<button class="btn btn-primary error-cta" data-cta="${escapeHtml(copy.cta.action ?? '')}">${escapeHtml(copy.cta.label)}</button>`
|
|
: '';
|
|
return `
|
|
<div class="error error-block">
|
|
<div class="error-title">${escapeHtml(copy.title)}</div>
|
|
<div class="error-body">${escapeHtml(copy.body)}</div>
|
|
${ctaHtml}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
export function renderLockScreen(ctx: VaultController, app: HTMLElement): void {
|
|
app.innerHTML = `
|
|
<div class="vault-lock-screen">
|
|
<img class="brand-logo" src="icons/relicario-logo.svg" alt="">
|
|
<span class="brand">Relicario</span>
|
|
<div class="vault-lock-screen__form">
|
|
<input type="password" id="vault-passphrase" placeholder="passphrase" autocomplete="off" />
|
|
<button class="btn btn-primary" id="vault-unlock-btn" style="width:100%;">unlock</button>
|
|
${renderErrorBlock(ctx.state.error)}
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
const input = document.getElementById('vault-passphrase') as HTMLInputElement;
|
|
const btn = document.getElementById('vault-unlock-btn')!;
|
|
|
|
const doUnlock = async () => {
|
|
const passphrase = input.value;
|
|
if (!passphrase) return;
|
|
btn.textContent = 'unlocking...';
|
|
btn.setAttribute('disabled', 'true');
|
|
const resp = await ctx.sendMessage({ type: 'unlock', passphrase });
|
|
if (resp.ok) {
|
|
ctx.state.unlocked = true;
|
|
ctx.state.error = null;
|
|
await ctx.loadManifest();
|
|
render(ctx);
|
|
} else {
|
|
ctx.state.error = resp.error ?? 'unlock failed';
|
|
render(ctx);
|
|
}
|
|
};
|
|
|
|
btn.addEventListener('click', doUnlock);
|
|
input.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter') doUnlock();
|
|
});
|
|
input.focus();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Shell (3-column: sidebar + list pane + drawer)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function renderShell(ctx: VaultController, app: HTMLElement): void {
|
|
if (!app.querySelector('.vault-shell')) {
|
|
app.innerHTML = `
|
|
<div class="vault-shell">
|
|
<div class="vault-sidebar">
|
|
<div class="vault-sidebar__header">
|
|
<img class="brand-logo" src="icons/relicario-logo.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 vault-sidebar__nav-item--primary" 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="devices" title="Devices">${GLYPH_DEVICES} <span class="vault-sidebar__nav-label">devices</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="history" title="History">${GLYPH_HISTORY} <span class="vault-sidebar__nav-label">history</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 class="vault-list-pane" id="vault-list-pane"></div>
|
|
<div class="vault-pane" id="vault-pane"></div>
|
|
<div class="vault-drawer" id="vault-drawer"></div>
|
|
<div class="vault-type-panel-scrim" id="vault-type-scrim"></div>
|
|
<aside class="vault-type-panel" id="vault-type-panel" aria-label="Choose item type"></aside>
|
|
</div>
|
|
`;
|
|
ctx.wireSidebar();
|
|
wireTypePanel(ctx);
|
|
}
|
|
|
|
applyShellViewClass(ctx);
|
|
ctx.renderSidebarCategories();
|
|
if (ctx.state.view === 'list') {
|
|
ctx.renderListPane();
|
|
if (ctx.state.drawerOpen && ctx.state.selectedItem) {
|
|
ctx.renderDrawer(ctx.state.selectedItem);
|
|
}
|
|
} else {
|
|
ctx.renderPane();
|
|
}
|
|
}
|
|
|
|
// Toggle which middle column is visible based on the current view.
|
|
// list view → list-pane (+ optional drawer); other views → vault-pane.
|
|
export function applyShellViewClass(ctx: VaultController): void {
|
|
const shell = document.querySelector('.vault-shell');
|
|
if (!shell) return;
|
|
shell.classList.toggle('vault-shell--list', ctx.state.view === 'list');
|
|
shell.classList.toggle('vault-shell--pane', ctx.state.view !== 'list');
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Right-side type picker panel
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function wireTypePanel(ctx: VaultController): void {
|
|
document.getElementById('vault-type-scrim')?.addEventListener('click', () => closeTypePanel(ctx));
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Escape' && ctx.state.typePanelOpen) closeTypePanel(ctx);
|
|
});
|
|
}
|
|
|
|
export function openTypePanel(ctx: VaultController): void {
|
|
const panel = document.getElementById('vault-type-panel');
|
|
const scrim = document.getElementById('vault-type-scrim');
|
|
if (!panel || !scrim) return;
|
|
|
|
panel.innerHTML = `
|
|
<div class="vault-type-panel__head">
|
|
<div class="vault-type-panel__title">New item</div>
|
|
<button class="vault-type-panel__close" id="vault-type-close" title="Close (Esc)" aria-label="Close">✕</button>
|
|
</div>
|
|
<div class="vault-type-panel__hint">Choose a type</div>
|
|
<div class="vault-type-list" role="menu">
|
|
${PICKER_TYPES.map((t) => `
|
|
<button class="vault-type-item" data-type="${t.type}" role="menuitem">
|
|
<span class="vault-type-item__icon" aria-hidden="true">${typeIcon(t.type)}</span>
|
|
<span class="vault-type-item__name">${escapeHtml(t.label)}</span>
|
|
</button>
|
|
`).join('')}
|
|
</div>
|
|
`;
|
|
|
|
panel.classList.add('vault-type-panel--open');
|
|
scrim.classList.add('vault-type-panel-scrim--visible');
|
|
ctx.state.typePanelOpen = true;
|
|
|
|
panel.querySelector('#vault-type-close')?.addEventListener('click', () => closeTypePanel(ctx));
|
|
|
|
panel.querySelectorAll<HTMLButtonElement>('[data-type]').forEach((btn) => {
|
|
btn.addEventListener('click', () => {
|
|
const type = btn.dataset.type as ItemType;
|
|
closeTypePanel(ctx);
|
|
// Use the host's navigate hook so view + hash + visibility all update
|
|
// together. This was the bug: bare setHash + renderPane left the
|
|
// shell stuck in list view with #vault-pane hidden.
|
|
ctx.state.newType = type;
|
|
ctx.state.selectedId = null;
|
|
ctx.state.selectedItem = null;
|
|
ctx.state.drawerOpen = false;
|
|
ctx.state.view = 'add';
|
|
ctx.setHash('add', type);
|
|
applyShellViewClass(ctx);
|
|
ctx.renderSidebarCategories();
|
|
ctx.renderPane();
|
|
});
|
|
});
|
|
|
|
// Focus first item for keyboard users
|
|
(panel.querySelector('.vault-type-item') as HTMLElement | null)?.focus();
|
|
}
|
|
|
|
export function closeTypePanel(ctx: VaultController): void {
|
|
document.getElementById('vault-type-panel')?.classList.remove('vault-type-panel--open');
|
|
document.getElementById('vault-type-scrim')?.classList.remove('vault-type-panel-scrim--visible');
|
|
ctx.state.typePanelOpen = false;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Color scheme + session-expired wiring (bootstrap helpers)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export async function applyVaultColorScheme(): Promise<void> {
|
|
await applyColorScheme();
|
|
|
|
chrome.storage.onChanged.addListener((changes, area) => {
|
|
if (area === 'sync' && 'password_display_scheme' in changes) {
|
|
void applyColorScheme();
|
|
}
|
|
});
|
|
}
|
|
|
|
export function wireSessionExpiredListener(ctx: VaultController): void {
|
|
chrome.runtime.onMessage.addListener((msg) => {
|
|
if (msg.type === 'session_expired') {
|
|
ctx.state.unlocked = false;
|
|
ctx.state.selectedId = null;
|
|
ctx.state.selectedItem = null;
|
|
ctx.state.entries = [];
|
|
ctx.state.error = null;
|
|
render(ctx);
|
|
}
|
|
});
|
|
}
|