178 lines
5.4 KiB
TypeScript
178 lines
5.4 KiB
TypeScript
// @ts-nocheck — transitional: downstream files updated in Slice 6 (item-* rewrites) / Slice 4 (vitest setup) / Slice 5 (content + setup rewires)
|
|
/// 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);
|
|
const filtered = getFilteredEntries();
|
|
|
|
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 });
|
|
}
|
|
}
|
|
|
|
/// Compute the visible (filtered) entry list from current state.
|
|
function getFilteredEntries(): Array<[string, ManifestEntry]> {
|
|
const state = getState();
|
|
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;
|
|
});
|
|
}
|
|
filtered.sort((a, b) => a[1].name.localeCompare(b[1].name));
|
|
return filtered;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
const filtered = getFilteredEntries();
|
|
|
|
if (e.key === 'ArrowDown') {
|
|
e.preventDefault();
|
|
const max = Math.max(filtered.length - 1, 0);
|
|
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();
|
|
if (filtered[state.selectedIndex]) {
|
|
openEntry(filtered[state.selectedIndex][0]);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (e.key === 'Escape') {
|
|
document.removeEventListener('keydown', handleListKeydown);
|
|
window.close();
|
|
return;
|
|
}
|
|
}
|