310 lines
11 KiB
TypeScript
310 lines
11 KiB
TypeScript
/// Typed-item list view — toolbar (search, new, sync, lock, settings) +
|
||
/// 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 type { ItemId, ItemType, ManifestEntry, Item } from '../../shared/types';
|
||
|
||
/// Extract the display hostname from an icon_hint or fallback to the first tag.
|
||
function metaLine(e: ManifestEntry): string {
|
||
if (e.icon_hint) return e.icon_hint;
|
||
if (e.tags.length > 0) return e.tags.join(', ');
|
||
return '';
|
||
}
|
||
|
||
/// Emoji icon per item type. Placeholder until we ship real SVG icons.
|
||
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 '⏱';
|
||
}
|
||
}
|
||
|
||
export function renderItemList(app: HTMLElement): void {
|
||
const state = getState();
|
||
const filtered = getFilteredEntries();
|
||
|
||
const rowsHtml = 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"><span class="type-icon" aria-hidden="true">${typeIcon(e.type)}</span> ${escapeHtml(e.title)}</span>
|
||
<span class="entry-meta">${escapeHtml(metaLine(e))}</span>
|
||
</div>
|
||
`).join('')
|
||
: '<div class="empty">no items</div>';
|
||
|
||
app.innerHTML = `
|
||
<div class="search-bar">
|
||
<input type="text" id="search-input" placeholder="/ search..." value="${escapeHtml(state.searchQuery)}">
|
||
</div>
|
||
<div class="toolbar" style="display:flex; gap:4px; padding:6px 12px; border-bottom:1px solid #21262d;">
|
||
<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="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 class="keyhints">
|
||
<span><kbd>/</kbd> search</span>
|
||
<span><kbd>+</kbd> new</span>
|
||
<span><kbd>↑↓</kbd> nav</span>
|
||
<span><kbd>Enter</kbd> open</span>
|
||
</div>
|
||
`;
|
||
|
||
// --- Event listeners ---
|
||
|
||
const searchInput = document.getElementById('search-input') as HTMLInputElement | null;
|
||
searchInput?.addEventListener('input', () => {
|
||
setState({ searchQuery: searchInput.value, selectedIndex: 0 });
|
||
});
|
||
|
||
document.getElementById('new-btn')?.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
showNewTypePicker(e.currentTarget as HTMLElement);
|
||
});
|
||
|
||
document.getElementById('sync-btn')?.addEventListener('click', async () => {
|
||
setState({ loading: true, error: null });
|
||
const resp = await sendMessage({ type: 'sync' });
|
||
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 });
|
||
}
|
||
});
|
||
|
||
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');
|
||
rows.forEach(row => {
|
||
row.addEventListener('click', async () => {
|
||
const id = (row as HTMLElement).dataset.id!;
|
||
document.removeEventListener('keydown', handleListKeydown);
|
||
await openItem(id);
|
||
});
|
||
});
|
||
|
||
// Keyboard navigation.
|
||
document.addEventListener('keydown', handleListKeydown);
|
||
|
||
// Focus search on open.
|
||
searchInput?.focus();
|
||
}
|
||
|
||
async function openItem(id: ItemId): Promise<void> {
|
||
setState({ loading: true });
|
||
const resp = await sendMessage({ type: 'get_item', id });
|
||
if (resp.ok) {
|
||
const data = resp.data as { item: Item };
|
||
navigate('detail', {
|
||
selectedId: id,
|
||
selectedItem: data.item,
|
||
});
|
||
} else {
|
||
setState({ loading: false, error: resp.error });
|
||
}
|
||
}
|
||
|
||
/// Compute the visible (filtered) entry list from current state.
|
||
function getFilteredEntries(): Array<[ItemId, ManifestEntry]> {
|
||
const state = getState();
|
||
// Hide trashed items from the main list.
|
||
let filtered = state.entries.filter(([, e]) => e.trashed_at === undefined || e.trashed_at === null);
|
||
if (state.searchQuery) {
|
||
const q = state.searchQuery.toLowerCase();
|
||
filtered = filtered.filter(([, e]) => {
|
||
if (e.title.toLowerCase().includes(q)) return true;
|
||
if (e.icon_hint?.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;
|
||
});
|
||
}
|
||
filtered.sort((a, b) => a[1].title.localeCompare(b[1].title));
|
||
return filtered;
|
||
}
|
||
|
||
/// True if the event target is an editable field (input/textarea/contenteditable).
|
||
/// Global shortcut handlers should bail when the user is typing into a field —
|
||
/// otherwise printable characters like "/" and "+" get eaten by the shortcut
|
||
/// routing and never reach the input.
|
||
function isEditableTarget(target: EventTarget | null): boolean {
|
||
if (!(target instanceof HTMLElement)) return false;
|
||
const tag = target.tagName;
|
||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true;
|
||
if (target.isContentEditable) return true;
|
||
return false;
|
||
}
|
||
|
||
function handleListKeydown(e: KeyboardEvent): void {
|
||
const state = getState();
|
||
const target = e.target as HTMLElement;
|
||
const isSearch = target.id === 'search-input';
|
||
|
||
// If the user is typing into any input/textarea (other than the list's own
|
||
// search field, which we want to focus on "/" even from outside it), let the
|
||
// keystroke through. The "/" shortcut below is specifically "jump to search
|
||
// from the list," not "steal printable characters while typing."
|
||
if (isEditableTarget(target) && !isSearch) {
|
||
if (e.key === 'Escape') {
|
||
document.removeEventListener('keydown', handleListKeydown);
|
||
window.close();
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (e.key === '/' && !isSearch) {
|
||
e.preventDefault();
|
||
(document.getElementById('search-input') as HTMLInputElement | null)?.focus();
|
||
return;
|
||
}
|
||
|
||
if (e.key === '+' && !isSearch) {
|
||
e.preventDefault();
|
||
document.removeEventListener('keydown', handleListKeydown);
|
||
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();
|
||
const selected = filtered[state.selectedIndex];
|
||
if (selected) {
|
||
document.removeEventListener('keydown', handleListKeydown);
|
||
void openItem(selected[0]);
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (e.key === 'Escape') {
|
||
document.removeEventListener('keydown', handleListKeydown);
|
||
window.close();
|
||
return;
|
||
}
|
||
}
|
||
|
||
// ----------------------------------------------------------------------
|
||
// New-item type picker popover
|
||
// ----------------------------------------------------------------------
|
||
|
||
const NEW_TYPE_OPTIONS: Array<{ type: ItemType; icon: string; label: string; disabled?: boolean; tooltip?: 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: 'totp', icon: '⏱', label: 'totp' },
|
||
{ type: 'document', icon: '📄', label: 'document', disabled: true, tooltip: 'coming in γ — needs attachment upload' },
|
||
];
|
||
|
||
function showNewTypePicker(anchor: HTMLElement): void {
|
||
document.querySelectorAll('.new-type-picker').forEach((el) => el.remove());
|
||
|
||
const picker = document.createElement('div');
|
||
picker.className = 'new-type-picker';
|
||
Object.assign(picker.style, {
|
||
position: 'absolute',
|
||
background: '#161b22',
|
||
border: '1px solid #30363d',
|
||
borderRadius: '6px',
|
||
boxShadow: '0 4px 12px rgba(0,0,0,0.4)',
|
||
padding: '4px',
|
||
minWidth: '160px',
|
||
zIndex: '999999',
|
||
fontSize: '12px',
|
||
});
|
||
|
||
const rect = anchor.getBoundingClientRect();
|
||
picker.style.top = `${rect.bottom + 4}px`;
|
||
picker.style.left = `${rect.left}px`;
|
||
|
||
for (const opt of NEW_TYPE_OPTIONS) {
|
||
const row = document.createElement('div');
|
||
Object.assign(row.style, {
|
||
padding: '6px 10px',
|
||
cursor: opt.disabled ? 'not-allowed' : 'pointer',
|
||
color: opt.disabled ? '#484f58' : '#c9d1d9',
|
||
borderRadius: '4px',
|
||
display: 'flex', alignItems: 'center', gap: '8px',
|
||
});
|
||
if (opt.tooltip) row.title = opt.tooltip;
|
||
const iconSpan = document.createElement('span');
|
||
Object.assign(iconSpan.style, { fontSize: '14px', width: '16px', display: 'inline-block', textAlign: 'center' });
|
||
iconSpan.textContent = opt.icon;
|
||
const labelSpan = document.createElement('span');
|
||
labelSpan.textContent = opt.label;
|
||
row.appendChild(iconSpan);
|
||
row.appendChild(labelSpan);
|
||
if (!opt.disabled) {
|
||
row.addEventListener('mouseenter', () => { row.style.background = '#21262d'; });
|
||
row.addEventListener('mouseleave', () => { row.style.background = 'transparent'; });
|
||
row.addEventListener('click', (ev) => {
|
||
ev.stopPropagation();
|
||
picker.remove();
|
||
document.removeEventListener('click', closeOnOutside);
|
||
document.removeEventListener('keydown', closeOnEsc);
|
||
setState({ newType: opt.type });
|
||
navigate('add');
|
||
});
|
||
}
|
||
picker.appendChild(row);
|
||
}
|
||
|
||
document.body.appendChild(picker);
|
||
|
||
const closeOnOutside = (ev: MouseEvent) => {
|
||
if (!picker.contains(ev.target as Node)) {
|
||
picker.remove();
|
||
document.removeEventListener('click', closeOnOutside);
|
||
document.removeEventListener('keydown', closeOnEsc);
|
||
}
|
||
};
|
||
const closeOnEsc = (ev: KeyboardEvent) => {
|
||
if (ev.key === 'Escape') {
|
||
picker.remove();
|
||
document.removeEventListener('click', closeOnOutside);
|
||
document.removeEventListener('keydown', closeOnEsc);
|
||
}
|
||
};
|
||
setTimeout(() => {
|
||
document.addEventListener('click', closeOnOutside);
|
||
document.addEventListener('keydown', closeOnEsc);
|
||
}, 0);
|
||
}
|