import { createEffect, createMemo, createSignal, For, Show } from "solid-js"; import type { VaultEntry } from "../../shared/types"; import { getDisplayDomain } from "../../shared/domain"; import { faviconUrl } from "../../shared/favicon"; function FaviconIcon(props: { domain: string; isNote: boolean; isPasskey: boolean }) { const src = props.domain && !props.isNote && !props.isPasskey ? faviconUrl(props.domain) : null; return ( ); } const LOGIN_ICON = ( ); const PASSKEY_ICON = ( ); const NOTE_ICON = ( ); export type SortOrder = "url-first" | "az" | "za" | "recent"; interface VaultProps { entries: VaultEntry[]; selectedEntryId?: string; currentTabDomain?: string; sortOrder: SortOrder; searchSeed?: string; searchSeedNonce?: number; onSelectEntry: (entry: VaultEntry) => void; onSortOrderChange: (order: SortOrder) => void; onAdd: () => void; onClose: () => void; onOpenTrash?: () => void; onDeleteMany?: (ids: string[]) => Promise; } export function VaultView(props: VaultProps) { const [search, setSearch] = createSignal(""); const [selectedTags, setSelectedTags] = createSignal>(new Set()); const [notesOnly, setNotesOnly] = createSignal(false); const [selecting, setSelecting] = createSignal(false); const [selectedIds, setSelectedIds] = createSignal>(new Set()); const [deleting, setDeleting] = createSignal(false); let searchRef: HTMLInputElement | undefined; let listRef: HTMLDivElement | undefined; const focusSearch = (el: HTMLInputElement) => { searchRef = el; setTimeout(() => el.focus(), 50); }; createEffect(() => { void props.searchSeedNonce; const seed = props.searchSeed; if (!seed) return; setSearch(seed); requestAnimationFrame(() => { searchRef?.focus(); searchRef?.setSelectionRange(seed.length, seed.length); }); }); function enterSelectMode() { setSelecting(true); setSelectedIds(new Set()); } function exitSelectMode() { setSelecting(false); setSelectedIds(new Set()); } function toggleId(id: string) { const s = new Set(selectedIds()); if (s.has(id)) s.delete(id); else s.add(id); setSelectedIds(s); } const allTags = createMemo(() => { const tagSet = new Set(); for (const entry of props.entries) { for (const tag of entry.tags ?? []) { tagSet.add(tag); } } return Array.from(tagSet).sort(); }); const hasTags = createMemo(() => allTags().length > 0); function toggleTag(tag: string) { const current = new Set(selectedTags()); if (current.has(tag)) { current.delete(tag); } else { current.add(tag); } setSelectedTags(current); } function fuzzyMatch(query: string, target: string): boolean { query = query.toLowerCase(); target = target.toLowerCase(); let qi = 0; for (let i = 0; i < target.length && qi < query.length; i++) { if (target[i] === query[qi]) qi++; } return qi === query.length; } function isExactPrefix(query: string, target: string): boolean { return target.toLowerCase().startsWith(query.toLowerCase()); } function entryDomain(entry: VaultEntry): string { if (entry.type === "login" && entry.url) return getDisplayDomain(entry.url); if (entry.type === "passkey" && entry.rpId) return entry.rpId; return ""; } const filteredEntries = createMemo(() => { let result = props.entries; if (notesOnly()) { result = result.filter(entry => entry.type === "note"); } if (selectedTags().size > 0) { result = result.filter(entry => Array.from(selectedTags()).every(tag => entry.tags?.includes(tag)) ); } const q = search().trim(); if (q) { result = result .filter((entry) => { const searchable = entry.type === "note" ? `${entry.title} ${entry.notes ?? ""}` : entry.type === "passkey" ? `${entry.title} ${entry.rpId ?? ""} ${entry.userName ?? ""} ${entry.userDisplayName ?? ""}` : `${entry.title} ${entry.url ?? ""} ${entry.username ?? ""}`; return fuzzyMatch(q, searchable); }) .sort((a, b) => { const aSearchable = a.type === "note" ? `${a.title} ${a.notes ?? ""}`.toLowerCase() : a.type === "passkey" ? `${a.title} ${a.rpId ?? ""} ${a.userName ?? ""}`.toLowerCase() : `${a.title} ${a.url ?? ""} ${a.username ?? ""}`.toLowerCase(); const bSearchable = b.type === "note" ? `${b.title} ${b.notes ?? ""}`.toLowerCase() : b.type === "passkey" ? `${b.title} ${b.rpId ?? ""} ${b.userName ?? ""}`.toLowerCase() : `${b.title} ${b.url ?? ""} ${b.username ?? ""}`.toLowerCase(); const qLower = q.toLowerCase(); const aExact = isExactPrefix(qLower, aSearchable); const bExact = isExactPrefix(qLower, bSearchable); if (aExact && !bExact) return -1; if (!aExact && bExact) return 1; return 0; }); return result; } const tabDomain = props.currentTabDomain ?? ""; const order = props.sortOrder; if (order === "az") { return [...result].sort((a, b) => a.title.localeCompare(b.title)); } if (order === "za") { return [...result].sort((a, b) => b.title.localeCompare(a.title)); } if (order === "recent") { return [...result].sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0)); } if (tabDomain) { return [...result].sort((a, b) => { const aMatch = entryDomain(a) === tabDomain ? 0 : 1; const bMatch = entryDomain(b) === tabDomain ? 0 : 1; return aMatch - bMatch; }); } return result; }); const allVisibleIds = createMemo(() => filteredEntries().map(e => e.id)); const allSelected = createMemo(() => allVisibleIds().length > 0 && allVisibleIds().every(id => selectedIds().has(id)) ); function toggleSelectAll() { if (allSelected()) { setSelectedIds(new Set()); } else { setSelectedIds(new Set(allVisibleIds())); } } async function handleDeleteSelected() { const ids = Array.from(selectedIds()); if (!ids.length || !props.onDeleteMany) return; setDeleting(true); await props.onDeleteMany(ids); setDeleting(false); exitSelectMode(); } function handleSearchKeydown(e: KeyboardEvent) { if (e.key === "ArrowDown") { e.preventDefault(); const firstBtn = listRef?.querySelector(".peach-entry-row"); firstBtn?.focus(); } else if (e.key === "ArrowUp") { e.preventDefault(); const allBtns = listRef?.querySelectorAll(".peach-entry-row"); allBtns?.[allBtns.length - 1]?.focus(); } else if (e.key === "Enter") { e.preventDefault(); const firstEntry = filteredEntries()[0]; if (firstEntry) props.onSelectEntry(firstEntry); } else if (e.key === "Escape" && selecting()) { exitSelectMode(); } } function handleListKeydown(e: KeyboardEvent) { if (!listRef) return; const btns = Array.from(listRef.querySelectorAll(".peach-entry-row")); const idx = btns.indexOf(document.activeElement as HTMLButtonElement); if (e.key === "ArrowDown") { e.preventDefault(); if (idx < btns.length - 1) btns[idx + 1]?.focus(); } else if (e.key === "ArrowUp") { e.preventDefault(); if (idx === 0) { searchRef?.focus(); } else btns[idx - 1]?.focus(); } else if (e.key === "Escape") { if (selecting()) exitSelectMode(); else props.onClose(); } } const sortLabels: { key: SortOrder; label: string }[] = [ { key: "url-first", label: "smart" }, { key: "az", label: "A–Z" }, { key: "za", label: "Z–A" }, { key: "recent", label: "newest" }, ]; return (
setSearch((e.currentTarget as HTMLInputElement).value)} onKeyDown={handleSearchKeydown} /> {search() || selectedTags().size > 0 ? `${filteredEntries().length} of ${props.entries.length}` : `${props.entries.length} entries`}
{/* Sort bar — morphs into select-mode controls */}
{({ key, label }) => ( )} } > {/* Select mode sort bar */} {selectedIds().size > 0 ? `${selectedIds().size} selected` : "select entries"}
{(tag) => ( )}
0} fallback={
no entries yet
} > {(entry, index) => { const isNote = entry.type === "note"; const isPasskey = entry.type === "passkey"; const domain = entry.type === "note" ? (entry.notes?.slice(0, 40) ?? "note") : entry.type === "passkey" ? entry.rpId ?? "passkey" : entry.url ? getDisplayDomain(entry.url) : "saved entry"; const isTabMatch = () => props.currentTabDomain !== undefined && props.currentTabDomain !== "" && entryDomain(entry) === props.currentTabDomain; const isSelected = () => selectedIds().has(entry.id); return ( ); }}
{/* Bottom bar — new entry normally, delete action in select mode */} new entry } >
); }