Compare commits
20 Commits
feature/v0
...
feature/v0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8baef5b3cb | ||
|
|
ddfb95d683 | ||
|
|
7df76c692a | ||
|
|
b4d253c60b | ||
|
|
c16adc4335 | ||
|
|
9a8cdf8e4f | ||
|
|
ade44b4ea1 | ||
|
|
1d4b018f9a | ||
|
|
882a89bedd | ||
|
|
37c20b28a6 | ||
|
|
3553150a53 | ||
|
|
b50f49b597 | ||
|
|
1ec8965910 | ||
|
|
ad6e4a2cd9 | ||
|
|
b768f649a2 | ||
|
|
8b197a7525 | ||
|
|
117716f6cf | ||
|
|
c5e8b52e12 | ||
|
|
a1b66a9147 | ||
|
|
934dfe05c2 |
@@ -0,0 +1,22 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
vi.stubGlobal('chrome', {
|
||||
storage: {
|
||||
local: {
|
||||
get: vi.fn((_keys: unknown, cb: (r: Record<string, unknown>) => void) => cb({})),
|
||||
set: vi.fn((_data: unknown, cb?: () => void) => cb?.()),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
import * as settingsMod from '../settings';
|
||||
|
||||
describe('settings module contract', () => {
|
||||
it('exports renderSettings as a function', () => {
|
||||
expect(typeof settingsMod.renderSettings).toBe('function');
|
||||
});
|
||||
|
||||
it('exports teardownSettings as a function', () => {
|
||||
expect(typeof settingsMod.teardownSettings).toBe('function');
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import { sendMessage, escapeHtml } from '../../shared/state';
|
||||
import type { AttachmentRef, VaultSettings } from '../../shared/types';
|
||||
import { GLYPH_TYPE_DOCUMENT } from '../../shared/glyphs';
|
||||
|
||||
export type DisclosureMode = 'edit' | 'view';
|
||||
|
||||
@@ -53,8 +54,8 @@ export function renderAttachmentsDisclosure(opts: AttachmentsDisclosureOpts): st
|
||||
const action = opts.mode === 'edit' ? '×' : '↓';
|
||||
const actionClass = opts.mode === 'edit' ? 'attachment-row__remove' : 'attachment-row__download';
|
||||
const iconHtml = isImage(a.mime_type)
|
||||
? `<span class="attachment-row__thumb" data-att-id="${escapeHtml(a.id)}" data-mime="${escapeHtml(a.mime_type)}">📄</span>`
|
||||
: `<span class="attachment-row__icon">📄</span>`;
|
||||
? `<span class="attachment-row__thumb" data-att-id="${escapeHtml(a.id)}" data-mime="${escapeHtml(a.mime_type)}">${GLYPH_TYPE_DOCUMENT}</span>`
|
||||
: `<span class="attachment-row__icon">${GLYPH_TYPE_DOCUMENT}</span>`;
|
||||
return `
|
||||
<div class="attachment-row" data-att-id="${escapeHtml(a.id)}">
|
||||
${iconHtml}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { getState, setState, sendMessage, navigate, escapeHtml } from '../../shared/state';
|
||||
import { colorizePassword } from '../../shared/password-coloring';
|
||||
import type { FieldHistoryView } from '../../shared/types';
|
||||
import { GLYPH_COPY } from '../../shared/glyphs';
|
||||
|
||||
function relativeTime(unixSec: number): string {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
@@ -75,7 +76,7 @@ export async function renderFieldHistory(app: HTMLElement): Promise<void> {
|
||||
${isCurrent ? '<span class="history-entry__current">current</span>' : ''}
|
||||
<span>${isCurrent ? 'set' : 'changed'} ${relativeTime(timestamp)}</span>
|
||||
</div>
|
||||
<button class="history-entry__copy" data-entry-copy="${escapeHtml(entryKey)}" title="Copy">📋</button>
|
||||
<button class="history-entry__copy" data-entry-copy="${escapeHtml(entryKey)}" title="Copy">${GLYPH_COPY}</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -140,7 +141,7 @@ export async function renderFieldHistory(app: HTMLElement): Promise<void> {
|
||||
const value = valueStore.get(key) ?? '';
|
||||
await navigator.clipboard.writeText(value);
|
||||
btn.textContent = '✓';
|
||||
setTimeout(() => { btn.textContent = '📋'; }, 1500);
|
||||
setTimeout(() => { btn.textContent = GLYPH_COPY; }, 1500);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,15 +3,19 @@
|
||||
|
||||
import { navigate, getState, setState, escapeHtml, popOutToTab, isInTab } from '../../shared/state';
|
||||
import type { Item, ItemType } from '../../shared/types';
|
||||
import {
|
||||
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';
|
||||
|
||||
const TYPE_OPTIONS: Array<{ type: ItemType; icon: string; label: string }> = [
|
||||
{ type: 'login', icon: '🔑', label: 'login' },
|
||||
{ type: 'secure_note', icon: '📝', label: 'secure note' },
|
||||
{ type: 'identity', icon: '👤', label: 'identity' },
|
||||
{ type: 'card', icon: '💳', label: 'card' },
|
||||
{ type: 'key', icon: '🔐', label: 'key' },
|
||||
{ type: 'document', icon: '📄', label: 'document' },
|
||||
{ type: 'totp', icon: '⏱️', label: 'totp' },
|
||||
const TYPE_OPTIONS: Array<{ type: ItemType; icon: string; label: string; description: string }> = [
|
||||
{ type: 'login', icon: GLYPH_TYPE_LOGIN, label: 'Login', description: 'Username + password' },
|
||||
{ type: 'secure_note', icon: GLYPH_TYPE_SECURE_NOTE, label: 'Secure Note', description: 'Encrypted text note' },
|
||||
{ type: 'identity', icon: GLYPH_TYPE_IDENTITY, label: 'Identity', description: 'Personal details' },
|
||||
{ type: 'card', icon: GLYPH_TYPE_CARD, label: 'Card', description: 'Credit / debit card' },
|
||||
{ type: 'key', icon: GLYPH_TYPE_KEY, label: 'SSH / API Key', description: 'Keys and tokens' },
|
||||
{ type: 'document', icon: GLYPH_TYPE_DOCUMENT, label: 'Document', description: 'File attachment' },
|
||||
{ type: 'totp', icon: GLYPH_TYPE_TOTP, label: 'TOTP', description: '2FA authenticator' },
|
||||
];
|
||||
import * as login from './types/login';
|
||||
import * as secureNote from './types/secure-note';
|
||||
@@ -54,36 +58,36 @@ export function renderItemForm(app: HTMLElement, mode: 'add' | 'edit'): void {
|
||||
function renderTypeSelection(app: HTMLElement): void {
|
||||
app.innerHTML = `
|
||||
<div class="pad">
|
||||
<div style="display:flex; align-items:center; gap:12px;">
|
||||
<button class="btn" id="back-btn">← back</button>
|
||||
<h3 style="margin:0;">new item</h3>
|
||||
<div style="display:flex; align-items:center; gap:12px; margin-bottom:12px;">
|
||||
<button class="btn" id="back-btn">◂ back</button>
|
||||
<span style="font-size:14px; font-weight:600;">New item</span>
|
||||
<span style="flex:1;"></span>
|
||||
${isInTab() ? '' : '<button class="btn" id="popout-btn" title="Open in tab">⤴</button>'}
|
||||
${isInTab() ? '' : '<button class="btn" id="popout-btn" title="Open in tab">⧉</button>'}
|
||||
</div>
|
||||
${isInTab() ? '<div class="form-subtitle">esc to cancel</div>' : '<div style="margin-bottom:16px;"></div>'}
|
||||
<div class="type-select-list">
|
||||
<div class="type-card-grid">
|
||||
${TYPE_OPTIONS.map((opt) => `
|
||||
<button class="type-select-row" data-type="${opt.type}">
|
||||
<span class="type-select-icon">${opt.icon}</span>
|
||||
<span>${escapeHtml(opt.label)}</span>
|
||||
<button class="type-card" data-type="${opt.type}">
|
||||
<span class="type-card__icon" aria-hidden="true">${opt.icon}</span>
|
||||
<span class="type-card__label">${escapeHtml(opt.label)}</span>
|
||||
<span class="type-card__desc">${escapeHtml(opt.description)}</span>
|
||||
</button>
|
||||
`).join('')}
|
||||
</div>
|
||||
<div class="keyhints"><span><kbd>Esc</kbd> back</span></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.getElementById('back-btn')?.addEventListener('click', () => navigate('list'));
|
||||
document.getElementById('popout-btn')?.addEventListener('click', popOutToTab);
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') navigate('list');
|
||||
}, { once: true });
|
||||
|
||||
document.querySelectorAll<HTMLButtonElement>('[data-type]').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
const type = btn.dataset.type as ItemType;
|
||||
setState({ newType: type });
|
||||
if (type === 'login' || type === 'secure_note') {
|
||||
renderItemForm(app, 'add');
|
||||
} else {
|
||||
popOutToTab();
|
||||
}
|
||||
renderItemForm(app, 'add');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,6 +3,13 @@
|
||||
/// to the detail view.
|
||||
|
||||
import { getState, setState, sendMessage, navigate, escapeHtml, openVaultTab } from '../../shared/state';
|
||||
import { showToast } from '../../shared/toast';
|
||||
import {
|
||||
GLYPH_VAULT_TAB,
|
||||
GLYPH_DEVICES, 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';
|
||||
import type { ItemId, ItemType, ManifestEntry, Item } from '../../shared/types';
|
||||
|
||||
/// Extract the display hostname from an icon_hint or fallback to the first tag.
|
||||
@@ -12,30 +19,46 @@ function metaLine(e: ManifestEntry): string {
|
||||
return '';
|
||||
}
|
||||
|
||||
/// Emoji icon per item type. Placeholder until we ship real SVG icons.
|
||||
/// Glyph icon per item type.
|
||||
function typeIcon(t: ItemType): string {
|
||||
switch (t) {
|
||||
case 'login': return '🔑';
|
||||
case 'secure_note': return '📝';
|
||||
case 'identity': return '🪪';
|
||||
case 'card': return '💳';
|
||||
case 'key': return '🗝';
|
||||
case 'document': return '📄';
|
||||
case 'totp': return '⏱';
|
||||
case 'login': return GLYPH_TYPE_LOGIN;
|
||||
case 'secure_note': return GLYPH_TYPE_SECURE_NOTE;
|
||||
case 'identity': return GLYPH_TYPE_IDENTITY;
|
||||
case 'card': return GLYPH_TYPE_CARD;
|
||||
case 'key': return GLYPH_TYPE_KEY;
|
||||
case 'document': return GLYPH_TYPE_DOCUMENT;
|
||||
case 'totp': return GLYPH_TYPE_TOTP;
|
||||
}
|
||||
}
|
||||
|
||||
function buildRowsHtml(): string {
|
||||
const state = getState();
|
||||
const filtered = getFilteredEntries();
|
||||
return filtered.length > 0
|
||||
? filtered.map(([id, e], i) => `
|
||||
if (filtered.length > 0) {
|
||||
return filtered.map(([id, e], i) => `
|
||||
<div class="entry-row ${i === state.selectedIndex ? 'selected' : ''}" data-id="${escapeHtml(id)}" data-index="${i}">
|
||||
<span class="entry-name"><span class="type-icon" aria-hidden="true">${typeIcon(e.type)}</span> ${escapeHtml(e.title)}${e.attachment_summaries.length > 0 ? ' <span class="entry-row__attach-indicator" title="has attachments">📎</span>' : ''}</span>
|
||||
<span class="entry-name"><span class="type-icon" aria-hidden="true">${typeIcon(e.type)}</span> ${escapeHtml(e.title)}${e.attachment_summaries.length > 0 ? ' <span class="entry-row__attach-indicator" title="has attachments">⊕</span>' : ''}</span>
|
||||
<span class="entry-meta">${escapeHtml(metaLine(e))}</span>
|
||||
</div>
|
||||
`).join('')
|
||||
: '<div class="empty">no items</div>';
|
||||
`).join('');
|
||||
}
|
||||
if (state.searchQuery) {
|
||||
return `
|
||||
<div class="empty-state">
|
||||
<span class="empty-state__icon" aria-hidden="true">⊘</span>
|
||||
<div class="empty-state__title">No results for "${escapeHtml(state.searchQuery)}"</div>
|
||||
<div class="empty-state__hint">Try a shorter search term.</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
return `
|
||||
<div class="empty-state">
|
||||
<span class="empty-state__icon" aria-hidden="true">◈</span>
|
||||
<div class="empty-state__title">No items yet</div>
|
||||
<div class="empty-state__hint">Press <kbd>+</kbd> to add your first item.</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function updateItemList(): void {
|
||||
@@ -66,7 +89,7 @@ export function renderItemList(app: HTMLElement): void {
|
||||
<button class="btn" id="new-btn" style="font-size:11px;">+ new</button>
|
||||
<button class="btn" id="sync-btn" style="font-size:11px;">sync</button>
|
||||
<span style="flex:1;"></span>
|
||||
<button class="btn" id="vault-btn" style="font-size:11px;" title="Open vault (Shift+F)">⤴</button>
|
||||
<button class="btn" id="vault-btn" style="font-size:11px;" title="Open vault (Shift+F)">${GLYPH_VAULT_TAB}</button>
|
||||
<button class="btn" id="settings-btn" style="font-size:11px;">settings</button>
|
||||
<button class="btn" id="lock-btn" style="font-size:11px;">lock</button>
|
||||
</div>
|
||||
@@ -108,11 +131,14 @@ export function renderItemList(app: HTMLElement): void {
|
||||
if (listResp.ok) {
|
||||
const data = listResp.data as { items: Array<[ItemId, ManifestEntry]> };
|
||||
setState({ entries: data.items, loading: false });
|
||||
showToast('Synced', 'success');
|
||||
return;
|
||||
}
|
||||
setState({ loading: false, error: listResp.error });
|
||||
showToast(listResp.error ?? 'Sync failed', 'error');
|
||||
} else {
|
||||
setState({ loading: false, error: resp.error });
|
||||
showToast(resp.error ?? 'Sync failed', 'error');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -253,8 +279,8 @@ function handleListKeydown(e: KeyboardEvent): void {
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
const SETTINGS_OPTIONS: Array<{ view: 'settings' | 'settings-vault'; icon: string; label: string }> = [
|
||||
{ view: 'settings', icon: '🖥', label: 'device settings' },
|
||||
{ view: 'settings-vault', icon: '🔐', label: 'vault settings' },
|
||||
{ view: 'settings', icon: GLYPH_DEVICES, label: 'device settings' },
|
||||
{ view: 'settings-vault', icon: GLYPH_LOCK, label: 'vault settings' },
|
||||
];
|
||||
|
||||
function showSettingsPicker(anchor: HTMLElement): void {
|
||||
|
||||
@@ -1,18 +1,111 @@
|
||||
/// Settings view — capture toggle, prompt style, and blacklist management.
|
||||
|
||||
import { sendMessage, navigate, escapeHtml } from '../../shared/state';
|
||||
import type { DeviceSettings } from '../../shared/types';
|
||||
import { GLYPH_TRASH, GLYPH_DEVICES } from '../../shared/glyphs';
|
||||
import { sendMessage, escapeHtml, openVaultTab } from '../../shared/state';
|
||||
import type { VaultSettings, DeviceSettings, TrashRetention, HistoryRetention } from '../../shared/types';
|
||||
import type { ColorScheme } from '../../shared/color-scheme';
|
||||
import {
|
||||
loadColorScheme, saveColorScheme, resetColorScheme,
|
||||
DEFAULT_DIGIT_COLOR, DEFAULT_SYMBOL_COLOR,
|
||||
} from '../../shared/color-scheme';
|
||||
import { colorizePassword } from '../../shared/password-coloring';
|
||||
import { openGeneratorPanel, closeGeneratorPanel, isGeneratorPanelOpen } from './generator-panel';
|
||||
import { renderSecuritySection, teardownSecuritySection } from './settings-security';
|
||||
|
||||
export async function renderSettings(app: HTMLElement): Promise<void> {
|
||||
app.innerHTML = '<div class="pad" style="text-align:center; padding-top:20px;"><span class="spinner"></span></div>';
|
||||
type SettingsSection =
|
||||
| 'autofill'
|
||||
| 'display'
|
||||
| 'security'
|
||||
| 'generator'
|
||||
| 'retention'
|
||||
| 'backup'
|
||||
| 'import';
|
||||
|
||||
// Load settings and blacklist in parallel
|
||||
const NAV_ITEMS: Array<{ id: SettingsSection; icon: string; label: string; group: 'device' | 'vault' }> = [
|
||||
{ id: 'autofill', icon: '⊙', label: 'Autofill', group: 'device' },
|
||||
{ id: 'display', icon: '◈', label: 'Display', group: 'device' },
|
||||
{ id: 'security', icon: '◉', label: 'Security', group: 'vault' },
|
||||
{ id: 'generator', icon: '↻', label: 'Generator', group: 'vault' },
|
||||
{ id: 'retention', icon: '▦', label: 'Retention', group: 'vault' },
|
||||
{ id: 'backup', icon: '⤓', label: 'Backup', group: 'vault' },
|
||||
{ id: 'import', icon: '≡', label: 'Import', group: 'vault' },
|
||||
];
|
||||
|
||||
let activeSection: SettingsSection = 'autofill';
|
||||
let activeKeyHandler: ((e: KeyboardEvent) => void) | null = null;
|
||||
let pendingVaultSettings: VaultSettings | null = null;
|
||||
let sessionHandle: number | null = null;
|
||||
|
||||
export async function renderSettings(container: HTMLElement): Promise<void> {
|
||||
container.innerHTML = `
|
||||
<div class="settings-layout">
|
||||
<nav class="settings-nav" id="settings-nav">
|
||||
<div class="settings-nav__group-label">Device</div>
|
||||
${NAV_ITEMS.filter(n => n.group === 'device').map(navItemHtml).join('')}
|
||||
<div class="settings-nav__group-label">Vault</div>
|
||||
${NAV_ITEMS.filter(n => n.group === 'vault').map(navItemHtml).join('')}
|
||||
</nav>
|
||||
<div class="settings-content" id="settings-content"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const unlockedResp = await sendMessage({ type: 'is_unlocked' });
|
||||
sessionHandle = (unlockedResp.ok && unlockedResp.data && (unlockedResp.data as { unlocked: boolean }).unlocked) ? 1 : null;
|
||||
|
||||
wireNav();
|
||||
await renderSection(activeSection);
|
||||
}
|
||||
|
||||
export function teardownSettings(): void {
|
||||
closeGeneratorPanel();
|
||||
teardownSecuritySection();
|
||||
if (activeKeyHandler) {
|
||||
document.removeEventListener('keydown', activeKeyHandler);
|
||||
activeKeyHandler = null;
|
||||
}
|
||||
pendingVaultSettings = null;
|
||||
sessionHandle = null;
|
||||
}
|
||||
|
||||
function navItemHtml(item: (typeof NAV_ITEMS)[0]): string {
|
||||
const active = item.id === activeSection ? ' settings-nav__item--active' : '';
|
||||
return `
|
||||
<button class="settings-nav__item${active}" data-section="${item.id}">
|
||||
<span class="settings-nav__icon" aria-hidden="true">${item.icon}</span>
|
||||
<span class="settings-nav__label">${escapeHtml(item.label)}</span>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
function wireNav(): void {
|
||||
document.getElementById('settings-nav')?.querySelectorAll<HTMLButtonElement>('[data-section]')
|
||||
.forEach((btn) => {
|
||||
btn.addEventListener('click', async () => {
|
||||
teardownSecuritySection();
|
||||
closeGeneratorPanel();
|
||||
activeSection = btn.dataset.section as SettingsSection;
|
||||
document.querySelectorAll('.settings-nav__item').forEach(b => b.classList.remove('settings-nav__item--active'));
|
||||
btn.classList.add('settings-nav__item--active');
|
||||
await renderSection(activeSection);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function renderSection(section: SettingsSection): Promise<void> {
|
||||
const content = document.getElementById('settings-content');
|
||||
if (!content) return;
|
||||
|
||||
switch (section) {
|
||||
case 'autofill': return renderAutofillSection(content);
|
||||
case 'display': return renderDisplaySection(content);
|
||||
case 'security': return renderSecuritySection(content, sessionHandle);
|
||||
case 'generator': return renderGeneratorSection(content);
|
||||
case 'retention': return renderRetentionSection(content);
|
||||
case 'backup': return renderBackupSection(content);
|
||||
case 'import': return renderImportSection(content);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Section stubs (filled in by Tasks 3-9) ---
|
||||
|
||||
async function renderAutofillSection(content: HTMLElement): Promise<void> {
|
||||
const [settingsResp, blacklistResp] = await Promise.all([
|
||||
sendMessage({ type: 'get_settings' }),
|
||||
sendMessage({ type: 'get_blacklist' }),
|
||||
@@ -26,166 +119,314 @@ export async function renderSettings(app: HTMLElement): Promise<void> {
|
||||
? (blacklistResp.data as { blacklist: string[] }).blacklist
|
||||
: [];
|
||||
|
||||
const blacklistHtml = blacklist.length > 0
|
||||
? blacklist.map((h) => `
|
||||
<div style="display:flex; align-items:center; justify-content:space-between; padding:4px 0; border-bottom:1px solid #21262d;">
|
||||
<span style="font-size:12px; overflow:hidden; text-overflow:ellipsis;">${escapeHtml(h)}</span>
|
||||
<button class="relicario-remove-bl" data-hostname="${escapeHtml(h)}" style="
|
||||
background:transparent; color:#ab2b20; border:none; cursor:pointer;
|
||||
font-size:11px; padding:2px 6px;
|
||||
">remove</button>
|
||||
</div>
|
||||
`).join('')
|
||||
: '<p class="muted" style="font-size:12px;">no blacklisted sites</p>';
|
||||
|
||||
app.innerHTML = `
|
||||
<div class="pad" style="padding-top:12px;">
|
||||
<div style="display:flex; align-items:center; margin-bottom:16px;">
|
||||
<button id="settings-back" class="btn" style="font-size:11px; margin-right:8px;">←</button>
|
||||
<span style="font-size:14px; font-weight:600;">settings</span>
|
||||
content.innerHTML = `
|
||||
<h3 class="settings-section-title">Capture</h3>
|
||||
<div class="setting-row">
|
||||
<div class="setting-row__info">
|
||||
<div class="setting-row__title">Auto-detect logins</div>
|
||||
<div class="setting-row__desc">Show a prompt when a login form is detected.</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom:16px;">
|
||||
<label style="display:flex; align-items:center; gap:8px; cursor:pointer; font-size:13px;">
|
||||
<input type="checkbox" id="capture-enabled" ${settings.captureEnabled ? 'checked' : ''}>
|
||||
auto-detect logins
|
||||
</label>
|
||||
<div class="setting-row__control">
|
||||
<input type="checkbox" id="capture-enabled" ${settings.captureEnabled ? 'checked' : ''}>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom:16px;">
|
||||
<div style="font-size:12px; color:#8b949e; margin-bottom:6px;">prompt style</div>
|
||||
<div style="display:flex; gap:8px;">
|
||||
<button id="style-bar" class="btn" style="font-size:11px; ${settings.captureStyle === 'bar' ? 'background:#7c5719; color:#fff;' : ''}">bar</button>
|
||||
<button id="style-toast" class="btn" style="font-size:11px; ${settings.captureStyle === 'toast' ? 'background:#7c5719; color:#fff;' : ''}">toast</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div class="setting-row__info">
|
||||
<div class="setting-row__title">Prompt style</div>
|
||||
<div class="setting-row__desc">How to prompt when a login is detected.</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom:16px;">
|
||||
<button class="btn" id="trash-btn" style="width:100%;margin-bottom:8px;">${GLYPH_TRASH} trash</button>
|
||||
<button class="btn" id="devices-btn" style="width:100%;margin-bottom:8px;">${GLYPH_DEVICES} devices</button>
|
||||
<button class="btn" id="sync-now-btn" style="width:100%;margin-bottom:8px;">📤 Sync now</button>
|
||||
<div id="sync-status" class="muted" style="font-size:12px;min-height:16px;"></div>
|
||||
<div class="setting-row__control" style="display:flex; gap:6px;">
|
||||
<button class="btn ${settings.captureStyle === 'bar' ? 'btn-active' : ''}" id="style-bar" style="font-size:11px;">bar</button>
|
||||
<button class="btn ${settings.captureStyle === 'toast' ? 'btn-active' : ''}" id="style-toast" style="font-size:11px;">toast</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom:16px;" id="display-section-container">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div style="font-size:12px; color:#8b949e; margin-bottom:6px;">blacklisted sites</div>
|
||||
<div id="blacklist-container">
|
||||
${blacklistHtml}
|
||||
</div>
|
||||
</div>
|
||||
<h3 class="settings-section-title" style="margin-top:20px;">Blocked sites</h3>
|
||||
<div id="blacklist-container">
|
||||
${blacklist.length > 0
|
||||
? blacklist.map((h) => `
|
||||
<div class="setting-row">
|
||||
<div class="setting-row__info">
|
||||
<div class="setting-row__title">${escapeHtml(h)}</div>
|
||||
</div>
|
||||
<button class="btn remove-bl" data-hostname="${escapeHtml(h)}" style="font-size:11px;">remove</button>
|
||||
</div>
|
||||
`).join('')
|
||||
: '<p class="muted" style="font-size:12px; padding:8px 0;">No blocked sites.</p>'}
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Back button
|
||||
document.getElementById('settings-back')?.addEventListener('click', () => {
|
||||
navigate('locked');
|
||||
});
|
||||
|
||||
// Navigation buttons
|
||||
document.getElementById('trash-btn')?.addEventListener('click', () => navigate('trash'));
|
||||
document.getElementById('devices-btn')?.addEventListener('click', () => navigate('devices'));
|
||||
|
||||
// Sync now button
|
||||
document.getElementById('sync-now-btn')?.addEventListener('click', async () => {
|
||||
const btn = document.getElementById('sync-now-btn') as HTMLButtonElement | null;
|
||||
const status = document.getElementById('sync-status');
|
||||
if (!btn || !status) return;
|
||||
btn.disabled = true;
|
||||
status.textContent = 'syncing...';
|
||||
const result = await sendMessage({ type: 'sync' });
|
||||
btn.disabled = false;
|
||||
status.textContent = result.ok ? 'synced ✓' : `sync failed: ${result.error}`;
|
||||
});
|
||||
|
||||
// Capture enabled toggle
|
||||
document.getElementById('capture-enabled')?.addEventListener('change', async (e) => {
|
||||
const checked = (e.target as HTMLInputElement).checked;
|
||||
await sendMessage({ type: 'update_settings', settings: { captureEnabled: checked } });
|
||||
const enabled = (e.target as HTMLInputElement).checked;
|
||||
await sendMessage({ type: 'update_settings', settings: { captureEnabled: enabled } });
|
||||
});
|
||||
|
||||
// Style buttons
|
||||
document.getElementById('style-bar')?.addEventListener('click', async () => {
|
||||
await sendMessage({ type: 'update_settings', settings: { captureStyle: 'bar' } });
|
||||
renderSettings(app);
|
||||
renderAutofillSection(content);
|
||||
});
|
||||
|
||||
document.getElementById('style-toast')?.addEventListener('click', async () => {
|
||||
await sendMessage({ type: 'update_settings', settings: { captureStyle: 'toast' } });
|
||||
renderSettings(app);
|
||||
renderAutofillSection(content);
|
||||
});
|
||||
|
||||
// Blacklist remove buttons
|
||||
document.querySelectorAll('.relicario-remove-bl').forEach((btn) => {
|
||||
content.querySelectorAll<HTMLButtonElement>('.remove-bl').forEach((btn) => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const hostname = (btn as HTMLElement).dataset.hostname;
|
||||
if (hostname) {
|
||||
await sendMessage({ type: 'remove_blacklist', hostname });
|
||||
renderSettings(app);
|
||||
}
|
||||
const host = btn.dataset.hostname;
|
||||
if (!host) return;
|
||||
await sendMessage({ type: 'remove_blacklist', hostname: host });
|
||||
renderAutofillSection(content);
|
||||
});
|
||||
});
|
||||
|
||||
// Render Display section after the rest of the DOM is ready
|
||||
await renderDisplaySection();
|
||||
}
|
||||
|
||||
function updateSwatch(swatch: HTMLElement, digitColor: string, symbolColor: string): void {
|
||||
swatch.style.setProperty('--relicario-pwd-digit-color', digitColor);
|
||||
swatch.style.setProperty('--relicario-pwd-symbol-color', symbolColor);
|
||||
swatch.innerHTML = '';
|
||||
swatch.appendChild(colorizePassword('Abc123!@#xyz'));
|
||||
}
|
||||
|
||||
async function renderDisplaySection(): Promise<void> {
|
||||
// The Display section container must be present in the DOM before we call this
|
||||
const container = document.getElementById('display-section-container');
|
||||
if (!container) return;
|
||||
|
||||
async function renderDisplaySection(content: HTMLElement): Promise<void> {
|
||||
const scheme = await loadColorScheme();
|
||||
|
||||
container.innerHTML = `
|
||||
<div style="font-size:12px; color:#8b949e; margin-bottom:6px;">display</div>
|
||||
<div style="margin-bottom:8px;">
|
||||
<label style="display:flex; align-items:center; gap:8px; font-size:13px;">
|
||||
<input type="color" id="display-digit-color" value="${escapeHtml(scheme.digit_color)}">
|
||||
digit color
|
||||
</label>
|
||||
content.innerHTML = `
|
||||
<h3 class="settings-section-title">Password coloring</h3>
|
||||
<div class="setting-row">
|
||||
<div class="setting-row__info">
|
||||
<div class="setting-row__title">Digit color</div>
|
||||
</div>
|
||||
<div class="setting-row__control">
|
||||
<input type="color" id="digit-color" value="${escapeHtml(scheme.digit_color)}">
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-bottom:8px;">
|
||||
<label style="display:flex; align-items:center; gap:8px; font-size:13px;">
|
||||
<input type="color" id="display-symbol-color" value="${escapeHtml(scheme.symbol_color)}">
|
||||
symbol color
|
||||
</label>
|
||||
<div class="setting-row">
|
||||
<div class="setting-row__info">
|
||||
<div class="setting-row__title">Symbol color</div>
|
||||
</div>
|
||||
<div class="setting-row__control">
|
||||
<input type="color" id="symbol-color" value="${escapeHtml(scheme.symbol_color)}">
|
||||
</div>
|
||||
</div>
|
||||
<div id="display-swatch" class="color-preview-swatch"></div>
|
||||
<div style="margin-top:8px;">
|
||||
<button id="display-reset" class="btn" style="font-size:11px;">reset to defaults</button>
|
||||
<div class="setting-row">
|
||||
<div class="setting-row__info">
|
||||
<div class="setting-row__title">Preview</div>
|
||||
</div>
|
||||
<div class="setting-row__control">
|
||||
<span id="color-preview" style="font-family:monospace; font-size:13px;"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top:12px;">
|
||||
<button class="btn" id="reset-colors" style="font-size:11px;">Reset defaults</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const digitInput = document.getElementById('display-digit-color') as HTMLInputElement;
|
||||
const symbolInput = document.getElementById('display-symbol-color') as HTMLInputElement;
|
||||
const swatch = document.getElementById('display-swatch') as HTMLElement;
|
||||
|
||||
// Render initial swatch
|
||||
updateSwatch(swatch, scheme.digit_color, scheme.symbol_color);
|
||||
|
||||
async function onColorChange(): Promise<void> {
|
||||
const newScheme = { digit_color: digitInput.value, symbol_color: symbolInput.value };
|
||||
await saveColorScheme(newScheme);
|
||||
updateSwatch(swatch, newScheme.digit_color, newScheme.symbol_color);
|
||||
function refreshPreview(s: ColorScheme): void {
|
||||
const preview = document.getElementById('color-preview');
|
||||
if (!preview) return;
|
||||
preview.style.setProperty('--relicario-pwd-digit-color', s.digit_color);
|
||||
preview.style.setProperty('--relicario-pwd-symbol-color', s.symbol_color);
|
||||
preview.innerHTML = '';
|
||||
preview.appendChild(colorizePassword('Abc123!@#'));
|
||||
}
|
||||
|
||||
digitInput.addEventListener('change', () => void onColorChange());
|
||||
symbolInput.addEventListener('change', () => void onColorChange());
|
||||
refreshPreview(scheme);
|
||||
|
||||
document.getElementById('display-reset')?.addEventListener('click', async () => {
|
||||
document.getElementById('digit-color')?.addEventListener('change', async (e) => {
|
||||
const color = (e.target as HTMLInputElement).value;
|
||||
const current = await loadColorScheme();
|
||||
await saveColorScheme({ ...current, digit_color: color });
|
||||
refreshPreview({ ...current, digit_color: color });
|
||||
});
|
||||
|
||||
document.getElementById('symbol-color')?.addEventListener('change', async (e) => {
|
||||
const color = (e.target as HTMLInputElement).value;
|
||||
const current = await loadColorScheme();
|
||||
await saveColorScheme({ ...current, symbol_color: color });
|
||||
refreshPreview({ ...current, symbol_color: color });
|
||||
});
|
||||
|
||||
document.getElementById('reset-colors')?.addEventListener('click', async () => {
|
||||
await resetColorScheme();
|
||||
digitInput.value = DEFAULT_DIGIT_COLOR;
|
||||
symbolInput.value = DEFAULT_SYMBOL_COLOR;
|
||||
updateSwatch(swatch, DEFAULT_DIGIT_COLOR, DEFAULT_SYMBOL_COLOR);
|
||||
renderDisplaySection(content);
|
||||
});
|
||||
}
|
||||
|
||||
async function renderGeneratorSection(content: HTMLElement): Promise<void> {
|
||||
content.innerHTML = '<p class="muted" style="padding:20px;font-size:12px;">Loading…</p>';
|
||||
const resp = await sendMessage({ type: 'get_vault_settings' });
|
||||
if (!resp.ok) {
|
||||
const errorMsg = (resp as { ok: false; error: string }).error;
|
||||
content.innerHTML = `<p class="muted" style="padding:20px;">Failed to load: ${escapeHtml(errorMsg)}</p>`;
|
||||
return;
|
||||
}
|
||||
const settings = (resp.data as { settings: VaultSettings }).settings;
|
||||
|
||||
content.innerHTML = `
|
||||
<h3 class="settings-section-title">Generator defaults</h3>
|
||||
<div class="setting-row">
|
||||
<div class="setting-row__info">
|
||||
<div class="setting-row__title">Configure generator</div>
|
||||
<div class="setting-row__desc">Password length, character classes, passphrase word count.</div>
|
||||
</div>
|
||||
<div class="setting-row__control">
|
||||
<button class="btn" id="open-generator-panel" style="font-size:11px;">Configure ▸</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.getElementById('open-generator-panel')?.addEventListener('click', (e) => {
|
||||
const trigger = e.currentTarget as HTMLElement;
|
||||
if (isGeneratorPanelOpen()) {
|
||||
closeGeneratorPanel();
|
||||
return;
|
||||
}
|
||||
openGeneratorPanel({
|
||||
parent: content,
|
||||
trigger,
|
||||
initial: settings.generator_defaults,
|
||||
context: 'configure-defaults',
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function renderRetentionSection(content: HTMLElement): Promise<void> {
|
||||
content.innerHTML = '<p class="muted" style="padding:20px;font-size:12px;">Loading…</p>';
|
||||
const resp = await sendMessage({ type: 'get_vault_settings' });
|
||||
if (!resp.ok) {
|
||||
content.innerHTML = `<p class="muted" style="padding:20px;">Failed to load: ${escapeHtml(resp.error ?? 'unknown')}</p>`;
|
||||
return;
|
||||
}
|
||||
const settings = (resp.data as { settings: VaultSettings }).settings;
|
||||
pendingVaultSettings = { ...settings };
|
||||
|
||||
content.innerHTML = `
|
||||
<h3 class="settings-section-title">Trash retention</h3>
|
||||
<div class="setting-row">
|
||||
<div class="setting-row__info">
|
||||
<div class="setting-row__title">Keep deleted items for</div>
|
||||
<div class="setting-row__desc">Items in trash older than this are permanently deleted on the next sync.</div>
|
||||
</div>
|
||||
<div class="setting-row__control">
|
||||
<select id="trash-retention" style="font-size:12px;">
|
||||
<option value="days:7">7 days</option>
|
||||
<option value="days:30">30 days</option>
|
||||
<option value="days:90">90 days</option>
|
||||
<option value="forever">Forever</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="settings-section-title" style="margin-top:20px;">Field history retention</h3>
|
||||
<div class="setting-row">
|
||||
<div class="setting-row__info">
|
||||
<div class="setting-row__title">Keep password history for</div>
|
||||
<div class="setting-row__desc">History entries older than this are pruned on save.</div>
|
||||
</div>
|
||||
<div class="setting-row__control">
|
||||
<select id="history-retention" style="font-size:12px;">
|
||||
<option value="last_n:5">Last 5</option>
|
||||
<option value="last_n:10">Last 10</option>
|
||||
<option value="days:90">90 days</option>
|
||||
<option value="days:365">1 year</option>
|
||||
<option value="forever">Forever</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top:12px;">
|
||||
<button class="btn btn-primary" id="save-retention" style="font-size:11px;">Save retention settings</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Set current select values
|
||||
(document.getElementById('trash-retention') as HTMLSelectElement).value =
|
||||
trashRetentionToValue(settings.trash_retention);
|
||||
(document.getElementById('history-retention') as HTMLSelectElement).value =
|
||||
historyRetentionToValue(settings.field_history_retention);
|
||||
|
||||
document.getElementById('trash-retention')?.addEventListener('change', (e) => {
|
||||
if (pendingVaultSettings) {
|
||||
pendingVaultSettings.trash_retention = valueToTrashRetention((e.target as HTMLSelectElement).value);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('history-retention')?.addEventListener('change', (e) => {
|
||||
if (pendingVaultSettings) {
|
||||
pendingVaultSettings.field_history_retention = valueToHistoryRetention((e.target as HTMLSelectElement).value);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('save-retention')?.addEventListener('click', async () => {
|
||||
if (!pendingVaultSettings) return;
|
||||
const r = await sendMessage({ type: 'update_vault_settings', settings: pendingVaultSettings });
|
||||
if (!r.ok) alert(`Save failed: ${r.error}`);
|
||||
});
|
||||
}
|
||||
|
||||
function trashRetentionToValue(r: TrashRetention): string {
|
||||
if (r.kind === 'forever') return 'forever';
|
||||
return `days:${r.value}`;
|
||||
}
|
||||
|
||||
function valueToTrashRetention(v: string): TrashRetention {
|
||||
if (v === 'forever') return { kind: 'forever' };
|
||||
const m = /^days:(\d+)$/.exec(v);
|
||||
if (m) return { kind: 'days', value: Number(m[1]) };
|
||||
return { kind: 'forever' };
|
||||
}
|
||||
|
||||
function historyRetentionToValue(r: HistoryRetention): string {
|
||||
if (r.kind === 'forever') return 'forever';
|
||||
if (r.kind === 'last_n') return `last_n:${r.value}`;
|
||||
return `days:${r.value}`;
|
||||
}
|
||||
|
||||
function valueToHistoryRetention(v: string): HistoryRetention {
|
||||
if (v === 'forever') return { kind: 'forever' };
|
||||
const mLast = /^last_n:(\d+)$/.exec(v);
|
||||
if (mLast) return { kind: 'last_n', value: Number(mLast[1]) };
|
||||
const mDays = /^days:(\d+)$/.exec(v);
|
||||
if (mDays) return { kind: 'days', value: Number(mDays[1]) };
|
||||
return { kind: 'forever' };
|
||||
}
|
||||
|
||||
function renderBackupSection(content: HTMLElement): void {
|
||||
content.innerHTML = `
|
||||
<h3 class="settings-section-title">Backup & restore</h3>
|
||||
<div class="setting-row">
|
||||
<div class="setting-row__info">
|
||||
<div class="setting-row__title">Export & restore backup</div>
|
||||
<div class="setting-row__desc">Download an encrypted backup or restore from a file. Opens in the vault tab.</div>
|
||||
</div>
|
||||
<div class="setting-row__control">
|
||||
<button class="btn" id="open-backup-tab" style="font-size:11px;">Open backup ▸</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.getElementById('open-backup-tab')?.addEventListener('click', () => openVaultTab('backup'));
|
||||
}
|
||||
|
||||
function renderImportSection(content: HTMLElement): void {
|
||||
content.innerHTML = `
|
||||
<h3 class="settings-section-title">Import</h3>
|
||||
<div class="setting-row">
|
||||
<div class="setting-row__info">
|
||||
<div class="setting-row__title">Import from LastPass</div>
|
||||
<div class="setting-row__desc">Import a LastPass CSV export. Opens in the vault tab for review before committing.</div>
|
||||
</div>
|
||||
<div class="setting-row__control">
|
||||
<button class="btn" id="open-import-tab" style="font-size:11px;">Open import ▸</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.getElementById('open-import-tab')?.addEventListener('click', () => openVaultTab('import'));
|
||||
}
|
||||
|
||||
export { renderAutofillSection, renderDisplaySection, renderGeneratorSection,
|
||||
renderRetentionSection, renderBackupSection, renderImportSection };
|
||||
|
||||
// Suppress unused-import warnings — these are used by Tasks 3-9
|
||||
void sendMessage;
|
||||
void loadColorScheme;
|
||||
void saveColorScheme;
|
||||
void resetColorScheme;
|
||||
void DEFAULT_DIGIT_COLOR;
|
||||
void DEFAULT_SYMBOL_COLOR;
|
||||
void colorizePassword;
|
||||
void openGeneratorPanel;
|
||||
void pendingVaultSettings;
|
||||
void activeKeyHandler;
|
||||
|
||||
@@ -2,10 +2,19 @@
|
||||
|
||||
import { getState, setState, sendMessage, navigate, escapeHtml } from '../../shared/state';
|
||||
import type { ItemId, ManifestEntry, VaultSettings } from '../../shared/types';
|
||||
import {
|
||||
GLYPH_TYPE_LOGIN, GLYPH_TYPE_SECURE_NOTE, GLYPH_TYPE_IDENTITY, GLYPH_TYPE_CARD,
|
||||
GLYPH_TYPE_KEY, GLYPH_TYPE_DOCUMENT, GLYPH_TYPE_TOTP,
|
||||
} from '../../shared/glyphs';
|
||||
|
||||
const TYPE_ICONS: Record<string, string> = {
|
||||
login: '🔑', secure_note: '📝', identity: '👤', card: '💳',
|
||||
key: '🔐', document: '📄', totp: '⏱️',
|
||||
login: GLYPH_TYPE_LOGIN,
|
||||
secure_note: GLYPH_TYPE_SECURE_NOTE,
|
||||
identity: GLYPH_TYPE_IDENTITY,
|
||||
card: GLYPH_TYPE_CARD,
|
||||
key: GLYPH_TYPE_KEY,
|
||||
document: GLYPH_TYPE_DOCUMENT,
|
||||
totp: GLYPH_TYPE_TOTP,
|
||||
};
|
||||
|
||||
function relativeTime(unixSec: number): string {
|
||||
@@ -64,7 +73,7 @@ export async function renderTrash(app: HTMLElement): Promise<void> {
|
||||
? `<p class="muted" style="text-align:center;margin-top:32px;">Trash is empty</p>`
|
||||
: items.map(([id, entry]) => `
|
||||
<div class="trash-row" data-id="${escapeHtml(id)}">
|
||||
<span class="trash-row__icon">${TYPE_ICONS[entry.type] ?? '📦'}</span>
|
||||
<span class="trash-row__icon">${TYPE_ICONS[entry.type] ?? '◻'}</span>
|
||||
<div class="trash-row__info">
|
||||
<span class="trash-row__title">${escapeHtml(entry.title)}</span>
|
||||
<span class="trash-row__meta">trashed ${relativeTime(entry.trashed_at ?? 0)}</span>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import { getState, setState, sendMessage, navigate, escapeHtml, popOutToTab } from '../../../shared/state';
|
||||
import { renderFormHeader } from '../form-header';
|
||||
import { REQUIRED_PILL_HTML } from '../../../shared/glyphs';
|
||||
import { REQUIRED_PILL_HTML, GLYPH_TYPE_DOCUMENT, GLYPH_PREVIEW } from '../../../shared/glyphs';
|
||||
import type { Item, ItemId, ManifestEntry, Section, AttachmentRef } from '../../../shared/types';
|
||||
import {
|
||||
renderSectionsEditor, wireSectionsEditor,
|
||||
@@ -76,7 +76,7 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
|
||||
}
|
||||
return `
|
||||
<div class="document-primary-row" id="primary-picker">
|
||||
<span class="document-primary-row__thumb">📄</span>
|
||||
<span class="document-primary-row__thumb">${GLYPH_TYPE_DOCUMENT}</span>
|
||||
<span class="document-primary-row__name">${escapeHtml(primaryRef.filename)}</span>
|
||||
<span class="document-primary-row__meta">${formatBytes(primaryRef.size)}</span>
|
||||
<span class="document-primary-row__action">↑ change</span>
|
||||
@@ -283,13 +283,13 @@ export async function renderDetail(app: HTMLElement, item: Item): Promise<void>
|
||||
<div class="detail-title" style="margin-bottom:12px;">${escapeHtml(item.title)}</div>
|
||||
|
||||
<div class="document-signature-block" id="doc-sigblock">
|
||||
<div class="document-signature-block__thumb" data-att-id="${escapeHtml(primaryRef.id)}" data-mime="${escapeHtml(primaryRef.mime_type)}">📄</div>
|
||||
<div class="document-signature-block__thumb" data-att-id="${escapeHtml(primaryRef.id)}" data-mime="${escapeHtml(primaryRef.mime_type)}">${GLYPH_TYPE_DOCUMENT}</div>
|
||||
<div class="document-signature-block__info">
|
||||
<div class="document-signature-block__name">${escapeHtml(primaryRef.filename)}</div>
|
||||
<div class="document-signature-block__meta">${formatBytes(primaryRef.size)} · ${new Date(primaryRef.created * 1000).toISOString().slice(0, 10)}</div>
|
||||
<div class="document-signature-block__actions">
|
||||
<span id="doc-download" style="cursor:pointer;color:#d2ab43;">↓ download</span>
|
||||
${isImageMime ? '<span id="doc-preview" style="cursor:pointer;color:#d2ab43;margin-left:10px;">🔍 preview</span>' : ''}
|
||||
${isImageMime ? `<span id="doc-preview" style="cursor:pointer;color:#d2ab43;margin-left:10px;">${GLYPH_PREVIEW} preview</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,7 +11,7 @@ import { renderUnlock } from './components/unlock';
|
||||
import { renderItemList } from './components/item-list';
|
||||
import { renderItemDetail } from './components/item-detail';
|
||||
import { renderItemForm } from './components/item-form';
|
||||
import { renderSettings } from './components/settings';
|
||||
import { renderSettings, teardownSettings } from './components/settings';
|
||||
import { renderVaultSettings } from './components/settings-vault';
|
||||
import { renderTrash } from './components/trash';
|
||||
import { renderDevices } from './components/devices';
|
||||
@@ -178,6 +178,7 @@ function render(): void {
|
||||
teardownTrash();
|
||||
teardownDevices();
|
||||
teardownFieldHistory();
|
||||
teardownSettings();
|
||||
|
||||
switch (currentState.view) {
|
||||
case 'locked':
|
||||
|
||||
@@ -1608,3 +1608,183 @@ textarea {
|
||||
margin-top: 8px;
|
||||
background: var(--bg-input);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-state__icon {
|
||||
font-size: 28px;
|
||||
color: var(--text-muted, #8b949e);
|
||||
margin-bottom: 12px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.empty-state__title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.empty-state__hint {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted, #8b949e);
|
||||
}
|
||||
|
||||
.type-card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.type-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
padding: 10px 12px;
|
||||
background: var(--bg-elevated, #161b22);
|
||||
border: 1px solid var(--border-mid, #30363d);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.type-card:hover { border-color: var(--gold-base, #a88a4a); }
|
||||
|
||||
.type-card__icon { font-size: 20px; margin-bottom: 4px; }
|
||||
.type-card__label { font-size: 12px; font-weight: 600; }
|
||||
|
||||
/* Toast notifications */
|
||||
.relicario-toast-container {
|
||||
position: fixed;
|
||||
bottom: 16px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
pointer-events: none;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.vault-shell .relicario-toast-container {
|
||||
left: auto;
|
||||
right: 24px;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.relicario-toast {
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
transition: opacity 0.2s, transform 0.2s;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.relicario-toast--visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.relicario-toast--success { background: #1f4a24; color: #aff0b5; border: 1px solid #238636; }
|
||||
.relicario-toast--error { background: #4a1f1f; color: #f0afaf; border: 1px solid #ab2b20; }
|
||||
.relicario-toast--info { background: #1f2d4a; color: #afc8f0; border: 1px solid #1f6feb; }
|
||||
.type-card__desc { font-size: 10px; color: var(--text-muted, #8b949e); margin-top: 2px; }
|
||||
|
||||
/* === Settings layout === */
|
||||
.settings-layout {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.settings-nav {
|
||||
width: 148px;
|
||||
min-width: 148px;
|
||||
border-right: 1px solid var(--border, #30363d);
|
||||
padding: 12px 0;
|
||||
overflow-y: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.settings-nav__group-label {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-muted, #8b949e);
|
||||
padding: 8px 12px 4px;
|
||||
}
|
||||
|
||||
.settings-nav__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 7px 12px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.settings-nav__item:hover { background: var(--bg-hover, #161b22); }
|
||||
.settings-nav__item--active { background: var(--bg-selected, #1c2d41); }
|
||||
|
||||
.settings-nav__icon { font-size: 14px; flex-shrink: 0; }
|
||||
|
||||
.settings-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px 24px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.setting-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid var(--border-subtle, #21262d);
|
||||
}
|
||||
|
||||
.setting-row:last-child { border-bottom: none; }
|
||||
|
||||
.setting-row__info { flex: 1; }
|
||||
.setting-row__title { font-size: 13px; font-weight: 500; }
|
||||
.setting-row__desc { font-size: 11px; color: var(--text-muted, #8b949e); margin-top: 2px; }
|
||||
.setting-row__control { flex-shrink: 0; }
|
||||
|
||||
.settings-section-title {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-muted, #8b949e);
|
||||
margin: 0 0 12px;
|
||||
padding-bottom: 6px;
|
||||
border-bottom: 1px solid var(--border, #30363d);
|
||||
}
|
||||
|
||||
.setting-card {
|
||||
padding: 12px 16px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border, #30363d);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.setting-card--ok { border-color: var(--success, #238636); background: rgba(35, 134, 54, 0.06); }
|
||||
.setting-card--warn { border-color: var(--gold, #b8860b); background: rgba(184, 134, 11, 0.06); }
|
||||
|
||||
.setting-card__status { font-size: 13px; margin-bottom: 8px; }
|
||||
.setting-card__actions { display: flex; gap: 8px; }
|
||||
|
||||
@@ -41,3 +41,30 @@ describe('glyph constants', () => {
|
||||
expect(GLYPH_NEXT).toBe('▸');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Stream A glyphs (vault tab + type icons)', () => {
|
||||
it('exports GLYPH_VAULT_TAB as U+29C9', () => {
|
||||
expect(glyphs.GLYPH_VAULT_TAB).toBe('⧉');
|
||||
});
|
||||
|
||||
it('exports per-type glyph constants', () => {
|
||||
expect(glyphs.GLYPH_TYPE_LOGIN).toBe('◉');
|
||||
expect(glyphs.GLYPH_TYPE_SECURE_NOTE).toBe('◫');
|
||||
expect(glyphs.GLYPH_TYPE_TOTP).toBe('⊡');
|
||||
expect(glyphs.GLYPH_TYPE_CARD).toBe('▭');
|
||||
expect(glyphs.GLYPH_TYPE_IDENTITY).toBe('⌬');
|
||||
expect(glyphs.GLYPH_TYPE_KEY).toBe('⊹');
|
||||
expect(glyphs.GLYPH_TYPE_DOCUMENT).toBe('≡');
|
||||
});
|
||||
|
||||
it('per-type glyphs are single codepoints (no emoji)', () => {
|
||||
const typeGlyphs = [
|
||||
glyphs.GLYPH_TYPE_LOGIN, glyphs.GLYPH_TYPE_SECURE_NOTE, glyphs.GLYPH_TYPE_TOTP,
|
||||
glyphs.GLYPH_TYPE_CARD, glyphs.GLYPH_TYPE_IDENTITY, glyphs.GLYPH_TYPE_KEY,
|
||||
glyphs.GLYPH_TYPE_DOCUMENT,
|
||||
];
|
||||
for (const g of typeGlyphs) {
|
||||
expect([...g].length).toBe(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,6 +17,19 @@ export const GLYPH_DEVICES = '⌬'; // sidebar devices nav
|
||||
export const GLYPH_SETTINGS = '⚙'; // sidebar settings nav
|
||||
export const GLYPH_LOCK = '⏻'; // sidebar lock nav
|
||||
export const GLYPH_NEXT = '▸'; // forward / next button (matches ▾/▸ disclosure family)
|
||||
export const GLYPH_COPY = '⎘'; // copy to clipboard
|
||||
export const GLYPH_SYNC = '⇅'; // sync / upload
|
||||
export const GLYPH_PREVIEW = '⊕'; // preview / expand
|
||||
|
||||
export const GLYPH_VAULT_TAB = '⧉'; // U+29C9 pop-out to fullscreen vault tab
|
||||
|
||||
export const GLYPH_TYPE_LOGIN = '◉'; // login
|
||||
export const GLYPH_TYPE_SECURE_NOTE = '◫'; // secure note
|
||||
export const GLYPH_TYPE_TOTP = '⊡'; // totp / 2FA
|
||||
export const GLYPH_TYPE_CARD = '▭'; // card
|
||||
export const GLYPH_TYPE_IDENTITY = '⌬'; // identity
|
||||
export const GLYPH_TYPE_KEY = '⊹'; // SSH / API key
|
||||
export const GLYPH_TYPE_DOCUMENT = '≡'; // document
|
||||
|
||||
/// Inline HTML snippet for the required-field pill. Use after a label's text:
|
||||
/// `<label class="label" for="f-title">title ${REQUIRED_PILL_HTML}</label>`
|
||||
|
||||
26
extension/src/shared/toast.ts
Normal file
26
extension/src/shared/toast.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
export function showToast(
|
||||
message: string,
|
||||
type: 'success' | 'error' | 'info' = 'info',
|
||||
durationMs = 2500,
|
||||
): void {
|
||||
let container = document.querySelector<HTMLElement>('.relicario-toast-container');
|
||||
if (!container) {
|
||||
container = document.createElement('div');
|
||||
container.className = 'relicario-toast-container';
|
||||
document.body.appendChild(container);
|
||||
}
|
||||
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `relicario-toast relicario-toast--${type}`;
|
||||
toast.textContent = message;
|
||||
container.appendChild(toast);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => toast.classList.add('relicario-toast--visible'));
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
toast.classList.remove('relicario-toast--visible');
|
||||
toast.addEventListener('transitionend', () => toast.remove(), { once: true });
|
||||
}, durationMs);
|
||||
}
|
||||
@@ -1396,6 +1396,281 @@ textarea {
|
||||
color: #484f58;
|
||||
}
|
||||
|
||||
/* === 3-column shell === */
|
||||
.vault-shell {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
background: var(--bg-page, #0d1117);
|
||||
}
|
||||
|
||||
.vault-sidebar {
|
||||
width: 200px;
|
||||
min-width: 200px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: 1px solid var(--border, #30363d);
|
||||
background: var(--bg-sidebar, #0d1117);
|
||||
overflow-y: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.vault-list-pane {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.vault-drawer {
|
||||
width: 440px;
|
||||
min-width: 440px;
|
||||
max-width: 440px;
|
||||
border-left: 1px solid var(--border, #30363d);
|
||||
background: var(--bg-elevated, #161b22);
|
||||
overflow-y: auto;
|
||||
transform: translateX(100%);
|
||||
transition: transform 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.vault-drawer--open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.vault-list-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 16px;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid var(--border-subtle, #21262d);
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.vault-list-row:hover { background: var(--bg-hover, #161b22); }
|
||||
|
||||
.vault-list-row--selected {
|
||||
background: var(--bg-selected, #1c2d41);
|
||||
border-left: 2px solid var(--gold, #b8860b);
|
||||
}
|
||||
|
||||
.vault-list-row__icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-elevated, #161b22);
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border, #30363d);
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.vault-list-row--selected .vault-list-row__icon { border-color: var(--gold, #b8860b); }
|
||||
|
||||
.vault-list-row__text { flex: 1; min-width: 0; }
|
||||
|
||||
.vault-list-row__title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.vault-list-row__subtitle {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted, #8b949e);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.vault-list-row__age {
|
||||
font-size: 10px;
|
||||
color: var(--text-dim, #6e7681);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Bottom sheet */
|
||||
.vault-bottom-sheet-scrim {
|
||||
position: absolute;
|
||||
inset: 0 0 0 200px;
|
||||
background: rgba(0,0,0,0.5);
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
pointer-events: none;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.vault-bottom-sheet-scrim--visible {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.vault-bottom-sheet {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 200px;
|
||||
right: 0;
|
||||
background: var(--bg-elevated, #161b22);
|
||||
border-top: 1px solid var(--border, #30363d);
|
||||
border-radius: 12px 12px 0 0;
|
||||
padding: 16px 24px 24px;
|
||||
transform: translateY(100%);
|
||||
transition: transform 0.25s ease;
|
||||
z-index: 101;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.vault-bottom-sheet--open { transform: translateY(0); }
|
||||
|
||||
.vault-bottom-sheet__handle {
|
||||
width: 40px;
|
||||
height: 4px;
|
||||
background: var(--border, #30363d);
|
||||
border-radius: 2px;
|
||||
margin: 0 auto 16px;
|
||||
}
|
||||
|
||||
.vault-bottom-sheet__title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.vault-type-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.vault-type-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 12px 8px;
|
||||
background: var(--bg-page, #0d1117);
|
||||
border: 1px solid var(--border, #30363d);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.vault-type-card:hover { border-color: var(--gold, #b8860b); }
|
||||
|
||||
.vault-type-card__icon { font-size: 28px; }
|
||||
.vault-type-card__name { font-size: 11px; color: var(--text-muted, #8b949e); }
|
||||
|
||||
/* Drawer header and body */
|
||||
.vault-drawer__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border, #30363d);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.vault-drawer__type-pill {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 2px 8px;
|
||||
background: var(--bg-page, #0d1117);
|
||||
border: 1px solid var(--border, #30363d);
|
||||
border-radius: 4px;
|
||||
color: var(--text-muted, #8b949e);
|
||||
}
|
||||
|
||||
.vault-drawer__actions { display: flex; gap: 6px; margin-left: auto; }
|
||||
|
||||
.vault-drawer__close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
color: var(--text-muted, #8b949e);
|
||||
padding: 4px 6px;
|
||||
}
|
||||
|
||||
.vault-drawer__body { padding: 20px 20px 16px; }
|
||||
|
||||
.vault-drawer__title { font-size: 18px; font-weight: 700; margin-bottom: 4px; }
|
||||
.vault-drawer__subtitle { font-size: 12px; color: var(--text-muted, #8b949e); margin-bottom: 16px; }
|
||||
|
||||
.vault-drawer__field-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.vault-drawer__field-grid > .vault-drawer__field--full { grid-column: 1 / -1; }
|
||||
|
||||
.vault-drawer__field-label {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-muted, #8b949e);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.vault-drawer__field-value {
|
||||
font-size: 13px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* Category nav */
|
||||
.vault-category-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 6px 12px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.vault-category-row:hover { background: var(--bg-hover, #161b22); }
|
||||
.vault-category-row--active { background: var(--bg-selected, #1c2d41); }
|
||||
.vault-category-row__icon { font-size: 14px; flex-shrink: 0; }
|
||||
.vault-category-row__label { flex: 1; }
|
||||
.vault-category-row__count { font-size: 11px; color: var(--text-muted, #8b949e); }
|
||||
|
||||
/* === Responsive === */
|
||||
@media (max-width: 960px) {
|
||||
.vault-drawer {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.vault-sidebar {
|
||||
width: 48px;
|
||||
min-width: 48px;
|
||||
}
|
||||
.vault-sidebar__category-label,
|
||||
.vault-sidebar__category-count,
|
||||
.vault-sidebar__nav-label {
|
||||
display: none;
|
||||
}
|
||||
.vault-sidebar__nav-item { justify-content: center; padding: 10px 0; }
|
||||
}
|
||||
|
||||
/* --- Lock screen (vault tab) --- */
|
||||
|
||||
.vault-lock-screen {
|
||||
@@ -1719,3 +1994,41 @@ textarea {
|
||||
background: linear-gradient(to top, rgba(17, 22, 30, 0.7), transparent);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Toast notifications */
|
||||
.relicario-toast-container {
|
||||
position: fixed;
|
||||
bottom: 16px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
pointer-events: none;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.vault-shell .relicario-toast-container {
|
||||
left: auto;
|
||||
right: 24px;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.relicario-toast {
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
transition: opacity 0.2s, transform 0.2s;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.relicario-toast--visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.relicario-toast--success { background: #1f4a24; color: #aff0b5; border: 1px solid #238636; }
|
||||
.relicario-toast--error { background: #4a1f1f; color: #f0afaf; border: 1px solid #ab2b20; }
|
||||
.relicario-toast--info { background: #1f2d4a; color: #afc8f0; border: 1px solid #1f6feb; }
|
||||
|
||||
@@ -10,18 +10,36 @@ import type {
|
||||
} from '../shared/types';
|
||||
import { registerHost } from '../shared/state';
|
||||
import { lookupErrorCopy, type ErrorCta } from '../shared/error-copy';
|
||||
import { GLYPH_TRASH, GLYPH_DEVICES, GLYPH_SETTINGS, GLYPH_LOCK } from '../shared/glyphs';
|
||||
import {
|
||||
GLYPH_TRASH, GLYPH_DEVICES, 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';
|
||||
import { renderItemDetail } from '../popup/components/item-detail';
|
||||
import { renderItemForm } from '../popup/components/item-form';
|
||||
import { renderTrash, teardown as teardownTrash } from '../popup/components/trash';
|
||||
import { renderDevices, teardown as teardownDevices } from '../popup/components/devices';
|
||||
import { renderSettings } from '../popup/components/settings';
|
||||
import { renderSettings, teardownSettings } from '../popup/components/settings';
|
||||
import { renderVaultSettings as renderVaultSettingsView } from '../popup/components/settings-vault';
|
||||
import { renderFieldHistory, teardown as teardownFieldHistory } from '../popup/components/field-history';
|
||||
import { renderBackupPanel, teardown as teardownBackup } from './components/backup-panel';
|
||||
import { renderImportPanel, teardown as teardownImport } from './components/import-panel';
|
||||
import { applyColorScheme } from '../shared/color-scheme';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bottom sheet type picker
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const BOTTOM_SHEET_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' },
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -60,26 +78,35 @@ function renderErrorBlock(code: string | null | undefined): string {
|
||||
|
||||
function typeIcon(t: ItemType): string {
|
||||
switch (t) {
|
||||
case 'login': return '\u{1F511}'; // key
|
||||
case 'secure_note': return '\u{1F4DD}'; // memo
|
||||
case 'identity': return '\u{1FAAA}'; // id card
|
||||
case 'card': return '\u{1F4B3}'; // credit card
|
||||
case 'key': return '\u{1F5DD}'; // old key
|
||||
case 'document': return '\u{1F4C4}'; // page facing up
|
||||
case 'totp': return '⏱'; // stopwatch
|
||||
case 'login': return GLYPH_TYPE_LOGIN;
|
||||
case 'secure_note': return GLYPH_TYPE_SECURE_NOTE;
|
||||
case 'identity': return GLYPH_TYPE_IDENTITY;
|
||||
case 'card': return GLYPH_TYPE_CARD;
|
||||
case 'key': return GLYPH_TYPE_KEY;
|
||||
case 'document': return GLYPH_TYPE_DOCUMENT;
|
||||
case 'totp': return GLYPH_TYPE_TOTP;
|
||||
}
|
||||
}
|
||||
|
||||
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<ItemType, string> = {
|
||||
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`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -138,6 +165,8 @@ interface VaultState {
|
||||
selectedIndex: number;
|
||||
searchQuery: string;
|
||||
activeGroup: string | null;
|
||||
drawerOpen: boolean;
|
||||
bottomSheetOpen: boolean;
|
||||
vaultSettings: VaultSettings | null;
|
||||
generatorDefaults: GeneratorRequest | null;
|
||||
error: string | null;
|
||||
@@ -157,6 +186,8 @@ const state: VaultState = {
|
||||
selectedIndex: 0,
|
||||
searchQuery: '',
|
||||
activeGroup: null,
|
||||
drawerOpen: false,
|
||||
bottomSheetOpen: false,
|
||||
vaultSettings: null,
|
||||
generatorDefaults: null,
|
||||
error: null,
|
||||
@@ -180,7 +211,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,
|
||||
@@ -249,39 +281,220 @@ 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 = `
|
||||
<div class="vault-sidebar">
|
||||
<div class="vault-sidebar__header">
|
||||
<img class="brand-logo" src="icons/relicario-logo-16.svg" alt="">
|
||||
<span class="brand">Relicario</span>
|
||||
<div class="vault-shell">
|
||||
<div class="vault-sidebar">
|
||||
<div class="vault-sidebar__header">
|
||||
<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="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="lock" title="Lock">${GLYPH_LOCK} <span class="vault-sidebar__nav-label">lock</span></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="vault-sidebar__search">
|
||||
<input type="text" id="vault-search" placeholder="/ search..." />
|
||||
</div>
|
||||
<div class="vault-sidebar__list" id="vault-sidebar-list"></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 class="vault-list-pane" id="vault-list-pane"></div>
|
||||
<div class="vault-drawer" id="vault-drawer"></div>
|
||||
<div class="vault-bottom-sheet-scrim" id="vault-sheet-scrim"></div>
|
||||
<div class="vault-bottom-sheet" id="vault-bottom-sheet"></div>
|
||||
</div>
|
||||
`;
|
||||
wireSidebar();
|
||||
wireBottomSheet();
|
||||
}
|
||||
|
||||
renderSidebarList();
|
||||
renderPane();
|
||||
renderSidebarCategories();
|
||||
renderListPane();
|
||||
if (state.drawerOpen && state.selectedItem) {
|
||||
renderDrawer(state.selectedItem);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bottom sheet (wired in Task 11)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function wireBottomSheet(): void {
|
||||
document.getElementById('vault-sheet-scrim')?.addEventListener('click', closeBottomSheet);
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && state.bottomSheetOpen) closeBottomSheet();
|
||||
});
|
||||
}
|
||||
|
||||
function openBottomSheet(): void {
|
||||
const sheet = document.getElementById('vault-bottom-sheet');
|
||||
const scrim = document.getElementById('vault-sheet-scrim');
|
||||
if (!sheet || !scrim) return;
|
||||
|
||||
sheet.innerHTML = `
|
||||
<div class="vault-bottom-sheet__handle"></div>
|
||||
<div class="vault-bottom-sheet__title">New item — choose type</div>
|
||||
<div class="vault-type-grid">
|
||||
${BOTTOM_SHEET_TYPES.map((t) => `
|
||||
<button class="vault-type-card" data-type="${t.type}">
|
||||
<span class="vault-type-card__icon" aria-hidden="true">${typeIcon(t.type)}</span>
|
||||
<span class="vault-type-card__name">${escapeHtml(t.label)}</span>
|
||||
</button>
|
||||
`).join('')}
|
||||
</div>
|
||||
`;
|
||||
|
||||
sheet.classList.add('vault-bottom-sheet--open');
|
||||
scrim.classList.add('vault-bottom-sheet-scrim--visible');
|
||||
state.bottomSheetOpen = true;
|
||||
|
||||
sheet.querySelectorAll<HTMLButtonElement>('[data-type]').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
const type = btn.dataset.type as ItemType;
|
||||
closeBottomSheet();
|
||||
setHash('add', type);
|
||||
renderPane();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function closeBottomSheet(): void {
|
||||
document.getElementById('vault-bottom-sheet')?.classList.remove('vault-bottom-sheet--open');
|
||||
document.getElementById('vault-sheet-scrim')?.classList.remove('vault-bottom-sheet-scrim--visible');
|
||||
state.bottomSheetOpen = false;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Drawer (implemented in Task 10)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function openDrawer(): void {
|
||||
document.getElementById('vault-drawer')?.classList.add('vault-drawer--open');
|
||||
}
|
||||
|
||||
function closeDrawer(): void {
|
||||
state.drawerOpen = false;
|
||||
state.selectedId = null;
|
||||
state.selectedItem = null;
|
||||
document.getElementById('vault-drawer')?.classList.remove('vault-drawer--open');
|
||||
}
|
||||
|
||||
function getDrawerCoreFields(item: Item): Array<[string, string, boolean]> {
|
||||
const core = item.core as unknown as Record<string, unknown>;
|
||||
if (!core) return [];
|
||||
const fields: Array<[string, string, boolean]> = [];
|
||||
|
||||
switch (item.type) {
|
||||
case 'login':
|
||||
if ('username' in core) fields.push(['username', String(core.username ?? ''), false]);
|
||||
if ('password' in core) fields.push(['password', '••••••••', false]);
|
||||
if ('url' in core) fields.push(['url', String(core.url ?? ''), true]);
|
||||
break;
|
||||
case 'card': {
|
||||
if ('number' in core) fields.push(['number', String(core.number ?? ''), false]);
|
||||
if ('holder' in core) fields.push(['holder', String(core.holder ?? ''), false]);
|
||||
if ('expiry' in core && core.expiry) {
|
||||
const exp = core.expiry as { month: number; year: number };
|
||||
fields.push(['expiry', `${String(exp.month).padStart(2, '0')}/${exp.year}`, false]);
|
||||
}
|
||||
if ('cvv' in core) fields.push(['cvv', '•••', false]);
|
||||
if ('pin' in core) fields.push(['pin', '••••', false]);
|
||||
break;
|
||||
}
|
||||
case 'identity':
|
||||
if ('full_name' in core) fields.push(['full name', String(core.full_name ?? ''), true]);
|
||||
if ('email' in core) fields.push(['email', String(core.email ?? ''), true]);
|
||||
if ('phone' in core) fields.push(['phone', String(core.phone ?? ''), false]);
|
||||
if ('address' in core) fields.push(['address', String(core.address ?? ''), true]);
|
||||
if ('date_of_birth' in core) fields.push(['date of birth', String(core.date_of_birth ?? ''), false]);
|
||||
break;
|
||||
case 'key':
|
||||
if ('label' in core) fields.push(['label', String(core.label ?? ''), true]);
|
||||
if ('algorithm' in core) fields.push(['algorithm', String(core.algorithm ?? ''), false]);
|
||||
if ('public_key' in core) fields.push(['public key', String(core.public_key ?? ''), true]);
|
||||
break;
|
||||
case 'secure_note':
|
||||
if ('body' in core) fields.push(['body', String(core.body ?? ''), true]);
|
||||
break;
|
||||
case 'totp':
|
||||
if ('issuer' in core) fields.push(['issuer', String(core.issuer ?? ''), false]);
|
||||
if ('label' in core) fields.push(['label', String(core.label ?? ''), false]);
|
||||
break;
|
||||
case 'document':
|
||||
if ('filename' in core) fields.push(['filename', String(core.filename ?? ''), true]);
|
||||
if ('mime_type' in core) fields.push(['type', String(core.mime_type ?? ''), false]);
|
||||
break;
|
||||
}
|
||||
|
||||
if (item.notes) fields.push(['notes', item.notes, true]);
|
||||
return fields;
|
||||
}
|
||||
|
||||
function renderDrawer(item: Item): void {
|
||||
const drawer = document.getElementById('vault-drawer');
|
||||
if (!drawer) return;
|
||||
|
||||
const coreFields = getDrawerCoreFields(item);
|
||||
|
||||
drawer.innerHTML = `
|
||||
<div class="vault-drawer__header">
|
||||
<span class="vault-drawer__type-pill">${item.type.replace('_', ' ').toUpperCase()}</span>
|
||||
<div class="vault-drawer__actions">
|
||||
<button class="btn" id="drawer-edit-btn" style="font-size:11px;">edit</button>
|
||||
<button class="vault-drawer__close" id="drawer-close-btn" title="Close (Esc)">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="vault-drawer__body">
|
||||
<div class="vault-drawer__title">${escapeHtml(item.title)}</div>
|
||||
${item.type === 'login' && (item.core as { url?: string }).url
|
||||
? `<div class="vault-drawer__subtitle">${escapeHtml((item.core as { url?: string }).url ?? '')}</div>`
|
||||
: ''}
|
||||
<div class="vault-drawer__field-grid">
|
||||
${coreFields.map(([label, value, full]) => `
|
||||
<div class="vault-drawer__field${full ? ' vault-drawer__field--full' : ''}">
|
||||
<div class="vault-drawer__field-label">${escapeHtml(label)}</div>
|
||||
<div class="vault-drawer__field-value">${escapeHtml(value)}</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.getElementById('drawer-close-btn')?.addEventListener('click', () => {
|
||||
closeDrawer();
|
||||
renderListPane();
|
||||
});
|
||||
|
||||
document.getElementById('drawer-edit-btn')?.addEventListener('click', () => {
|
||||
if (state.selectedId) {
|
||||
setHash('edit', state.selectedId);
|
||||
renderPane();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Item selection (implemented in Task 10)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function selectItemForDrawer(id: string): Promise<void> {
|
||||
const resp = await sendMessage({ type: 'get_item', id });
|
||||
if (!resp.ok) return;
|
||||
const data = resp.data as { item: Item };
|
||||
state.selectedId = id;
|
||||
state.selectedItem = data.item;
|
||||
state.drawerOpen = true;
|
||||
renderSidebarCategories();
|
||||
renderListPane();
|
||||
renderDrawer(data.item);
|
||||
openDrawer();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -293,7 +506,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
|
||||
@@ -313,8 +527,7 @@ function wireSidebar(): void {
|
||||
state.selectedId = null;
|
||||
state.selectedItem = null;
|
||||
state.newType = null;
|
||||
setHash('add');
|
||||
renderPane();
|
||||
openBottomSheet();
|
||||
return;
|
||||
}
|
||||
if (nav === 'trash' || nav === 'devices' || nav === 'settings') {
|
||||
@@ -328,11 +541,16 @@ function wireSidebar(): void {
|
||||
});
|
||||
});
|
||||
|
||||
// Global "/" shortcut to focus search
|
||||
// Global "/" shortcut to focus search; Esc to close drawer
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === '/' && !isEditableTarget(e.target)) {
|
||||
e.preventDefault();
|
||||
searchInput?.focus();
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Escape' && state.drawerOpen) {
|
||||
closeDrawer();
|
||||
renderListPane();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -346,7 +564,7 @@ function isEditableTarget(target: EventTarget | null): boolean {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sidebar list
|
||||
// Sidebar category nav
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function getFilteredEntries(): Array<[ItemId, ManifestEntry]> {
|
||||
@@ -367,70 +585,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<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 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) {
|
||||
const items = groups.get(t);
|
||||
if (!items || items.length === 0) continue;
|
||||
html += `<div class="vault-group-header">${typeIcon(t)} ${escapeHtml(typeLabel(t))}</div>`;
|
||||
for (const [id, e] of items) {
|
||||
const sel = id === state.selectedId ? ' selected' : '';
|
||||
const meta = e.icon_hint ? escapeHtml(e.icon_hint) : '';
|
||||
html += `
|
||||
<div class="vault-entry${sel}" data-id="${escapeHtml(id)}">
|
||||
<span class="vault-entry__title">${escapeHtml(e.title)}</span>
|
||||
${meta ? `<span class="vault-entry__meta">${meta}</span>` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
const count = filtered.filter(([, e]) => e.type === t).length;
|
||||
if (count === 0 && allCount > 0) continue;
|
||||
const isActive = state.activeGroup === t;
|
||||
html += `
|
||||
<button class="vault-category-row ${isActive ? 'vault-category-row--active' : ''}" data-group="${t}">
|
||||
<span class="vault-category-row__icon">${typeIcon(t)}</span>
|
||||
<span class="vault-category-row__label vault-sidebar__category-label">${typeLabel(t)}</span>
|
||||
<span class="vault-category-row__count vault-sidebar__category-count">${count}</span>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
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<HTMLButtonElement>('.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<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);
|
||||
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 = `
|
||||
<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!);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -446,8 +690,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 = `
|
||||
@@ -505,6 +749,7 @@ export const __test__ = { renderFormWrapped };
|
||||
function teardownPaneComponents(): void {
|
||||
teardownTrash();
|
||||
teardownDevices();
|
||||
teardownSettings();
|
||||
teardownFieldHistory();
|
||||
teardownBackup();
|
||||
teardownImport();
|
||||
@@ -554,7 +799,7 @@ function renderPane(): void {
|
||||
renderDevices(pane);
|
||||
break;
|
||||
case 'settings':
|
||||
renderSettings(pane);
|
||||
void renderSettings(pane);
|
||||
break;
|
||||
case 'settings-vault':
|
||||
renderVaultSettingsView(pane);
|
||||
@@ -674,7 +919,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
|
||||
@@ -685,7 +931,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<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