333 lines
12 KiB
TypeScript
333 lines
12 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, openVaultTab } from '../../shared/state';
|
|
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.
|
|
function metaLine(e: ManifestEntry): string {
|
|
if (e.icon_hint) return e.icon_hint;
|
|
if (e.tags.length > 0) return e.tags.join(', ');
|
|
return '';
|
|
}
|
|
|
|
/// Glyph icon per item type.
|
|
function typeIcon(t: ItemType): string {
|
|
switch (t) {
|
|
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) => `
|
|
<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-meta">${escapeHtml(metaLine(e))}</span>
|
|
</div>
|
|
`).join('')
|
|
: '<div class="empty">no items</div>';
|
|
}
|
|
|
|
function updateItemList(): void {
|
|
const list = document.getElementById('item-list');
|
|
if (list) {
|
|
list.innerHTML = buildRowsHtml();
|
|
wireRowClicks();
|
|
}
|
|
}
|
|
|
|
function wireRowClicks(): void {
|
|
document.querySelectorAll('.entry-row').forEach(row => {
|
|
row.addEventListener('click', async () => {
|
|
const id = (row as HTMLElement).dataset.id!;
|
|
document.removeEventListener('keydown', handleListKeydown);
|
|
await openItem(id);
|
|
});
|
|
});
|
|
}
|
|
|
|
export function renderItemList(app: HTMLElement): void {
|
|
const state = getState();
|
|
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="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>
|
|
<div class="entry-list" id="item-list">
|
|
${buildRowsHtml()}
|
|
</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', () => {
|
|
const state2 = getState();
|
|
state2.searchQuery = searchInput.value;
|
|
state2.selectedIndex = 0;
|
|
const list = document.getElementById('item-list');
|
|
if (list) list.innerHTML = buildRowsHtml();
|
|
wireRowClicks();
|
|
});
|
|
|
|
document.getElementById('vault-btn')?.addEventListener('click', () => openVaultTab());
|
|
|
|
document.getElementById('new-btn')?.addEventListener('click', () => {
|
|
setState({ newType: null });
|
|
navigate('add');
|
|
});
|
|
|
|
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', (e) => {
|
|
e.stopPropagation();
|
|
showSettingsPicker(e.currentTarget as HTMLElement);
|
|
});
|
|
|
|
wireRowClicks();
|
|
|
|
document.addEventListener('keydown', handleListKeydown);
|
|
}
|
|
|
|
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();
|
|
const entries: Array<[ItemId, ManifestEntry]> = state.entries;
|
|
// Hide trashed items from the main list.
|
|
let filtered = 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;
|
|
}
|
|
|
|
if (e.key === 'F' && e.shiftKey) {
|
|
e.preventDefault();
|
|
openVaultTab();
|
|
return;
|
|
}
|
|
|
|
const filtered = getFilteredEntries();
|
|
|
|
if (e.key === 'ArrowDown') {
|
|
e.preventDefault();
|
|
const max = Math.max(filtered.length - 1, 0);
|
|
state.selectedIndex = Math.min(state.selectedIndex + 1, max);
|
|
updateItemList();
|
|
return;
|
|
}
|
|
|
|
if (e.key === 'ArrowUp') {
|
|
e.preventDefault();
|
|
state.selectedIndex = Math.max(state.selectedIndex - 1, 0);
|
|
updateItemList();
|
|
return;
|
|
}
|
|
|
|
if (e.key === 'Enter') {
|
|
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;
|
|
}
|
|
}
|
|
|
|
// ----------------------------------------------------------------------
|
|
// ----------------------------------------------------------------------
|
|
// Settings picker popover (device vs vault)
|
|
// ----------------------------------------------------------------------
|
|
|
|
const SETTINGS_OPTIONS: Array<{ view: 'settings' | 'settings-vault'; icon: string; label: string }> = [
|
|
{ view: 'settings', icon: GLYPH_DEVICES, label: 'device settings' },
|
|
{ view: 'settings-vault', icon: GLYPH_LOCK, label: 'vault settings' },
|
|
];
|
|
|
|
function showSettingsPicker(anchor: HTMLElement): void {
|
|
document.querySelectorAll('.settings-picker').forEach((el) => el.remove());
|
|
|
|
const picker = document.createElement('div');
|
|
picker.className = 'settings-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: '170px',
|
|
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 SETTINGS_OPTIONS) {
|
|
const row = document.createElement('div');
|
|
Object.assign(row.style, {
|
|
padding: '6px 10px', cursor: 'pointer', color: '#c9d1d9',
|
|
borderRadius: '4px', display: 'flex', alignItems: 'center', gap: '8px',
|
|
});
|
|
const iconSpan = document.createElement('span');
|
|
iconSpan.textContent = opt.icon;
|
|
Object.assign(iconSpan.style, { fontSize: '14px', width: '16px', textAlign: 'center' });
|
|
const labelSpan = document.createElement('span');
|
|
labelSpan.textContent = opt.label;
|
|
row.appendChild(iconSpan);
|
|
row.appendChild(labelSpan);
|
|
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);
|
|
navigate(opt.view);
|
|
});
|
|
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);
|
|
}
|