feat: add popup state machine and all components
View router (setup/locked/list/detail/add/edit), unlock screen with passphrase input, entry list with search/group tabs/keyboard nav, entry detail with TOTP countdown and copy shortcuts, add/edit form with password generation, and 3-step setup wizard. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
255
extension/src/popup/components/entry-detail.ts
Normal file
255
extension/src/popup/components/entry-detail.ts
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
/// Entry detail view — shows fields, TOTP countdown, copy/fill shortcuts.
|
||||||
|
|
||||||
|
import { getState, setState, sendMessage, navigate, escapeHtml } from '../popup';
|
||||||
|
import type { ManifestEntry } from '../../shared/types';
|
||||||
|
|
||||||
|
let totpInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
function stopTotpTimer(): void {
|
||||||
|
if (totpInterval !== null) {
|
||||||
|
clearInterval(totpInterval);
|
||||||
|
totpInterval = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyToClipboard(text: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
} catch {
|
||||||
|
// Fallback for older browsers.
|
||||||
|
const ta = document.createElement('textarea');
|
||||||
|
ta.value = text;
|
||||||
|
ta.style.position = 'fixed';
|
||||||
|
ta.style.left = '-9999px';
|
||||||
|
document.body.appendChild(ta);
|
||||||
|
ta.select();
|
||||||
|
document.execCommand('copy');
|
||||||
|
document.body.removeChild(ta);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderEntryDetail(app: HTMLElement): void {
|
||||||
|
const state = getState();
|
||||||
|
const entry = state.selectedEntry;
|
||||||
|
const id = state.selectedId;
|
||||||
|
if (!entry || !id) {
|
||||||
|
navigate('list');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
stopTotpTimer();
|
||||||
|
|
||||||
|
let html = `
|
||||||
|
<div class="detail-header">
|
||||||
|
<span class="detail-title">${escapeHtml(entry.name)}</span>
|
||||||
|
<button class="btn" id="back-btn" style="font-size:11px;">esc</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// URL
|
||||||
|
if (entry.url) {
|
||||||
|
html += `
|
||||||
|
<div class="field">
|
||||||
|
<div class="label">url</div>
|
||||||
|
<div class="field-value">${escapeHtml(entry.url)}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Username
|
||||||
|
if (entry.username) {
|
||||||
|
html += `
|
||||||
|
<div class="field">
|
||||||
|
<div class="label">username</div>
|
||||||
|
<div class="field-value" id="username-val">${escapeHtml(entry.username)}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Password (masked by default)
|
||||||
|
html += `
|
||||||
|
<div class="field">
|
||||||
|
<div class="label">password</div>
|
||||||
|
<div class="field-value" id="password-val" style="cursor:pointer;">
|
||||||
|
<span id="password-display">********</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// TOTP
|
||||||
|
if (entry.totp_secret) {
|
||||||
|
html += `
|
||||||
|
<div class="field">
|
||||||
|
<div class="label">totp</div>
|
||||||
|
<div class="totp-code" id="totp-code">------</div>
|
||||||
|
<div class="totp-bar"><div class="totp-bar-fill" id="totp-bar-fill" style="width:100%;"></div></div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notes
|
||||||
|
if (entry.notes) {
|
||||||
|
html += `
|
||||||
|
<div class="field">
|
||||||
|
<div class="label">notes</div>
|
||||||
|
<div class="field-value">${escapeHtml(entry.notes)}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group
|
||||||
|
if (entry.group) {
|
||||||
|
html += `
|
||||||
|
<div class="field">
|
||||||
|
<div class="label">group</div>
|
||||||
|
<div class="field-value">${escapeHtml(entry.group)}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Metadata
|
||||||
|
html += `
|
||||||
|
<div class="field">
|
||||||
|
<div class="muted">updated ${escapeHtml(entry.updated_at)}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Key hints
|
||||||
|
html += `
|
||||||
|
<div class="keyhints">
|
||||||
|
<span><kbd>c</kbd> copy user</span>
|
||||||
|
<span><kbd>p</kbd> copy pass</span>
|
||||||
|
${entry.totp_secret ? '<span><kbd>t</kbd> copy totp</span>' : ''}
|
||||||
|
<span><kbd>f</kbd> autofill</span>
|
||||||
|
<span><kbd>e</kbd> edit</span>
|
||||||
|
<span><kbd>d</kbd> delete</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
app.innerHTML = html;
|
||||||
|
|
||||||
|
// --- Password toggle ---
|
||||||
|
let passwordVisible = false;
|
||||||
|
const passwordDisplay = document.getElementById('password-display')!;
|
||||||
|
const passwordVal = document.getElementById('password-val')!;
|
||||||
|
passwordVal?.addEventListener('click', () => {
|
||||||
|
passwordVisible = !passwordVisible;
|
||||||
|
passwordDisplay.textContent = passwordVisible ? entry.password : '********';
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Back button ---
|
||||||
|
document.getElementById('back-btn')?.addEventListener('click', goBack);
|
||||||
|
|
||||||
|
// --- TOTP timer ---
|
||||||
|
if (entry.totp_secret) {
|
||||||
|
refreshTotp(id);
|
||||||
|
totpInterval = setInterval(() => refreshTotp(id), 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Keyboard shortcuts ---
|
||||||
|
const handler = async (e: KeyboardEvent) => {
|
||||||
|
// Ignore if typing in an input.
|
||||||
|
if ((e.target as HTMLElement).tagName === 'INPUT') return;
|
||||||
|
|
||||||
|
switch (e.key) {
|
||||||
|
case 'Escape':
|
||||||
|
document.removeEventListener('keydown', handler);
|
||||||
|
goBack();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'c':
|
||||||
|
if (entry.username) await copyToClipboard(entry.username);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'p':
|
||||||
|
await copyToClipboard(entry.password);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 't':
|
||||||
|
if (entry.totp_secret) {
|
||||||
|
const codeEl = document.getElementById('totp-code');
|
||||||
|
if (codeEl) await copyToClipboard(codeEl.textContent ?? '');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'f': {
|
||||||
|
const resp = await sendMessage({
|
||||||
|
type: 'fill_credentials',
|
||||||
|
username: entry.username ?? '',
|
||||||
|
password: entry.password,
|
||||||
|
});
|
||||||
|
if (!resp.ok) setState({ error: resp.error });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'e':
|
||||||
|
document.removeEventListener('keydown', handler);
|
||||||
|
stopTotpTimer();
|
||||||
|
navigate('edit');
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'd':
|
||||||
|
e.preventDefault();
|
||||||
|
showDeleteConfirm(id, entry.name, handler);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('keydown', handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshTotp(id: string): Promise<void> {
|
||||||
|
const resp = await sendMessage({ type: 'get_totp', id });
|
||||||
|
if (resp.ok) {
|
||||||
|
const data = resp.data as { code: string; remaining_seconds: number };
|
||||||
|
const codeEl = document.getElementById('totp-code');
|
||||||
|
const barEl = document.getElementById('totp-bar-fill');
|
||||||
|
if (codeEl) codeEl.textContent = data.code;
|
||||||
|
if (barEl) barEl.style.width = `${(data.remaining_seconds / 30) * 100}%`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function goBack(): void {
|
||||||
|
stopTotpTimer();
|
||||||
|
// Reload the entry list.
|
||||||
|
sendMessage({ type: 'list_entries' }).then(resp => {
|
||||||
|
if (resp.ok) {
|
||||||
|
const data = resp.data as { entries: Array<[string, ManifestEntry]> };
|
||||||
|
navigate('list', {
|
||||||
|
entries: data.entries,
|
||||||
|
selectedId: null,
|
||||||
|
selectedEntry: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function showDeleteConfirm(id: string, name: string, parentHandler: (e: KeyboardEvent) => void): void {
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.className = 'confirm-overlay';
|
||||||
|
overlay.innerHTML = `
|
||||||
|
<div class="confirm-box">
|
||||||
|
<p>Delete <strong>${escapeHtml(name)}</strong>?</p>
|
||||||
|
<button class="btn" id="cancel-delete">cancel</button>
|
||||||
|
<button class="btn btn-danger" id="confirm-delete">delete</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
|
||||||
|
document.getElementById('cancel-delete')?.addEventListener('click', () => {
|
||||||
|
overlay.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('confirm-delete')?.addEventListener('click', async () => {
|
||||||
|
overlay.remove();
|
||||||
|
setState({ loading: true });
|
||||||
|
const resp = await sendMessage({ type: 'delete_entry', id });
|
||||||
|
if (resp.ok) {
|
||||||
|
document.removeEventListener('keydown', parentHandler);
|
||||||
|
stopTotpTimer();
|
||||||
|
goBack();
|
||||||
|
} else {
|
||||||
|
setState({ loading: false, error: resp.error });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
142
extension/src/popup/components/entry-form.ts
Normal file
142
extension/src/popup/components/entry-form.ts
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
/// Entry form — add or edit an entry.
|
||||||
|
|
||||||
|
import { getState, setState, sendMessage, navigate, escapeHtml } from '../popup';
|
||||||
|
import type { Entry, ManifestEntry } from '../../shared/types';
|
||||||
|
|
||||||
|
export function renderEntryForm(app: HTMLElement, mode: 'add' | 'edit'): void {
|
||||||
|
const state = getState();
|
||||||
|
const existing = mode === 'edit' ? state.selectedEntry : null;
|
||||||
|
|
||||||
|
app.innerHTML = `
|
||||||
|
<div class="pad">
|
||||||
|
<div class="detail-title" style="margin-bottom:16px;">${mode === 'add' ? 'new entry' : 'edit entry'}</div>
|
||||||
|
${state.error ? `<div class="error">${escapeHtml(state.error)}</div>` : ''}
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="label" for="f-name">name *</label>
|
||||||
|
<input id="f-name" type="text" value="${escapeHtml(existing?.name ?? '')}" placeholder="GitHub">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="label" for="f-url">url</label>
|
||||||
|
<input id="f-url" type="text" value="${escapeHtml(existing?.url ?? '')}" placeholder="https://github.com/login">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="label" for="f-username">username</label>
|
||||||
|
<input id="f-username" type="text" value="${escapeHtml(existing?.username ?? '')}" placeholder="alice@example.com">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="label" for="f-password">password</label>
|
||||||
|
<div class="inline-row">
|
||||||
|
<input id="f-password" type="password" value="${escapeHtml(existing?.password ?? '')}">
|
||||||
|
<button class="btn" id="gen-btn" title="generate">gen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="label" for="f-totp">totp secret</label>
|
||||||
|
<input id="f-totp" type="text" value="${escapeHtml(existing?.totp_secret ?? '')}" placeholder="JBSWY3DPEHPK3PXP">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="label" for="f-group">group</label>
|
||||||
|
<input id="f-group" type="text" value="${escapeHtml(existing?.group ?? '')}" placeholder="work">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="label" for="f-notes">notes</label>
|
||||||
|
<textarea id="f-notes" placeholder="recovery codes, security questions...">${escapeHtml(existing?.notes ?? '')}</textarea>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="btn" id="cancel-btn">cancel</button>
|
||||||
|
<button class="btn btn-primary" id="save-btn">${state.loading ? '<span class="spinner"></span>' : 'save'}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// --- Generate password ---
|
||||||
|
document.getElementById('gen-btn')?.addEventListener('click', async () => {
|
||||||
|
const resp = await sendMessage({ type: 'generate_password', length: 24 });
|
||||||
|
if (resp.ok) {
|
||||||
|
const data = resp.data as { password: string };
|
||||||
|
const pwInput = document.getElementById('f-password') as HTMLInputElement;
|
||||||
|
pwInput.value = data.password;
|
||||||
|
pwInput.type = 'text'; // Show generated password.
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Cancel ---
|
||||||
|
document.getElementById('cancel-btn')?.addEventListener('click', () => {
|
||||||
|
if (mode === 'edit' && state.selectedId && state.selectedEntry) {
|
||||||
|
navigate('detail');
|
||||||
|
} else {
|
||||||
|
navigate('list');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Save ---
|
||||||
|
document.getElementById('save-btn')?.addEventListener('click', async () => {
|
||||||
|
const name = (document.getElementById('f-name') as HTMLInputElement).value.trim();
|
||||||
|
const url = (document.getElementById('f-url') as HTMLInputElement).value.trim() || undefined;
|
||||||
|
const username = (document.getElementById('f-username') as HTMLInputElement).value.trim() || undefined;
|
||||||
|
const password = (document.getElementById('f-password') as HTMLInputElement).value;
|
||||||
|
const totp_secret = (document.getElementById('f-totp') as HTMLInputElement).value.trim() || undefined;
|
||||||
|
const group = (document.getElementById('f-group') as HTMLInputElement).value.trim() || undefined;
|
||||||
|
const notes = (document.getElementById('f-notes') as HTMLTextAreaElement).value.trim() || undefined;
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
setState({ error: 'Name is required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!password) {
|
||||||
|
setState({ error: 'Password is required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const entry: Entry = {
|
||||||
|
name,
|
||||||
|
url,
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
notes,
|
||||||
|
totp_secret,
|
||||||
|
group,
|
||||||
|
created_at: existing?.created_at ?? now,
|
||||||
|
updated_at: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
setState({ loading: true, error: null });
|
||||||
|
|
||||||
|
let resp;
|
||||||
|
if (mode === 'add') {
|
||||||
|
resp = await sendMessage({ type: 'add_entry', entry });
|
||||||
|
} else {
|
||||||
|
resp = await sendMessage({ type: 'update_entry', id: state.selectedId!, entry });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resp.ok) {
|
||||||
|
// Refresh entries and go to list.
|
||||||
|
const listResp = await sendMessage({ type: 'list_entries' });
|
||||||
|
if (listResp.ok) {
|
||||||
|
const data = listResp.data as { entries: Array<[string, ManifestEntry]> };
|
||||||
|
navigate('list', { entries: data.entries, selectedId: null, selectedEntry: null });
|
||||||
|
} else {
|
||||||
|
navigate('list');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setState({ loading: false, error: resp.error });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Escape to cancel ---
|
||||||
|
const escHandler = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
document.removeEventListener('keydown', escHandler);
|
||||||
|
if (mode === 'edit' && state.selectedId && state.selectedEntry) {
|
||||||
|
navigate('detail');
|
||||||
|
} else {
|
||||||
|
navigate('list');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', escHandler);
|
||||||
|
|
||||||
|
// Focus the name field.
|
||||||
|
(document.getElementById('f-name') as HTMLInputElement)?.focus();
|
||||||
|
}
|
||||||
173
extension/src/popup/components/entry-list.ts
Normal file
173
extension/src/popup/components/entry-list.ts
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
/// Entry list view — search bar, group tabs, scrollable entry list with keyboard nav.
|
||||||
|
|
||||||
|
import { getState, setState, sendMessage, navigate, escapeHtml } from '../popup';
|
||||||
|
import type { ManifestEntry } from '../../shared/types';
|
||||||
|
|
||||||
|
/// Extract the domain from a URL for display.
|
||||||
|
function domainOf(url: string | undefined): string {
|
||||||
|
if (!url) return '';
|
||||||
|
try {
|
||||||
|
return new URL(url).hostname;
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Derive unique group names from the current entries.
|
||||||
|
function getGroups(entries: Array<[string, ManifestEntry]>): string[] {
|
||||||
|
const groups = new Set<string>();
|
||||||
|
for (const [, e] of entries) {
|
||||||
|
if (e.group) groups.add(e.group);
|
||||||
|
}
|
||||||
|
return Array.from(groups).sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderEntryList(app: HTMLElement): void {
|
||||||
|
const state = getState();
|
||||||
|
const groups = getGroups(state.entries);
|
||||||
|
|
||||||
|
// Filter entries by active group (already filtered if group was sent to service worker,
|
||||||
|
// but we also support client-side filtering for instant response).
|
||||||
|
let filtered = state.entries;
|
||||||
|
if (state.activeGroup) {
|
||||||
|
const g = state.activeGroup.toLowerCase();
|
||||||
|
filtered = filtered.filter(([, e]) => e.group?.toLowerCase() === g);
|
||||||
|
}
|
||||||
|
if (state.searchQuery) {
|
||||||
|
const q = state.searchQuery.toLowerCase();
|
||||||
|
filtered = filtered.filter(([, e]) => {
|
||||||
|
if (e.name.toLowerCase().includes(q)) return true;
|
||||||
|
if (e.url?.toLowerCase().includes(q)) return true;
|
||||||
|
if (e.username?.toLowerCase().includes(q)) return true;
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by name.
|
||||||
|
filtered.sort((a, b) => a[1].name.localeCompare(b[1].name));
|
||||||
|
|
||||||
|
const groupTabsHtml = groups.length > 0
|
||||||
|
? `<div class="group-tabs">
|
||||||
|
<button class="group-tab ${!state.activeGroup ? 'active' : ''}" data-group="">all</button>
|
||||||
|
${groups.map(g =>
|
||||||
|
`<button class="group-tab ${state.activeGroup === g ? 'active' : ''}" data-group="${escapeHtml(g)}">${escapeHtml(g)}</button>`
|
||||||
|
).join('')}
|
||||||
|
</div>`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const entriesHtml = filtered.length > 0
|
||||||
|
? filtered.map(([id, e], i) => `
|
||||||
|
<div class="entry-row ${i === state.selectedIndex ? 'selected' : ''}" data-id="${escapeHtml(id)}" data-index="${i}">
|
||||||
|
<span class="entry-name">${escapeHtml(e.name)}</span>
|
||||||
|
<span class="entry-meta">${escapeHtml(e.username ?? '')}${e.username && e.url ? ' · ' : ''}${escapeHtml(domainOf(e.url))}</span>
|
||||||
|
</div>
|
||||||
|
`).join('')
|
||||||
|
: '<div class="empty">no entries</div>';
|
||||||
|
|
||||||
|
app.innerHTML = `
|
||||||
|
<div class="search-bar">
|
||||||
|
<input type="text" id="search-input" placeholder="/ search..." value="${escapeHtml(state.searchQuery)}">
|
||||||
|
</div>
|
||||||
|
${groupTabsHtml}
|
||||||
|
<div class="entry-list" id="entry-list">
|
||||||
|
${entriesHtml}
|
||||||
|
</div>
|
||||||
|
<div class="keyhints">
|
||||||
|
<span><kbd>/</kbd> search</span>
|
||||||
|
<span><kbd>+</kbd> add</span>
|
||||||
|
<span><kbd>↑↓</kbd> nav</span>
|
||||||
|
<span><kbd>Enter</kbd> open</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// --- Event listeners ---
|
||||||
|
|
||||||
|
const searchInput = document.getElementById('search-input') as HTMLInputElement;
|
||||||
|
searchInput?.addEventListener('input', () => {
|
||||||
|
setState({ searchQuery: searchInput.value, selectedIndex: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Group tab clicks.
|
||||||
|
const groupTabs = app.querySelectorAll('.group-tab');
|
||||||
|
groupTabs.forEach(tab => {
|
||||||
|
tab.addEventListener('click', () => {
|
||||||
|
const group = (tab as HTMLElement).dataset.group || null;
|
||||||
|
setState({ activeGroup: group, selectedIndex: 0 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Entry row clicks.
|
||||||
|
const rows = app.querySelectorAll('.entry-row');
|
||||||
|
rows.forEach(row => {
|
||||||
|
row.addEventListener('click', async () => {
|
||||||
|
const id = (row as HTMLElement).dataset.id!;
|
||||||
|
await openEntry(id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keyboard navigation.
|
||||||
|
document.addEventListener('keydown', handleListKeydown);
|
||||||
|
|
||||||
|
// Focus search on / key (unless already focused).
|
||||||
|
searchInput?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openEntry(id: string): Promise<void> {
|
||||||
|
setState({ loading: true });
|
||||||
|
const resp = await sendMessage({ type: 'get_entry', id });
|
||||||
|
if (resp.ok) {
|
||||||
|
const data = resp.data as { entry: import('../../shared/types').Entry };
|
||||||
|
navigate('detail', {
|
||||||
|
selectedId: id,
|
||||||
|
selectedEntry: data.entry,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setState({ loading: false, error: resp.error });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleListKeydown(e: KeyboardEvent): void {
|
||||||
|
const state = getState();
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
const isSearch = target.id === 'search-input';
|
||||||
|
|
||||||
|
if (e.key === '/' && !isSearch) {
|
||||||
|
e.preventDefault();
|
||||||
|
(document.getElementById('search-input') as HTMLInputElement)?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === '+' && !isSearch) {
|
||||||
|
e.preventDefault();
|
||||||
|
navigate('add');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
const max = state.entries.length - 1;
|
||||||
|
setState({ selectedIndex: Math.min(state.selectedIndex + 1, max) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
setState({ selectedIndex: Math.max(state.selectedIndex - 1, 0) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'Enter' && !isSearch) {
|
||||||
|
e.preventDefault();
|
||||||
|
const filtered = state.entries;
|
||||||
|
if (filtered[state.selectedIndex]) {
|
||||||
|
openEntry(filtered[state.selectedIndex][0]);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
// Remove listener to avoid stacking.
|
||||||
|
document.removeEventListener('keydown', handleListKeydown);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
259
extension/src/popup/components/setup-wizard.ts
Normal file
259
extension/src/popup/components/setup-wizard.ts
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
/// Setup wizard — 3-step flow: host config, image upload, test unlock.
|
||||||
|
|
||||||
|
import { getState, setState, sendMessage, navigate, escapeHtml } from '../popup';
|
||||||
|
import type { VaultConfig, ManifestEntry } from '../../shared/types';
|
||||||
|
|
||||||
|
let wizardStep = 0;
|
||||||
|
let wizardConfig: Partial<VaultConfig> = {
|
||||||
|
hostType: 'gitea',
|
||||||
|
};
|
||||||
|
let wizardImageBase64: string | null = null;
|
||||||
|
|
||||||
|
export function renderSetupWizard(app: HTMLElement): void {
|
||||||
|
const state = getState();
|
||||||
|
|
||||||
|
// Progress bar.
|
||||||
|
const progressHtml = `
|
||||||
|
<div class="progress-bar">
|
||||||
|
<div class="step ${wizardStep > 0 ? 'done' : wizardStep === 0 ? 'current' : ''}"></div>
|
||||||
|
<div class="step ${wizardStep > 1 ? 'done' : wizardStep === 1 ? 'current' : ''}"></div>
|
||||||
|
<div class="step ${wizardStep > 2 ? 'done' : wizardStep === 2 ? 'current' : ''}"></div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
let stepHtml = '';
|
||||||
|
|
||||||
|
switch (wizardStep) {
|
||||||
|
case 0:
|
||||||
|
stepHtml = renderStep0();
|
||||||
|
break;
|
||||||
|
case 1:
|
||||||
|
stepHtml = renderStep1();
|
||||||
|
break;
|
||||||
|
case 2:
|
||||||
|
stepHtml = renderStep2(state);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
app.innerHTML = `
|
||||||
|
<div class="pad" style="padding-top:12px;">
|
||||||
|
<div class="brand" style="margin-bottom:4px;">idfoto setup</div>
|
||||||
|
${progressHtml}
|
||||||
|
${stepHtml}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Attach event listeners after rendering.
|
||||||
|
switch (wizardStep) {
|
||||||
|
case 0: attachStep0Listeners(); break;
|
||||||
|
case 1: attachStep1Listeners(); break;
|
||||||
|
case 2: attachStep2Listeners(); break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Step 0: Host configuration ---
|
||||||
|
|
||||||
|
function renderStep0(): string {
|
||||||
|
return `
|
||||||
|
<div class="wizard-step">
|
||||||
|
<h3>git host</h3>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="label">host type</label>
|
||||||
|
<div class="toggle-group">
|
||||||
|
<button class="${wizardConfig.hostType === 'gitea' ? 'active' : ''}" data-host="gitea">Gitea</button>
|
||||||
|
<button class="${wizardConfig.hostType === 'github' ? 'active' : ''}" data-host="github">GitHub</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" id="host-url-group" ${wizardConfig.hostType === 'github' ? 'style="display:none;"' : ''}>
|
||||||
|
<label class="label" for="host-url">host url</label>
|
||||||
|
<input id="host-url" type="text" value="${escapeHtml(wizardConfig.hostUrl ?? '')}" placeholder="https://git.example.com">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="label" for="repo-path">repository</label>
|
||||||
|
<input id="repo-path" type="text" value="${escapeHtml(wizardConfig.repoPath ?? '')}" placeholder="user/vault">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="label" for="api-token">api token</label>
|
||||||
|
<input id="api-token" type="password" value="${escapeHtml(wizardConfig.apiToken ?? '')}" placeholder="token with repo read/write access">
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="btn btn-primary" id="next-btn">next</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachStep0Listeners(): void {
|
||||||
|
// Host type toggle.
|
||||||
|
document.querySelectorAll('.toggle-group button').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const hostType = (btn as HTMLElement).dataset.host as 'gitea' | 'github';
|
||||||
|
wizardConfig.hostType = hostType;
|
||||||
|
const urlGroup = document.getElementById('host-url-group');
|
||||||
|
if (urlGroup) {
|
||||||
|
urlGroup.style.display = hostType === 'github' ? 'none' : '';
|
||||||
|
}
|
||||||
|
document.querySelectorAll('.toggle-group button').forEach(b => b.classList.remove('active'));
|
||||||
|
btn.classList.add('active');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('next-btn')?.addEventListener('click', () => {
|
||||||
|
const repoPath = (document.getElementById('repo-path') as HTMLInputElement).value.trim();
|
||||||
|
const apiToken = (document.getElementById('api-token') as HTMLInputElement).value.trim();
|
||||||
|
const hostUrl = wizardConfig.hostType === 'github'
|
||||||
|
? 'https://api.github.com'
|
||||||
|
: (document.getElementById('host-url') as HTMLInputElement).value.trim();
|
||||||
|
|
||||||
|
if (!repoPath || !apiToken) {
|
||||||
|
setState({ error: 'Repository and API token are required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (wizardConfig.hostType === 'gitea' && !hostUrl) {
|
||||||
|
setState({ error: 'Host URL is required for Gitea' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
wizardConfig = { ...wizardConfig, hostUrl, repoPath, apiToken };
|
||||||
|
wizardStep = 1;
|
||||||
|
setState({ error: null });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Step 1: Reference image upload ---
|
||||||
|
|
||||||
|
function renderStep1(): string {
|
||||||
|
return `
|
||||||
|
<div class="wizard-step">
|
||||||
|
<h3>reference image</h3>
|
||||||
|
<p class="muted" style="margin-bottom:12px;">
|
||||||
|
Upload the JPEG that contains your embedded secret.
|
||||||
|
This is the second factor for vault decryption.
|
||||||
|
</p>
|
||||||
|
<div class="file-drop ${wizardImageBase64 ? 'has-file' : ''}" id="file-drop">
|
||||||
|
<input type="file" id="file-input" accept="image/jpeg" style="display:none;">
|
||||||
|
${wizardImageBase64
|
||||||
|
? '<p class="secondary">image loaded</p>'
|
||||||
|
: '<p class="secondary">click to select JPEG</p>'}
|
||||||
|
</div>
|
||||||
|
<div class="form-actions" style="margin-top:16px;">
|
||||||
|
<button class="btn" id="back-btn">back</button>
|
||||||
|
<button class="btn btn-primary" id="next-btn" ${!wizardImageBase64 ? 'disabled' : ''}>next</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachStep1Listeners(): void {
|
||||||
|
const fileDrop = document.getElementById('file-drop')!;
|
||||||
|
const fileInput = document.getElementById('file-input') as HTMLInputElement;
|
||||||
|
|
||||||
|
fileDrop.addEventListener('click', () => fileInput.click());
|
||||||
|
|
||||||
|
fileInput.addEventListener('change', () => {
|
||||||
|
const file = fileInput.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
const result = reader.result as string;
|
||||||
|
// Remove the data:image/jpeg;base64, prefix.
|
||||||
|
const base64 = result.split(',')[1] ?? result;
|
||||||
|
wizardImageBase64 = base64;
|
||||||
|
setState({ error: null }); // Re-render to show "image loaded".
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('back-btn')?.addEventListener('click', () => {
|
||||||
|
wizardStep = 0;
|
||||||
|
setState({ error: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('next-btn')?.addEventListener('click', () => {
|
||||||
|
if (!wizardImageBase64) return;
|
||||||
|
wizardStep = 2;
|
||||||
|
setState({ error: null });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Step 2: Test unlock ---
|
||||||
|
|
||||||
|
function renderStep2(state: ReturnType<typeof getState>): string {
|
||||||
|
return `
|
||||||
|
<div class="wizard-step">
|
||||||
|
<h3>test unlock</h3>
|
||||||
|
<p class="muted" style="margin-bottom:12px;">
|
||||||
|
Enter your passphrase to verify the configuration works.
|
||||||
|
</p>
|
||||||
|
<div class="form-group">
|
||||||
|
<input id="test-passphrase" type="password" placeholder="passphrase" ${state.loading ? 'disabled' : ''}>
|
||||||
|
</div>
|
||||||
|
${state.loading ? '<div style="margin:12px 0;"><span class="spinner"></span></div>' : ''}
|
||||||
|
${state.error ? `<div class="error">${escapeHtml(state.error)}</div>` : ''}
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="btn" id="back-btn">back</button>
|
||||||
|
<button class="btn btn-primary" id="save-btn" ${state.loading ? 'disabled' : ''}>save & unlock</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachStep2Listeners(): void {
|
||||||
|
document.getElementById('back-btn')?.addEventListener('click', () => {
|
||||||
|
wizardStep = 1;
|
||||||
|
setState({ error: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
const saveBtn = document.getElementById('save-btn');
|
||||||
|
const input = document.getElementById('test-passphrase') as HTMLInputElement;
|
||||||
|
|
||||||
|
const doSave = async () => {
|
||||||
|
const passphrase = input?.value;
|
||||||
|
if (!passphrase) {
|
||||||
|
setState({ error: 'Passphrase is required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState({ loading: true, error: null });
|
||||||
|
|
||||||
|
// Save config first.
|
||||||
|
const saveResp = await sendMessage({
|
||||||
|
type: 'save_setup',
|
||||||
|
config: wizardConfig as VaultConfig,
|
||||||
|
imageBase64: wizardImageBase64!,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!saveResp.ok) {
|
||||||
|
setState({ loading: false, error: saveResp.error });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to unlock.
|
||||||
|
const unlockResp = await sendMessage({ type: 'unlock', passphrase });
|
||||||
|
if (!unlockResp.ok) {
|
||||||
|
setState({ loading: false, error: unlockResp.error });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success — go to entry list.
|
||||||
|
const listResp = await sendMessage({ type: 'list_entries' });
|
||||||
|
if (listResp.ok) {
|
||||||
|
const data = listResp.data as { entries: Array<[string, ManifestEntry]> };
|
||||||
|
// Reset wizard state for next time.
|
||||||
|
wizardStep = 0;
|
||||||
|
wizardConfig = { hostType: 'gitea' };
|
||||||
|
wizardImageBase64 = null;
|
||||||
|
navigate('list', { entries: data.entries });
|
||||||
|
} else {
|
||||||
|
setState({ loading: false, error: listResp.error });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
saveBtn?.addEventListener('click', doSave);
|
||||||
|
input?.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter') doSave();
|
||||||
|
});
|
||||||
|
|
||||||
|
input?.focus();
|
||||||
|
}
|
||||||
56
extension/src/popup/components/unlock.ts
Normal file
56
extension/src/popup/components/unlock.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
/// Unlock view — passphrase input with ENTER to submit.
|
||||||
|
|
||||||
|
import { getState, setState, sendMessage, navigate, escapeHtml } from '../popup';
|
||||||
|
import type { ManifestEntry } from '../../shared/types';
|
||||||
|
|
||||||
|
export function renderUnlock(app: HTMLElement): void {
|
||||||
|
const state = getState();
|
||||||
|
|
||||||
|
app.innerHTML = `
|
||||||
|
<div class="pad" style="text-align:center; padding-top:40px;">
|
||||||
|
<div class="brand">idfoto</div>
|
||||||
|
<p class="muted" style="margin:8px 0 24px;">two-factor vault</p>
|
||||||
|
<div class="form-group">
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="passphrase-input"
|
||||||
|
placeholder="passphrase"
|
||||||
|
autocomplete="off"
|
||||||
|
${state.loading ? 'disabled' : ''}
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
${state.loading ? '<div style="margin:12px 0;"><span class="spinner"></span></div>' : ''}
|
||||||
|
${state.error ? `<div class="error">${escapeHtml(state.error)}</div>` : ''}
|
||||||
|
<div style="margin-top:24px;">
|
||||||
|
<button class="btn" id="settings-btn" style="font-size:11px;">settings</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const input = document.getElementById('passphrase-input') as HTMLInputElement;
|
||||||
|
if (input && !state.loading) {
|
||||||
|
input.focus();
|
||||||
|
input.addEventListener('keydown', async (e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
const passphrase = input.value;
|
||||||
|
if (!passphrase) return;
|
||||||
|
setState({ loading: true, error: null });
|
||||||
|
const resp = await sendMessage({ type: 'unlock', passphrase });
|
||||||
|
if (resp.ok) {
|
||||||
|
const listResp = await sendMessage({ type: 'list_entries' });
|
||||||
|
if (listResp.ok) {
|
||||||
|
const data = listResp.data as { entries: Array<[string, ManifestEntry]> };
|
||||||
|
navigate('list', { entries: data.entries });
|
||||||
|
} else {
|
||||||
|
setState({ loading: false, error: listResp.error });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setState({ loading: false, error: resp.error });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const settingsBtn = document.getElementById('settings-btn');
|
||||||
|
settingsBtn?.addEventListener('click', () => navigate('setup'));
|
||||||
|
}
|
||||||
133
extension/src/popup/popup.ts
Normal file
133
extension/src/popup/popup.ts
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
/// Popup entry point — state machine with view routing.
|
||||||
|
///
|
||||||
|
/// Views: setup | locked | list | detail | add | edit
|
||||||
|
/// Navigation works by updating `currentState` and calling `render()`.
|
||||||
|
|
||||||
|
import type { Request, Response } from '../shared/messages';
|
||||||
|
import type { ManifestEntry, Entry } from '../shared/types';
|
||||||
|
import { renderUnlock } from './components/unlock';
|
||||||
|
import { renderEntryList } from './components/entry-list';
|
||||||
|
import { renderEntryDetail } from './components/entry-detail';
|
||||||
|
import { renderEntryForm } from './components/entry-form';
|
||||||
|
import { renderSetupWizard } from './components/setup-wizard';
|
||||||
|
|
||||||
|
// --- Escape HTML to prevent XSS ---
|
||||||
|
export function escapeHtml(str: string): string {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = str;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- State ---
|
||||||
|
|
||||||
|
export type View = 'setup' | 'locked' | 'list' | 'detail' | 'add' | 'edit';
|
||||||
|
|
||||||
|
export interface PopupState {
|
||||||
|
view: View;
|
||||||
|
entries: Array<[string, ManifestEntry]>;
|
||||||
|
selectedId: string | null;
|
||||||
|
selectedEntry: Entry | null;
|
||||||
|
selectedIndex: number;
|
||||||
|
searchQuery: string;
|
||||||
|
activeGroup: string | null;
|
||||||
|
error: string | null;
|
||||||
|
loading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentState: PopupState = {
|
||||||
|
view: 'locked',
|
||||||
|
entries: [],
|
||||||
|
selectedId: null,
|
||||||
|
selectedEntry: null,
|
||||||
|
selectedIndex: 0,
|
||||||
|
searchQuery: '',
|
||||||
|
activeGroup: null,
|
||||||
|
error: null,
|
||||||
|
loading: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getState(): PopupState {
|
||||||
|
return currentState;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setState(partial: Partial<PopupState>): void {
|
||||||
|
currentState = { ...currentState, ...partial };
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Messaging ---
|
||||||
|
|
||||||
|
export function sendMessage(request: Request): Promise<Response> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
chrome.runtime.sendMessage(request, (response: Response) => {
|
||||||
|
resolve(response);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Navigation ---
|
||||||
|
|
||||||
|
export function navigate(view: View, extras?: Partial<PopupState>): void {
|
||||||
|
setState({ view, error: null, loading: false, ...extras });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Render ---
|
||||||
|
|
||||||
|
function render(): void {
|
||||||
|
const app = document.getElementById('app');
|
||||||
|
if (!app) return;
|
||||||
|
|
||||||
|
switch (currentState.view) {
|
||||||
|
case 'setup':
|
||||||
|
renderSetupWizard(app);
|
||||||
|
break;
|
||||||
|
case 'locked':
|
||||||
|
renderUnlock(app);
|
||||||
|
break;
|
||||||
|
case 'list':
|
||||||
|
renderEntryList(app);
|
||||||
|
break;
|
||||||
|
case 'detail':
|
||||||
|
renderEntryDetail(app);
|
||||||
|
break;
|
||||||
|
case 'add':
|
||||||
|
renderEntryForm(app, 'add');
|
||||||
|
break;
|
||||||
|
case 'edit':
|
||||||
|
renderEntryForm(app, 'edit');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Init ---
|
||||||
|
|
||||||
|
async function init(): Promise<void> {
|
||||||
|
// Check if extension is configured.
|
||||||
|
const setupResp = await sendMessage({ type: 'get_setup_state' });
|
||||||
|
if (setupResp.ok) {
|
||||||
|
const data = setupResp.data as { isConfigured: boolean };
|
||||||
|
if (!data.isConfigured) {
|
||||||
|
navigate('setup');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if vault is unlocked.
|
||||||
|
const unlockResp = await sendMessage({ type: 'is_unlocked' });
|
||||||
|
if (unlockResp.ok) {
|
||||||
|
const data = unlockResp.data as { unlocked: boolean };
|
||||||
|
if (data.unlocked) {
|
||||||
|
// Load entries and go to list.
|
||||||
|
const listResp = await sendMessage({ type: 'list_entries' });
|
||||||
|
if (listResp.ok) {
|
||||||
|
const listData = listResp.data as { entries: Array<[string, ManifestEntry]> };
|
||||||
|
navigate('list', { entries: listData.entries });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
navigate('locked');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', init);
|
||||||
Reference in New Issue
Block a user