feat(ext/popup): typed-item list view
Rewrites item-list.ts to render the typed-item ManifestEntry v2 surface: title + type-icon emoji (🔑/📝/🪪/💳/🗝/📄/⏱) + icon_hint as the meta line. Toolbar now has +new, sync, settings, lock. Keyboard nav unchanged (/, +, arrows, Enter). Clicking a row fires list_items → get_item (the new typed-item messages) and stores the full Item in state.selectedItem before navigating to 'detail'. Also updates popup.ts PopupState: - entries now typed Array<[ItemId, ManifestEntry]> - selectedEntry → selectedItem (Item) - init() uses list_items not list_entries Trashed items (trashed_at set) are filtered out of the visible list. @ts-nocheck removed from item-list.ts. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,62 +1,60 @@
|
|||||||
// @ts-nocheck — transitional: downstream files updated in Slice 6 (item-* rewrites) / Slice 4 (vitest setup) / Slice 5 (content + setup rewires)
|
/// Typed-item list view — toolbar (search, new, sync, lock, settings) +
|
||||||
/// Entry list view — search bar, group tabs, scrollable entry list with keyboard nav.
|
/// type-iconed rows. Clicking a row fetches the full Item and navigates
|
||||||
|
/// to the detail view.
|
||||||
|
|
||||||
import { getState, setState, sendMessage, navigate, escapeHtml } from '../popup';
|
import { getState, setState, sendMessage, navigate, escapeHtml } from '../popup';
|
||||||
import type { ManifestEntry } from '../../shared/types';
|
import type { ItemId, ItemType, ManifestEntry, Item } from '../../shared/types';
|
||||||
|
|
||||||
/// Extract the domain from a URL for display.
|
/// Extract the display hostname from an icon_hint or fallback to the first tag.
|
||||||
function domainOf(url: string | undefined): string {
|
function metaLine(e: ManifestEntry): string {
|
||||||
if (!url) return '';
|
if (e.icon_hint) return e.icon_hint;
|
||||||
try {
|
if (e.tags.length > 0) return e.tags.join(', ');
|
||||||
return new URL(url).hostname;
|
return '';
|
||||||
} catch {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Derive unique group names from the current entries.
|
/// Emoji icon per item type. Placeholder until we ship real SVG icons.
|
||||||
function getGroups(entries: Array<[string, ManifestEntry]>): string[] {
|
function typeIcon(t: ItemType): string {
|
||||||
const groups = new Set<string>();
|
switch (t) {
|
||||||
for (const [, e] of entries) {
|
case 'login': return '🔑';
|
||||||
if (e.group) groups.add(e.group);
|
case 'secure_note': return '📝';
|
||||||
|
case 'identity': return '🪪';
|
||||||
|
case 'card': return '💳';
|
||||||
|
case 'key': return '🗝';
|
||||||
|
case 'document': return '📄';
|
||||||
|
case 'totp': return '⏱';
|
||||||
}
|
}
|
||||||
return Array.from(groups).sort();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderItemList(app: HTMLElement): void {
|
export function renderItemList(app: HTMLElement): void {
|
||||||
const state = getState();
|
const state = getState();
|
||||||
const groups = getGroups(state.entries);
|
|
||||||
const filtered = getFilteredEntries();
|
const filtered = getFilteredEntries();
|
||||||
|
|
||||||
const groupTabsHtml = groups.length > 0
|
const rowsHtml = filtered.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) => `
|
? filtered.map(([id, e], i) => `
|
||||||
<div class="entry-row ${i === state.selectedIndex ? 'selected' : ''}" data-id="${escapeHtml(id)}" data-index="${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-name"><span class="type-icon" aria-hidden="true">${typeIcon(e.type)}</span> ${escapeHtml(e.title)}</span>
|
||||||
<span class="entry-meta">${escapeHtml(e.username ?? '')}${e.username && e.url ? ' · ' : ''}${escapeHtml(domainOf(e.url))}</span>
|
<span class="entry-meta">${escapeHtml(metaLine(e))}</span>
|
||||||
</div>
|
</div>
|
||||||
`).join('')
|
`).join('')
|
||||||
: '<div class="empty">no entries</div>';
|
: '<div class="empty">no items</div>';
|
||||||
|
|
||||||
app.innerHTML = `
|
app.innerHTML = `
|
||||||
<div class="search-bar">
|
<div class="search-bar">
|
||||||
<input type="text" id="search-input" placeholder="/ search..." value="${escapeHtml(state.searchQuery)}">
|
<input type="text" id="search-input" placeholder="/ search..." value="${escapeHtml(state.searchQuery)}">
|
||||||
</div>
|
</div>
|
||||||
${groupTabsHtml}
|
<div class="toolbar" style="display:flex; gap:4px; padding:6px 12px; border-bottom:1px solid #21262d;">
|
||||||
<div class="entry-list" id="entry-list">
|
<button class="btn" id="new-btn" style="font-size:11px;">+ new</button>
|
||||||
${entriesHtml}
|
<button class="btn" id="sync-btn" style="font-size:11px;">sync</button>
|
||||||
|
<span style="flex:1;"></span>
|
||||||
|
<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>
|
||||||
|
<div class="entry-list" id="item-list">
|
||||||
|
${rowsHtml}
|
||||||
</div>
|
</div>
|
||||||
<div class="keyhints">
|
<div class="keyhints">
|
||||||
<span><kbd>/</kbd> search</span>
|
<span><kbd>/</kbd> search</span>
|
||||||
<span><kbd>+</kbd> add</span>
|
<span><kbd>+</kbd> new</span>
|
||||||
<span><kbd>↑↓</kbd> nav</span>
|
<span><kbd>↑↓</kbd> nav</span>
|
||||||
<span><kbd>Enter</kbd> open</span>
|
<span><kbd>Enter</kbd> open</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -64,44 +62,60 @@ export function renderItemList(app: HTMLElement): void {
|
|||||||
|
|
||||||
// --- Event listeners ---
|
// --- Event listeners ---
|
||||||
|
|
||||||
const searchInput = document.getElementById('search-input') as HTMLInputElement;
|
const searchInput = document.getElementById('search-input') as HTMLInputElement | null;
|
||||||
searchInput?.addEventListener('input', () => {
|
searchInput?.addEventListener('input', () => {
|
||||||
setState({ searchQuery: searchInput.value, selectedIndex: 0 });
|
setState({ searchQuery: searchInput.value, selectedIndex: 0 });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Group tab clicks.
|
document.getElementById('new-btn')?.addEventListener('click', () => navigate('add'));
|
||||||
const groupTabs = app.querySelectorAll('.group-tab');
|
|
||||||
groupTabs.forEach(tab => {
|
document.getElementById('sync-btn')?.addEventListener('click', async () => {
|
||||||
tab.addEventListener('click', () => {
|
setState({ loading: true, error: null });
|
||||||
const group = (tab as HTMLElement).dataset.group || null;
|
const resp = await sendMessage({ type: 'sync' });
|
||||||
setState({ activeGroup: group, selectedIndex: 0 });
|
if (resp.ok) {
|
||||||
});
|
const listResp = await sendMessage({ type: 'list_items' });
|
||||||
|
if (listResp.ok) {
|
||||||
|
const data = listResp.data as { items: Array<[ItemId, ManifestEntry]> };
|
||||||
|
setState({ entries: data.items, loading: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState({ loading: false, error: listResp.error });
|
||||||
|
} else {
|
||||||
|
setState({ loading: false, error: resp.error });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Entry row clicks.
|
document.getElementById('lock-btn')?.addEventListener('click', async () => {
|
||||||
|
await sendMessage({ type: 'lock' });
|
||||||
|
navigate('locked');
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('settings-btn')?.addEventListener('click', () => navigate('settings'));
|
||||||
|
|
||||||
|
// Item row clicks.
|
||||||
const rows = app.querySelectorAll('.entry-row');
|
const rows = app.querySelectorAll('.entry-row');
|
||||||
rows.forEach(row => {
|
rows.forEach(row => {
|
||||||
row.addEventListener('click', async () => {
|
row.addEventListener('click', async () => {
|
||||||
const id = (row as HTMLElement).dataset.id!;
|
const id = (row as HTMLElement).dataset.id!;
|
||||||
await openEntry(id);
|
await openItem(id);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Keyboard navigation.
|
// Keyboard navigation.
|
||||||
document.addEventListener('keydown', handleListKeydown);
|
document.addEventListener('keydown', handleListKeydown);
|
||||||
|
|
||||||
// Focus search on / key (unless already focused).
|
// Focus search on open.
|
||||||
searchInput?.focus();
|
searchInput?.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openEntry(id: string): Promise<void> {
|
async function openItem(id: ItemId): Promise<void> {
|
||||||
setState({ loading: true });
|
setState({ loading: true });
|
||||||
const resp = await sendMessage({ type: 'get_entry', id });
|
const resp = await sendMessage({ type: 'get_item', id });
|
||||||
if (resp.ok) {
|
if (resp.ok) {
|
||||||
const data = resp.data as { entry: import('../../shared/types').Entry };
|
const data = resp.data as { item: Item };
|
||||||
navigate('detail', {
|
navigate('detail', {
|
||||||
selectedId: id,
|
selectedId: id,
|
||||||
selectedEntry: data.entry,
|
selectedItem: data.item,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
setState({ loading: false, error: resp.error });
|
setState({ loading: false, error: resp.error });
|
||||||
@@ -109,23 +123,21 @@ async function openEntry(id: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Compute the visible (filtered) entry list from current state.
|
/// Compute the visible (filtered) entry list from current state.
|
||||||
function getFilteredEntries(): Array<[string, ManifestEntry]> {
|
function getFilteredEntries(): Array<[ItemId, ManifestEntry]> {
|
||||||
const state = getState();
|
const state = getState();
|
||||||
let filtered = state.entries;
|
// Hide trashed items from the main list.
|
||||||
if (state.activeGroup) {
|
let filtered = state.entries.filter(([, e]) => e.trashed_at === undefined || e.trashed_at === null);
|
||||||
const g = state.activeGroup.toLowerCase();
|
|
||||||
filtered = filtered.filter(([, e]) => e.group?.toLowerCase() === g);
|
|
||||||
}
|
|
||||||
if (state.searchQuery) {
|
if (state.searchQuery) {
|
||||||
const q = state.searchQuery.toLowerCase();
|
const q = state.searchQuery.toLowerCase();
|
||||||
filtered = filtered.filter(([, e]) => {
|
filtered = filtered.filter(([, e]) => {
|
||||||
if (e.name.toLowerCase().includes(q)) return true;
|
if (e.title.toLowerCase().includes(q)) return true;
|
||||||
if (e.url?.toLowerCase().includes(q)) return true;
|
if (e.icon_hint?.toLowerCase().includes(q)) return true;
|
||||||
if (e.username?.toLowerCase().includes(q)) return true;
|
if (e.group?.toLowerCase().includes(q)) return true;
|
||||||
|
if (e.tags.some((t) => t.toLowerCase().includes(q))) return true;
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
filtered.sort((a, b) => a[1].name.localeCompare(b[1].name));
|
filtered.sort((a, b) => a[1].title.localeCompare(b[1].title));
|
||||||
return filtered;
|
return filtered;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,12 +148,13 @@ function handleListKeydown(e: KeyboardEvent): void {
|
|||||||
|
|
||||||
if (e.key === '/' && !isSearch) {
|
if (e.key === '/' && !isSearch) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
(document.getElementById('search-input') as HTMLInputElement)?.focus();
|
(document.getElementById('search-input') as HTMLInputElement | null)?.focus();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (e.key === '+' && !isSearch) {
|
if (e.key === '+' && !isSearch) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
document.removeEventListener('keydown', handleListKeydown);
|
||||||
navigate('add');
|
navigate('add');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -163,8 +176,10 @@ function handleListKeydown(e: KeyboardEvent): void {
|
|||||||
|
|
||||||
if (e.key === 'Enter' && !isSearch) {
|
if (e.key === 'Enter' && !isSearch) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (filtered[state.selectedIndex]) {
|
const selected = filtered[state.selectedIndex];
|
||||||
openEntry(filtered[state.selectedIndex][0]);
|
if (selected) {
|
||||||
|
document.removeEventListener('keydown', handleListKeydown);
|
||||||
|
void openItem(selected[0]);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
/// Navigation works by updating `currentState` and calling `render()`.
|
/// Navigation works by updating `currentState` and calling `render()`.
|
||||||
|
|
||||||
import type { Request, Response } from '../shared/messages';
|
import type { Request, Response } from '../shared/messages';
|
||||||
import type { ManifestEntry, Entry } from '../shared/types';
|
import type { ItemId, ManifestEntry, Item } from '../shared/types';
|
||||||
import { renderUnlock } from './components/unlock';
|
import { renderUnlock } from './components/unlock';
|
||||||
import { renderItemList } from './components/item-list';
|
import { renderItemList } from './components/item-list';
|
||||||
import { renderItemDetail } from './components/item-detail';
|
import { renderItemDetail } from './components/item-detail';
|
||||||
@@ -25,9 +25,9 @@ export type View = 'locked' | 'list' | 'detail' | 'add' | 'edit' | 'settings';
|
|||||||
|
|
||||||
export interface PopupState {
|
export interface PopupState {
|
||||||
view: View;
|
view: View;
|
||||||
entries: Array<[string, ManifestEntry]>;
|
entries: Array<[ItemId, ManifestEntry]>;
|
||||||
selectedId: string | null;
|
selectedId: ItemId | null;
|
||||||
selectedEntry: Entry | null;
|
selectedItem: Item | null;
|
||||||
selectedIndex: number;
|
selectedIndex: number;
|
||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
activeGroup: string | null;
|
activeGroup: string | null;
|
||||||
@@ -45,7 +45,7 @@ let currentState: PopupState = {
|
|||||||
view: 'locked',
|
view: 'locked',
|
||||||
entries: [],
|
entries: [],
|
||||||
selectedId: null,
|
selectedId: null,
|
||||||
selectedEntry: null,
|
selectedItem: null,
|
||||||
selectedIndex: 0,
|
selectedIndex: 0,
|
||||||
searchQuery: '',
|
searchQuery: '',
|
||||||
activeGroup: null,
|
activeGroup: null,
|
||||||
@@ -135,10 +135,10 @@ async function init(): Promise<void> {
|
|||||||
const data = unlockResp.data as { unlocked: boolean };
|
const data = unlockResp.data as { unlocked: boolean };
|
||||||
if (data.unlocked) {
|
if (data.unlocked) {
|
||||||
// Load entries and go to list.
|
// Load entries and go to list.
|
||||||
const listResp = await sendMessage({ type: 'list_entries' });
|
const listResp = await sendMessage({ type: 'list_items' });
|
||||||
if (listResp.ok) {
|
if (listResp.ok) {
|
||||||
const listData = listResp.data as { entries: Array<[string, ManifestEntry]> };
|
const listData = listResp.data as { items: Array<[ItemId, ManifestEntry]> };
|
||||||
navigate('list', { entries: listData.entries });
|
navigate('list', { entries: listData.items });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user