1import { createEffect, createMemo, createSignal, For, Show } from "solid-js";2import type { VaultEntry } from "../../shared/types";3import { getDisplayDomain } from "../../shared/domain";4import { faviconUrl } from "../../shared/favicon";56function FaviconIcon(props: { domain: string; isNote: boolean; isPasskey: boolean }) {7 const src = props.domain && !props.isNote && !props.isPasskey8 ? faviconUrl(props.domain)9 : null;10 return (11 <Show when={src} fallback={props.isPasskey ? PASSKEY_ICON : props.isNote ? NOTE_ICON : LOGIN_ICON}>12 <img src={src!} width="16" height="16" alt="" style="border-radius:3px;" />13 </Show>14 );15}1617const LOGIN_ICON = (18 <svg width="11" height="12" viewBox="0 0 11 12" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round" aria-hidden="true">19 <rect x="2" y="5" width="7" height="5" rx="1.2" />20 <path d="M3.5 5V3.75A2 2 0 0 1 5.5 1.75A2 2 0 0 1 7.5 3.75V5" />21 </svg>22);2324const PASSKEY_ICON = (25 <svg width="11" height="12" viewBox="0 0 11 12" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round" aria-hidden="true">26 <circle cx="4" cy="4.5" r="3" />27 <path d="M6.5 7l3.5 3.5M8.5 8.5L10 10" />28 </svg>29);3031const NOTE_ICON = (32 <svg width="11" height="12" viewBox="0 0 11 12" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round" aria-hidden="true">33 <rect x="1.5" y="1.5" width="8" height="9" rx="1.5" />34 <path d="M3.5 4.5h4M3.5 6.5h4M3.5 8.5h2" />35 </svg>36);3738export type SortOrder = "url-first" | "az" | "za" | "recent";3940interface VaultProps {41 entries: VaultEntry[];42 selectedEntryId?: string;43 currentTabDomain?: string;44 sortOrder: SortOrder;45 searchSeed?: string;46 searchSeedNonce?: number;47 onSelectEntry: (entry: VaultEntry) => void;48 onSortOrderChange: (order: SortOrder) => void;49 onAdd: () => void;50 onClose: () => void;51 onOpenTrash?: () => void;52 onDeleteMany?: (ids: string[]) => Promise<void>;53}5455export function VaultView(props: VaultProps) {56 const [search, setSearch] = createSignal("");57 const [selectedTags, setSelectedTags] = createSignal<Set<string>>(new Set());58 const [notesOnly, setNotesOnly] = createSignal(false);59 const [selecting, setSelecting] = createSignal(false);60 const [selectedIds, setSelectedIds] = createSignal<Set<string>>(new Set());61 const [deleting, setDeleting] = createSignal(false);6263 let searchRef: HTMLInputElement | undefined;64 let listRef: HTMLDivElement | undefined;6566 const focusSearch = (el: HTMLInputElement) => {67 searchRef = el;68 setTimeout(() => el.focus(), 50);69 };7071 createEffect(() => {72 void props.searchSeedNonce;73 const seed = props.searchSeed;74 if (!seed) return;75 setSearch(seed);76 requestAnimationFrame(() => {77 searchRef?.focus();78 searchRef?.setSelectionRange(seed.length, seed.length);79 });80 });8182 function enterSelectMode() {83 setSelecting(true);84 setSelectedIds(new Set<string>());85 }8687 function exitSelectMode() {88 setSelecting(false);89 setSelectedIds(new Set<string>());90 }9192 function toggleId(id: string) {93 const s = new Set<string>(selectedIds());94 if (s.has(id)) s.delete(id); else s.add(id);95 setSelectedIds(s);96 }9798 const allTags = createMemo(() => {99 const tagSet = new Set<string>();100 for (const entry of props.entries) {101 for (const tag of entry.tags ?? []) {102 tagSet.add(tag);103 }104 }105 return Array.from(tagSet).sort();106 });107108 const hasTags = createMemo(() => allTags().length > 0);109110 function toggleTag(tag: string) {111 const current = new Set(selectedTags());112 if (current.has(tag)) {113 current.delete(tag);114 } else {115 current.add(tag);116 }117 setSelectedTags(current);118 }119120 function fuzzyMatch(query: string, target: string): boolean {121 query = query.toLowerCase();122 target = target.toLowerCase();123 let qi = 0;124 for (let i = 0; i < target.length && qi < query.length; i++) {125 if (target[i] === query[qi]) qi++;126 }127 return qi === query.length;128 }129130 function isExactPrefix(query: string, target: string): boolean {131 return target.toLowerCase().startsWith(query.toLowerCase());132 }133134 function entryDomain(entry: VaultEntry): string {135 if (entry.type === "login" && entry.url) return getDisplayDomain(entry.url);136 if (entry.type === "passkey" && entry.rpId) return entry.rpId;137 return "";138 }139140 const filteredEntries = createMemo(() => {141 let result = props.entries;142143 if (notesOnly()) {144 result = result.filter(entry => entry.type === "note");145 }146147 if (selectedTags().size > 0) {148 result = result.filter(entry =>149 Array.from(selectedTags()).every(tag => entry.tags?.includes(tag))150 );151 }152153 const q = search().trim();154 if (q) {155 result = result156 .filter((entry) => {157 const searchable = entry.type === "note"158 ? `${entry.title} ${entry.notes ?? ""}`159 : entry.type === "passkey"160 ? `${entry.title} ${entry.rpId ?? ""} ${entry.userName ?? ""} ${entry.userDisplayName ?? ""}`161 : `${entry.title} ${entry.url ?? ""} ${entry.username ?? ""}`;162 return fuzzyMatch(q, searchable);163 })164 .sort((a, b) => {165 const aSearchable = a.type === "note"166 ? `${a.title} ${a.notes ?? ""}`.toLowerCase()167 : a.type === "passkey"168 ? `${a.title} ${a.rpId ?? ""} ${a.userName ?? ""}`.toLowerCase()169 : `${a.title} ${a.url ?? ""} ${a.username ?? ""}`.toLowerCase();170 const bSearchable = b.type === "note"171 ? `${b.title} ${b.notes ?? ""}`.toLowerCase()172 : b.type === "passkey"173 ? `${b.title} ${b.rpId ?? ""} ${b.userName ?? ""}`.toLowerCase()174 : `${b.title} ${b.url ?? ""} ${b.username ?? ""}`.toLowerCase();175 const qLower = q.toLowerCase();176 const aExact = isExactPrefix(qLower, aSearchable);177 const bExact = isExactPrefix(qLower, bSearchable);178 if (aExact && !bExact) return -1;179 if (!aExact && bExact) return 1;180 return 0;181 });182 return result;183 }184185 const tabDomain = props.currentTabDomain ?? "";186 const order = props.sortOrder;187188 if (order === "az") {189 return [...result].sort((a, b) => a.title.localeCompare(b.title));190 }191 if (order === "za") {192 return [...result].sort((a, b) => b.title.localeCompare(a.title));193 }194 if (order === "recent") {195 return [...result].sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0));196 }197 if (tabDomain) {198 return [...result].sort((a, b) => {199 const aMatch = entryDomain(a) === tabDomain ? 0 : 1;200 const bMatch = entryDomain(b) === tabDomain ? 0 : 1;201 return aMatch - bMatch;202 });203 }204 return result;205 });206207 const allVisibleIds = createMemo(() => filteredEntries().map(e => e.id));208 const allSelected = createMemo(() =>209 allVisibleIds().length > 0 && allVisibleIds().every(id => selectedIds().has(id))210 );211212 function toggleSelectAll() {213 if (allSelected()) {214 setSelectedIds(new Set<string>());215 } else {216 setSelectedIds(new Set<string>(allVisibleIds()));217 }218 }219220 async function handleDeleteSelected() {221 const ids = Array.from(selectedIds());222 if (!ids.length || !props.onDeleteMany) return;223 setDeleting(true);224 await props.onDeleteMany(ids);225 setDeleting(false);226 exitSelectMode();227 }228229 function handleSearchKeydown(e: KeyboardEvent) {230 if (e.key === "ArrowDown") {231 e.preventDefault();232 const firstBtn = listRef?.querySelector<HTMLButtonElement>(".peach-entry-row");233 firstBtn?.focus();234 } else if (e.key === "ArrowUp") {235 e.preventDefault();236 const allBtns = listRef?.querySelectorAll<HTMLButtonElement>(".peach-entry-row");237 allBtns?.[allBtns.length - 1]?.focus();238 } else if (e.key === "Enter") {239 e.preventDefault();240 const firstEntry = filteredEntries()[0];241 if (firstEntry) props.onSelectEntry(firstEntry);242 } else if (e.key === "Escape" && selecting()) {243 exitSelectMode();244 }245 }246247 function handleListKeydown(e: KeyboardEvent) {248 if (!listRef) return;249 const btns = Array.from(listRef.querySelectorAll<HTMLButtonElement>(".peach-entry-row"));250 const idx = btns.indexOf(document.activeElement as HTMLButtonElement);251 if (e.key === "ArrowDown") {252 e.preventDefault();253 if (idx < btns.length - 1) btns[idx + 1]?.focus();254 } else if (e.key === "ArrowUp") {255 e.preventDefault();256 if (idx === 0) { searchRef?.focus(); }257 else btns[idx - 1]?.focus();258 } else if (e.key === "Escape") {259 if (selecting()) exitSelectMode();260 else props.onClose();261 }262 }263264 const sortLabels: { key: SortOrder; label: string }[] = [265 { key: "url-first", label: "smart" },266 { key: "az", label: "A–Z" },267 { key: "za", label: "Z–A" },268 { key: "recent", label: "newest" },269 ];270271 return (272 <div class="peach-overlay-view ov-enter-up" style="display:flex">273 <div class="peach-list-top">274 <button275 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"276 onClick={props.onClose}277 aria-label="Back"278 >279 <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">280 <path d="M6 1L1 6L6 11" />281 </svg>282 </button>283 <input284 class="peach-search-input"285 placeholder="search vault..."286 autocomplete="off"287 style="font-size:14px"288 ref={focusSearch}289 value={search()}290 onInput={(e) => setSearch((e.currentTarget as HTMLInputElement).value)}291 onKeyDown={handleSearchKeydown}292 />293 <span class="peach-search-counter peach-search-counter-list">294 {search() || selectedTags().size > 0295 ? `${filteredEntries().length} of ${props.entries.length}`296 : `${props.entries.length} entries`}297 </span>298 </div>299300 {}301 <div class="peach-sort-bar">302 <Show303 when={selecting()}304 fallback={305 <>306 <For each={sortLabels}>307 {({ key, label }) => (308 <button309 class={`peach-sort-btn${props.sortOrder === key ? " is-active" : ""}`}310 onClick={() => props.onSortOrderChange(key)}311 >312 {label}313 </button>314 )}315 </For>316 <span style="flex:1" />317 <button318 class={`peach-sort-trash-btn${notesOnly() ? " is-active" : ""}`}319 onClick={() => setNotesOnly(v => !v)}320 aria-label="Notes only"321 title="Show notes only"322 >323 {NOTE_ICON}324 </button>325 <Show when={props.onOpenTrash}>326 <button327 class="peach-sort-trash-btn"328 onClick={props.onOpenTrash}329 aria-label="View trash"330 title="View deleted entries"331 >332 <svg width="11" height="12" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">333 <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />334 </svg>335 </button>336 </Show>337 <Show when={props.onDeleteMany}>338 <button339 class="peach-sort-trash-btn"340 onClick={enterSelectMode}341 aria-label="Select entries"342 title="Select entries"343 >344 <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">345 <circle cx="4" cy="4" r="2.5" />346 <circle cx="9" cy="4" r="2.5" />347 <circle cx="4" cy="9" r="2.5" />348 <circle cx="9" cy="9" r="2.5" />349 </svg>350 </button>351 </Show>352 </>353 }354 >355 {}356 <button357 class="peach-select-cancel-btn"358 onClick={exitSelectMode}359 >360 cancel361 </button>362 <span class="peach-select-count-label">363 {selectedIds().size > 0 ? `${selectedIds().size} selected` : "select entries"}364 </span>365 <button366 class="peach-select-all-btn"367 onClick={toggleSelectAll}368 >369 {allSelected() ? "none" : "all"}370 </button>371 </Show>372 </div>373374 <Show when={hasTags() && !selecting()}>375 <div class="peach-tag-filter-strip" style="display:flex;gap:6px;padding:4px 12px 6px;overflow-x:auto;flex-wrap:nowrap;scrollbar-width:none;">376 <For each={allTags()}>377 {(tag) => (378 <button379 class={`peach-tag-filter-chip ${selectedTags().has(tag) ? "is-selected" : ""}`}380 onClick={() => toggleTag(tag)}381 >382 {tag}383 </button>384 )}385 </For>386 </div>387 </Show>388389 <div390 class={`peach-vault-list${selecting() ? " is-selecting" : ""}`}391 ref={listRef}392 onKeyDown={handleListKeydown}393 >394 <Show395 when={filteredEntries().length > 0}396 fallback={397 <div class="peach-list-empty">398 <span>no entries yet</span>399 </div>400 }401 >402 <For each={filteredEntries()}>403 {(entry, index) => {404 const isNote = entry.type === "note";405 const isPasskey = entry.type === "passkey";406 const domain = entry.type === "note"407 ? (entry.notes?.slice(0, 40) ?? "note")408 : entry.type === "passkey"409 ? entry.rpId ?? "passkey"410 : entry.url411 ? getDisplayDomain(entry.url)412 : "saved entry";413 const isTabMatch = () =>414 props.currentTabDomain !== undefined &&415 props.currentTabDomain !== "" &&416 entryDomain(entry) === props.currentTabDomain;417 const isSelected = () => selectedIds().has(entry.id);418419 return (420 <button421 class={`peach-entry-row${!selecting() && props.selectedEntryId === entry.id ? " is-active" : ""}${!selecting() && isTabMatch() ? " is-tab-match" : ""}${selecting() && isSelected() ? " is-selected" : ""}`}422 style={{ "animation-delay": `${Math.min(index(), 24) * 20}ms` }}423 onClick={() => {424 if (selecting()) {425 toggleId(entry.id);426 } else {427 props.onSelectEntry(entry);428 }429 }}430 >431 <div class={`peach-entry-row-icon${!selecting() && entry.breach ? " is-breach" : ""}${selecting() ? " is-select-icon" : ""}`}>432 <Show433 when={selecting()}434 fallback={435 <FaviconIcon436 domain={entry.url ? getDisplayDomain(entry.url) : ""}437 isNote={isNote}438 isPasskey={isPasskey}439 />440 }441 >442 {}443 <svg width="16" height="16" viewBox="0 0 16 16" fill="none">444 <circle445 cx="8" cy="8" r="7"446 stroke="currentColor"447 stroke-width="1"448 fill={isSelected() ? "var(--peach-accent)" : "transparent"}449 opacity={isSelected() ? "1" : "0.3"}450 />451 <Show when={isSelected()}>452 <path453 d="M5 8.5L7 10.5L11 6"454 stroke="white"455 stroke-width="1.2"456 stroke-linecap="round"457 stroke-linejoin="round"458 />459 </Show>460 </svg>461 </Show>462 </div>463 <div class="peach-entry-row-info">464 <span class="peach-entry-row-name">465 {entry.title}466 <Show when={isPasskey && !selecting()}>467 <span class="peach-passkey-badge">passkey</span>468 </Show>469 <Show when={isTabMatch() && !selecting()}>470 <span class="peach-tab-match-badge" title="Matches current tab">●</span>471 </Show>472 </span>473 <span class="peach-entry-row-meta">{domain}</span>474 </div>475 <Show when={entry.breach && !selecting()}>476 <span class="peach-entry-row-dot" aria-label="breach"></span>477 </Show>478 <Show when={entry.canary && !selecting()}>479 <span class="peach-entry-row-canary" title={`Canary: ${entry.canary?.label ?? "canary entry"}`}>🪤</span>480 </Show>481 </button>482 );483 }}484 </For>485 </Show>486 </div>487488 {}489 <Show490 when={selecting()}491 fallback={492 <button class="peach-new-entry-row" onClick={props.onAdd}>493 <svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round">494 <path d="M5 1v8M1 5h8" />495 </svg>496 new entry497 </button>498 }499 >500 <button501 class={`peach-select-action-bar${selectedIds().size === 0 ? " is-empty" : ""}`}502 disabled={selectedIds().size === 0 || deleting()}503 onClick={() => void handleDeleteSelected()}504 >505 <svg width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">506 <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />507 </svg>508 <Show509 when={selectedIds().size > 0}510 fallback={<span>select entries to delete</span>}511 >512 <span>513 {deleting()514 ? "moving to trash…"515 : `move ${selectedIds().size} to trash`}516 </span>517 </Show>518 </button>519 </Show>520 </div>521 );522}