1import { createEffect, createMemo, createSignal, For, Show } from "solid-js";2import type { VaultEntry } from "../../shared/types";3import { extractDomain, getDisplayDomain } from "../../shared/domain";4import { faviconUrl } from "../../shared/favicon";56function FaviconIcon(props: { domain: string; title?: string; isNote: boolean; isPasskey: boolean }) {7 const src = props.domain && !props.isNote && !props.isPasskey8 ? faviconUrl(props.domain)9 : null;10 11 12 13 14 const fallback = props.isPasskey15 ? PASSKEY_ICON16 : props.isNote17 ? NOTE_ICON18 : <InitialsChip title={props.title ?? props.domain ?? ""} />;19 const [failed, setFailed] = createSignal(false);20 return (21 <Show when={src && !failed()} fallback={fallback}>22 <img23 src={src!}24 width="16"25 height="16"26 alt=""27 style="border-radius:3px;"28 onError={() => setFailed(true)}29 />30 </Show>31 );32}3334353637383940function titleInitials(source: string): string {41 const cleaned = source.replace(/^https?:\/\//, "").replace(/^www\./, "").trim();42 if (!cleaned) return "·";43 const words = cleaned.split(/[\s.\-_/]+/).filter(Boolean);44 if (words.length >= 2 && words[0]![0] && words[1]![0]) {45 return (words[0]![0]! + words[1]![0]!).toUpperCase();46 }47 const single = words[0] ?? cleaned;48 return single.slice(0, 2).toUpperCase();49}50515253545556function titleBackground(source: string): string {57 let hash = 0;58 for (let i = 0; i < source.length; i++) {59 hash = ((hash << 5) - hash + source.charCodeAt(i)) | 0;60 }61 const hue = ((hash % 360) + 360) % 360;62 return `hsl(${hue}, 32%, 26%)`;63}6465function InitialsChip(props: { title: string }) {66 return (67 <span68 class="peach-favicon-initials"69 style={{ background: titleBackground(props.title) }}70 aria-hidden="true"71 >72 {titleInitials(props.title)}73 </span>74 );75}7677const LOGIN_ICON = (78 <svg width="11" height="12" viewBox="0 0 11 12" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round" aria-hidden="true">79 <rect x="2" y="5" width="7" height="5" rx="1.2" />80 <path d="M3.5 5V3.75A2 2 0 0 1 5.5 1.75A2 2 0 0 1 7.5 3.75V5" />81 </svg>82);8384const PASSKEY_ICON = (85 <svg width="11" height="12" viewBox="0 0 11 12" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round" aria-hidden="true">86 <circle cx="4" cy="4.5" r="3" />87 <path d="M6.5 7l3.5 3.5M8.5 8.5L10 10" />88 </svg>89);9091const NOTE_ICON = (92 <svg width="11" height="12" viewBox="0 0 11 12" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round" aria-hidden="true">93 <rect x="1.5" y="1.5" width="8" height="9" rx="1.5" />94 <path d="M3.5 4.5h4M3.5 6.5h4M3.5 8.5h2" />95 </svg>96);9798export type SortOrder = "url-first" | "az" | "za" | "recent";99100interface VaultProps {101 entries: VaultEntry[];102 selectedEntryId?: string;103 currentTabDomain?: string;104 sortOrder: SortOrder;105 searchSeed?: string;106 searchSeedNonce?: number;107 onSelectEntry: (entry: VaultEntry) => void;108 onSortOrderChange: (order: SortOrder) => void;109 onAdd: () => void;110 onClose: () => void;111 onOpenTrash?: () => void;112 onDeleteMany?: (ids: string[]) => Promise<void>;113}114115export function VaultView(props: VaultProps) {116 const [search, setSearch] = createSignal("");117 const [selectedTags, setSelectedTags] = createSignal<Set<string>>(new Set());118 const [notesOnly, setNotesOnly] = createSignal(false);119 const [selecting, setSelecting] = createSignal(false);120 const [selectedIds, setSelectedIds] = createSignal<Set<string>>(new Set());121 const [deleting, setDeleting] = createSignal(false);122123 let searchRef: HTMLInputElement | undefined;124 let listRef: HTMLDivElement | undefined;125126 const focusSearch = (el: HTMLInputElement) => {127 searchRef = el;128 setTimeout(() => el.focus(), 50);129 };130131 createEffect(() => {132 void props.searchSeedNonce;133 const seed = props.searchSeed;134 if (!seed) return;135 setSearch(seed);136 requestAnimationFrame(() => {137 searchRef?.focus();138 searchRef?.setSelectionRange(seed.length, seed.length);139 });140 });141142 function enterSelectMode() {143 setSelecting(true);144 setSelectedIds(new Set<string>());145 }146147 function exitSelectMode() {148 setSelecting(false);149 setSelectedIds(new Set<string>());150 }151152 function toggleId(id: string) {153 const s = new Set<string>(selectedIds());154 if (s.has(id)) s.delete(id); else s.add(id);155 setSelectedIds(s);156 }157158 const allTags = createMemo(() => {159 const tagSet = new Set<string>();160 for (const entry of props.entries) {161 for (const tag of entry.tags ?? []) {162 tagSet.add(tag);163 }164 }165 return Array.from(tagSet).sort();166 });167168 const hasTags = createMemo(() => allTags().length > 0);169170 function toggleTag(tag: string) {171 const current = new Set(selectedTags());172 if (current.has(tag)) {173 current.delete(tag);174 } else {175 current.add(tag);176 }177 setSelectedTags(current);178 }179180 function fuzzyMatch(query: string, target: string): boolean {181 query = query.toLowerCase();182 target = target.toLowerCase();183 let qi = 0;184 for (let i = 0; i < target.length && qi < query.length; i++) {185 if (target[i] === query[qi]) qi++;186 }187 return qi === query.length;188 }189190 function isExactPrefix(query: string, target: string): boolean {191 return target.toLowerCase().startsWith(query.toLowerCase());192 }193194 function entryDomain(entry: VaultEntry): string {195 if (entry.type === "login" && entry.url) return getDisplayDomain(entry.url);196 if (entry.passkey?.rpId) return entry.passkey.rpId;197 return "";198 }199200 function entryMatchesDomain(entry: VaultEntry, domain: string): boolean {201 if (!domain) return false;202 if (entry.passkey?.rpId === domain) return true;203 if (entry.type !== "login") return false;204 205 206 if (entry.url && extractDomain(entry.url) === domain) return true;207 for (const extra of entry.additionalUrls ?? []) {208 if (extractDomain(extra) === domain) return true;209 }210 return false;211 }212213 const filteredEntries = createMemo(() => {214 let result = props.entries;215216 if (notesOnly()) {217 result = result.filter(entry => entry.type === "note");218 }219220 if (selectedTags().size > 0) {221 result = result.filter(entry =>222 Array.from(selectedTags()).every(tag => entry.tags?.includes(tag))223 );224 }225226 const q = search().trim();227 if (q) {228 const searchableFor = (e: VaultEntry): string => {229 if (e.type === "note") return `${e.title} ${e.notes ?? ""}`;230 const passkeyText = e.passkey231 ? ` ${e.passkey.rpId} ${e.passkey.userName} ${e.passkey.userDisplayName}`232 : "";233 return `${e.title} ${e.url ?? ""} ${e.username ?? ""}${passkeyText}`;234 };235 result = result236 .filter((entry) => fuzzyMatch(q, searchableFor(entry)))237 .sort((a, b) => {238 const aSearchable = searchableFor(a).toLowerCase();239 const bSearchable = searchableFor(b).toLowerCase();240 const qLower = q.toLowerCase();241 const aExact = isExactPrefix(qLower, aSearchable);242 const bExact = isExactPrefix(qLower, bSearchable);243 if (aExact && !bExact) return -1;244 if (!aExact && bExact) return 1;245 return 0;246 });247 return result;248 }249250 const tabDomain = props.currentTabDomain ?? "";251 const order = props.sortOrder;252253 if (order === "az") {254 return [...result].sort((a, b) => a.title.localeCompare(b.title));255 }256 if (order === "za") {257 return [...result].sort((a, b) => b.title.localeCompare(a.title));258 }259 if (order === "recent") {260 return [...result].sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0));261 }262 if (tabDomain) {263 return [...result].sort((a, b) => {264 const aMatch = entryMatchesDomain(a, tabDomain) ? 0 : 1;265 const bMatch = entryMatchesDomain(b, tabDomain) ? 0 : 1;266 return aMatch - bMatch;267 });268 }269 return result;270 });271272 273 274 275 276 const allVisibleIds = createMemo(() => filteredEntries().map(e => e.id));277 const allSelected = createMemo(() =>278 allVisibleIds().length > 0 && allVisibleIds().every(id => selectedIds().has(id))279 );280281 function toggleSelectAll() {282 if (allSelected()) {283 setSelectedIds(new Set<string>());284 } else {285 setSelectedIds(new Set<string>(allVisibleIds()));286 }287 }288289 async function handleDeleteSelected() {290 const ids = Array.from(selectedIds());291 if (!ids.length || !props.onDeleteMany) return;292 setDeleting(true);293 await props.onDeleteMany(ids);294 setDeleting(false);295 exitSelectMode();296 }297298 function handleSearchKeydown(e: KeyboardEvent) {299 if (e.key === "ArrowDown") {300 e.preventDefault();301 const firstBtn = listRef?.querySelector<HTMLButtonElement>(".peach-entry-row");302 firstBtn?.focus();303 } else if (e.key === "ArrowUp") {304 e.preventDefault();305 const allBtns = listRef?.querySelectorAll<HTMLButtonElement>(".peach-entry-row");306 allBtns?.[allBtns.length - 1]?.focus();307 } else if (e.key === "Enter") {308 e.preventDefault();309 const firstEntry = filteredEntries()[0];310 if (firstEntry) props.onSelectEntry(firstEntry);311 } else if (e.key === "Escape" && selecting()) {312 exitSelectMode();313 }314 }315316 function handleListKeydown(e: KeyboardEvent) {317 if (!listRef) return;318 const btns = Array.from(listRef.querySelectorAll<HTMLButtonElement>(".peach-entry-row"));319 const idx = btns.indexOf(document.activeElement as HTMLButtonElement);320 if (e.key === "ArrowDown") {321 e.preventDefault();322 if (idx < btns.length - 1) btns[idx + 1]?.focus();323 } else if (e.key === "ArrowUp") {324 e.preventDefault();325 if (idx === 0) { searchRef?.focus(); }326 else btns[idx - 1]?.focus();327 } else if (e.key === "Escape") {328 if (selecting()) exitSelectMode();329 else props.onClose();330 }331 }332333 const sortLabels: { key: SortOrder; label: string }[] = [334 { key: "url-first", label: "smart" },335 { key: "az", label: "A–Z" },336 { key: "za", label: "Z–A" },337 { key: "recent", label: "newest" },338 ];339340 return (341 <div class="peach-overlay-view ov-enter-up" style="display:flex">342 <div class="peach-list-top">343 <button344 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"345 onClick={props.onClose}346 aria-label="Back"347 >348 <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">349 <path d="M6 1L1 6L6 11" />350 </svg>351 </button>352 <input353 class="peach-search-input"354 placeholder="search vault..."355 autocomplete="off"356 style="font-size:14px"357 ref={focusSearch}358 value={search()}359 onInput={(e) => setSearch((e.currentTarget as HTMLInputElement).value)}360 onKeyDown={handleSearchKeydown}361 />362 <span class="peach-search-counter peach-search-counter-list">363 {search() || selectedTags().size > 0364 ? `${filteredEntries().length} of ${props.entries.length}`365 : `${props.entries.length} entries`}366 </span>367 </div>368369 {}370 <div class="peach-sort-bar">371 <Show372 when={selecting()}373 fallback={374 <>375 <For each={sortLabels}>376 {({ key, label }) => (377 <button378 class={`peach-sort-btn${props.sortOrder === key ? " is-active" : ""}`}379 onClick={() => props.onSortOrderChange(key)}380 >381 {label}382 </button>383 )}384 </For>385 <span style="flex:1" />386 <button387 class={`peach-sort-trash-btn${notesOnly() ? " is-active" : ""}`}388 onClick={() => setNotesOnly(v => !v)}389 aria-label="Notes only"390 title="Show notes only"391 >392 {NOTE_ICON}393 </button>394 <Show when={props.onOpenTrash}>395 <button396 class="peach-sort-trash-btn"397 onClick={props.onOpenTrash}398 aria-label="View trash"399 title="View deleted entries"400 >401 <svg width="11" height="12" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">402 <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />403 </svg>404 </button>405 </Show>406 <Show when={props.onDeleteMany}>407 <button408 class="peach-sort-trash-btn"409 onClick={enterSelectMode}410 aria-label="Select entries"411 title="Select entries"412 >413 <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">414 <circle cx="4" cy="4" r="2.5" />415 <circle cx="9" cy="4" r="2.5" />416 <circle cx="4" cy="9" r="2.5" />417 <circle cx="9" cy="9" r="2.5" />418 </svg>419 </button>420 </Show>421 </>422 }423 >424 {}425 <button426 class="peach-select-cancel-btn"427 onClick={exitSelectMode}428 >429 cancel430 </button>431 <span class="peach-select-count-label">432 {selectedIds().size > 0 ? `${selectedIds().size} selected` : "select entries"}433 </span>434 <button435 class="peach-select-all-btn"436 onClick={toggleSelectAll}437 >438 {allSelected() ? "none" : "all"}439 </button>440 </Show>441 </div>442443 <Show when={hasTags() && !selecting()}>444 <div class="peach-tag-filter-strip" style="display:flex;gap:6px;padding:4px 12px 6px;overflow-x:auto;flex-wrap:nowrap;scrollbar-width:none;">445 <For each={allTags()}>446 {(tag) => (447 <button448 class={`peach-tag-filter-chip ${selectedTags().has(tag) ? "is-selected" : ""}`}449 onClick={() => toggleTag(tag)}450 >451 {tag}452 </button>453 )}454 </For>455 </div>456 </Show>457458 <div459 class={`peach-vault-list${selecting() ? " is-selecting" : ""}`}460 ref={listRef}461 onKeyDown={handleListKeydown}462 >463 <Show464 when={filteredEntries().length > 0}465 fallback={466 <div class="peach-list-empty">467 <span>no entries yet</span>468 </div>469 }470 >471 <For each={filteredEntries()}>472 {(entry, index) => {473 const isNote = entry.type === "note";474 const isPasskey = entry.passkey !== undefined;475 const domain = entry.type === "note"476 ? (entry.notes?.slice(0, 40) ?? "note")477 : entry.url478 ? getDisplayDomain(entry.url)479 : entry.passkey?.rpId480 ? entry.passkey.rpId481 : "saved entry";482 const isTabMatch = () =>483 props.currentTabDomain !== undefined &&484 props.currentTabDomain !== "" &&485 entryMatchesDomain(entry, props.currentTabDomain);486 const isSelected = () => selectedIds().has(entry.id);487488 return (489 <button490 class={`peach-entry-row${!selecting() && props.selectedEntryId === entry.id ? " is-active" : ""}${!selecting() && isTabMatch() ? " is-tab-match" : ""}${selecting() && isSelected() ? " is-selected" : ""}`}491 style={{ "animation-delay": `${Math.min(index(), 24) * 20}ms` }}492 onClick={() => {493 if (selecting()) {494 toggleId(entry.id);495 } else {496 props.onSelectEntry(entry);497 }498 }}499 >500 <div class={`peach-entry-row-icon${!selecting() && entry.breach ? " is-breach" : ""}${selecting() ? " is-select-icon" : ""}`}>501 <Show502 when={selecting()}503 fallback={504 <FaviconIcon505 domain={entry.url ? getDisplayDomain(entry.url) : ""}506 title={entry.title}507 isNote={isNote}508 isPasskey={isPasskey}509 />510 }511 >512 {}513 <svg width="16" height="16" viewBox="0 0 16 16" fill="none">514 <circle515 cx="8" cy="8" r="7"516 stroke="currentColor"517 stroke-width="1"518 fill={isSelected() ? "var(--peach-accent)" : "transparent"}519 opacity={isSelected() ? "1" : "0.3"}520 />521 <Show when={isSelected()}>522 <path523 d="M5 8.5L7 10.5L11 6"524 stroke="white"525 stroke-width="1.2"526 stroke-linecap="round"527 stroke-linejoin="round"528 />529 </Show>530 </svg>531 </Show>532 </div>533 <div class="peach-entry-row-info">534 <span class="peach-entry-row-name">535 {entry.title}536 <Show when={isPasskey && !selecting()}>537 <span class="peach-passkey-badge">passkey</span>538 </Show>539 <Show when={isTabMatch() && !selecting()}>540 <span class="peach-tab-match-badge" title="Matches current tab">●</span>541 </Show>542 </span>543 <span class="peach-entry-row-meta">{domain}</span>544 </div>545 <Show when={entry.breach && !selecting()}>546 <span class="peach-entry-row-dot" aria-label="breach"></span>547 </Show>548 <Show when={entry.canary && !selecting()}>549 <span class="peach-entry-row-canary" title={`Canary: ${entry.canary?.label ?? "canary entry"}`}>🪤</span>550 </Show>551 </button>552 );553 }}554 </For>555 </Show>556 </div>557558 {}559 <Show560 when={selecting()}561 fallback={562 <button class="peach-new-entry-row" onClick={props.onAdd}>563 <svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round">564 <path d="M5 1v8M1 5h8" />565 </svg>566 new entry567 </button>568 }569 >570 <button571 class={`peach-select-action-bar${selectedIds().size === 0 ? " is-empty" : ""}`}572 disabled={selectedIds().size === 0 || deleting()}573 onClick={() => void handleDeleteSelected()}574 >575 <svg width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">576 <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />577 </svg>578 <Show579 when={selectedIds().size > 0}580 fallback={<span>select entries to delete</span>}581 >582 <span>583 {deleting()584 ? "moving to trash…"585 : `move ${selectedIds().size} to trash`}586 </span>587 </Show>588 </button>589 </Show>590 </div>591 );592}