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; isNote: boolean; isPasskey: boolean }) {7 const src = props.domain && !props.isNote && !props.isPasskey8 ? faviconUrl(props.domain)9 : null;10 const fallback = props.isPasskey ? PASSKEY_ICON : props.isNote ? NOTE_ICON : LOGIN_ICON;11 const [failed, setFailed] = createSignal(false);12 return (13 <Show when={src && !failed()} fallback={fallback}>14 <img15 src={src!}16 width="16"17 height="16"18 alt=""19 style="border-radius:3px;"20 onError={() => setFailed(true)}21 />22 </Show>23 );24}2526const LOGIN_ICON = (27 <svg width="11" height="12" viewBox="0 0 11 12" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round" aria-hidden="true">28 <rect x="2" y="5" width="7" height="5" rx="1.2" />29 <path d="M3.5 5V3.75A2 2 0 0 1 5.5 1.75A2 2 0 0 1 7.5 3.75V5" />30 </svg>31);3233const PASSKEY_ICON = (34 <svg width="11" height="12" viewBox="0 0 11 12" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round" aria-hidden="true">35 <circle cx="4" cy="4.5" r="3" />36 <path d="M6.5 7l3.5 3.5M8.5 8.5L10 10" />37 </svg>38);3940const NOTE_ICON = (41 <svg width="11" height="12" viewBox="0 0 11 12" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round" aria-hidden="true">42 <rect x="1.5" y="1.5" width="8" height="9" rx="1.5" />43 <path d="M3.5 4.5h4M3.5 6.5h4M3.5 8.5h2" />44 </svg>45);4647export type SortOrder = "url-first" | "az" | "za" | "recent";4849interface VaultProps {50 entries: VaultEntry[];51 selectedEntryId?: string;52 currentTabDomain?: string;53 sortOrder: SortOrder;54 searchSeed?: string;55 searchSeedNonce?: number;56 onSelectEntry: (entry: VaultEntry) => void;57 onSortOrderChange: (order: SortOrder) => void;58 onAdd: () => void;59 onClose: () => void;60 onOpenTrash?: () => void;61 onDeleteMany?: (ids: string[]) => Promise<void>;62}6364export function VaultView(props: VaultProps) {65 const [search, setSearch] = createSignal("");66 const [selectedTags, setSelectedTags] = createSignal<Set<string>>(new Set());67 const [notesOnly, setNotesOnly] = createSignal(false);68 const [selecting, setSelecting] = createSignal(false);69 const [selectedIds, setSelectedIds] = createSignal<Set<string>>(new Set());70 const [deleting, setDeleting] = createSignal(false);7172 let searchRef: HTMLInputElement | undefined;73 let listRef: HTMLDivElement | undefined;7475 const focusSearch = (el: HTMLInputElement) => {76 searchRef = el;77 setTimeout(() => el.focus(), 50);78 };7980 createEffect(() => {81 void props.searchSeedNonce;82 const seed = props.searchSeed;83 if (!seed) return;84 setSearch(seed);85 requestAnimationFrame(() => {86 searchRef?.focus();87 searchRef?.setSelectionRange(seed.length, seed.length);88 });89 });9091 function enterSelectMode() {92 setSelecting(true);93 setSelectedIds(new Set<string>());94 }9596 function exitSelectMode() {97 setSelecting(false);98 setSelectedIds(new Set<string>());99 }100101 function toggleId(id: string) {102 const s = new Set<string>(selectedIds());103 if (s.has(id)) s.delete(id); else s.add(id);104 setSelectedIds(s);105 }106107 const allTags = createMemo(() => {108 const tagSet = new Set<string>();109 for (const entry of props.entries) {110 for (const tag of entry.tags ?? []) {111 tagSet.add(tag);112 }113 }114 return Array.from(tagSet).sort();115 });116117 const hasTags = createMemo(() => allTags().length > 0);118119 function toggleTag(tag: string) {120 const current = new Set(selectedTags());121 if (current.has(tag)) {122 current.delete(tag);123 } else {124 current.add(tag);125 }126 setSelectedTags(current);127 }128129 function fuzzyMatch(query: string, target: string): boolean {130 query = query.toLowerCase();131 target = target.toLowerCase();132 let qi = 0;133 for (let i = 0; i < target.length && qi < query.length; i++) {134 if (target[i] === query[qi]) qi++;135 }136 return qi === query.length;137 }138139 function isExactPrefix(query: string, target: string): boolean {140 return target.toLowerCase().startsWith(query.toLowerCase());141 }142143 function entryDomain(entry: VaultEntry): string {144 if (entry.type === "login" && entry.url) return getDisplayDomain(entry.url);145 if (entry.type === "passkey" && entry.rpId) return entry.rpId;146 return "";147 }148149 function entryMatchesDomain(entry: VaultEntry, domain: string): boolean {150 if (!domain) return false;151 if (entry.type === "passkey") return entry.rpId === domain;152 if (entry.type !== "login") return false;153 154 155 if (entry.url && extractDomain(entry.url) === domain) return true;156 for (const extra of entry.additionalUrls ?? []) {157 if (extractDomain(extra) === domain) return true;158 }159 return false;160 }161162 const filteredEntries = createMemo(() => {163 let result = props.entries;164165 if (notesOnly()) {166 result = result.filter(entry => entry.type === "note");167 }168169 if (selectedTags().size > 0) {170 result = result.filter(entry =>171 Array.from(selectedTags()).every(tag => entry.tags?.includes(tag))172 );173 }174175 const q = search().trim();176 if (q) {177 result = result178 .filter((entry) => {179 const searchable = entry.type === "note"180 ? `${entry.title} ${entry.notes ?? ""}`181 : entry.type === "passkey"182 ? `${entry.title} ${entry.rpId ?? ""} ${entry.userName ?? ""} ${entry.userDisplayName ?? ""}`183 : `${entry.title} ${entry.url ?? ""} ${entry.username ?? ""}`;184 return fuzzyMatch(q, searchable);185 })186 .sort((a, b) => {187 const aSearchable = a.type === "note"188 ? `${a.title} ${a.notes ?? ""}`.toLowerCase()189 : a.type === "passkey"190 ? `${a.title} ${a.rpId ?? ""} ${a.userName ?? ""}`.toLowerCase()191 : `${a.title} ${a.url ?? ""} ${a.username ?? ""}`.toLowerCase();192 const bSearchable = b.type === "note"193 ? `${b.title} ${b.notes ?? ""}`.toLowerCase()194 : b.type === "passkey"195 ? `${b.title} ${b.rpId ?? ""} ${b.userName ?? ""}`.toLowerCase()196 : `${b.title} ${b.url ?? ""} ${b.username ?? ""}`.toLowerCase();197 const qLower = q.toLowerCase();198 const aExact = isExactPrefix(qLower, aSearchable);199 const bExact = isExactPrefix(qLower, bSearchable);200 if (aExact && !bExact) return -1;201 if (!aExact && bExact) return 1;202 return 0;203 });204 return result;205 }206207 const tabDomain = props.currentTabDomain ?? "";208 const order = props.sortOrder;209210 if (order === "az") {211 return [...result].sort((a, b) => a.title.localeCompare(b.title));212 }213 if (order === "za") {214 return [...result].sort((a, b) => b.title.localeCompare(a.title));215 }216 if (order === "recent") {217 return [...result].sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0));218 }219 if (tabDomain) {220 return [...result].sort((a, b) => {221 const aMatch = entryMatchesDomain(a, tabDomain) ? 0 : 1;222 const bMatch = entryMatchesDomain(b, tabDomain) ? 0 : 1;223 return aMatch - bMatch;224 });225 }226 return result;227 });228229 230 231 232 233 const allVisibleIds = createMemo(() => filteredEntries().map(e => e.id));234 const allSelected = createMemo(() =>235 allVisibleIds().length > 0 && allVisibleIds().every(id => selectedIds().has(id))236 );237238 function toggleSelectAll() {239 if (allSelected()) {240 setSelectedIds(new Set<string>());241 } else {242 setSelectedIds(new Set<string>(allVisibleIds()));243 }244 }245246 async function handleDeleteSelected() {247 const ids = Array.from(selectedIds());248 if (!ids.length || !props.onDeleteMany) return;249 setDeleting(true);250 await props.onDeleteMany(ids);251 setDeleting(false);252 exitSelectMode();253 }254255 function handleSearchKeydown(e: KeyboardEvent) {256 if (e.key === "ArrowDown") {257 e.preventDefault();258 const firstBtn = listRef?.querySelector<HTMLButtonElement>(".peach-entry-row");259 firstBtn?.focus();260 } else if (e.key === "ArrowUp") {261 e.preventDefault();262 const allBtns = listRef?.querySelectorAll<HTMLButtonElement>(".peach-entry-row");263 allBtns?.[allBtns.length - 1]?.focus();264 } else if (e.key === "Enter") {265 e.preventDefault();266 const firstEntry = filteredEntries()[0];267 if (firstEntry) props.onSelectEntry(firstEntry);268 } else if (e.key === "Escape" && selecting()) {269 exitSelectMode();270 }271 }272273 function handleListKeydown(e: KeyboardEvent) {274 if (!listRef) return;275 const btns = Array.from(listRef.querySelectorAll<HTMLButtonElement>(".peach-entry-row"));276 const idx = btns.indexOf(document.activeElement as HTMLButtonElement);277 if (e.key === "ArrowDown") {278 e.preventDefault();279 if (idx < btns.length - 1) btns[idx + 1]?.focus();280 } else if (e.key === "ArrowUp") {281 e.preventDefault();282 if (idx === 0) { searchRef?.focus(); }283 else btns[idx - 1]?.focus();284 } else if (e.key === "Escape") {285 if (selecting()) exitSelectMode();286 else props.onClose();287 }288 }289290 const sortLabels: { key: SortOrder; label: string }[] = [291 { key: "url-first", label: "smart" },292 { key: "az", label: "A–Z" },293 { key: "za", label: "Z–A" },294 { key: "recent", label: "newest" },295 ];296297 return (298 <div class="peach-overlay-view ov-enter-up" style="display:flex">299 <div class="peach-list-top">300 <button301 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"302 onClick={props.onClose}303 aria-label="Back"304 >305 <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">306 <path d="M6 1L1 6L6 11" />307 </svg>308 </button>309 <input310 class="peach-search-input"311 placeholder="search vault..."312 autocomplete="off"313 style="font-size:14px"314 ref={focusSearch}315 value={search()}316 onInput={(e) => setSearch((e.currentTarget as HTMLInputElement).value)}317 onKeyDown={handleSearchKeydown}318 />319 <span class="peach-search-counter peach-search-counter-list">320 {search() || selectedTags().size > 0321 ? `${filteredEntries().length} of ${props.entries.length}`322 : `${props.entries.length} entries`}323 </span>324 </div>325326 {}327 <div class="peach-sort-bar">328 <Show329 when={selecting()}330 fallback={331 <>332 <For each={sortLabels}>333 {({ key, label }) => (334 <button335 class={`peach-sort-btn${props.sortOrder === key ? " is-active" : ""}`}336 onClick={() => props.onSortOrderChange(key)}337 >338 {label}339 </button>340 )}341 </For>342 <span style="flex:1" />343 <button344 class={`peach-sort-trash-btn${notesOnly() ? " is-active" : ""}`}345 onClick={() => setNotesOnly(v => !v)}346 aria-label="Notes only"347 title="Show notes only"348 >349 {NOTE_ICON}350 </button>351 <Show when={props.onOpenTrash}>352 <button353 class="peach-sort-trash-btn"354 onClick={props.onOpenTrash}355 aria-label="View trash"356 title="View deleted entries"357 >358 <svg width="11" height="12" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">359 <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />360 </svg>361 </button>362 </Show>363 <Show when={props.onDeleteMany}>364 <button365 class="peach-sort-trash-btn"366 onClick={enterSelectMode}367 aria-label="Select entries"368 title="Select entries"369 >370 <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">371 <circle cx="4" cy="4" r="2.5" />372 <circle cx="9" cy="4" r="2.5" />373 <circle cx="4" cy="9" r="2.5" />374 <circle cx="9" cy="9" r="2.5" />375 </svg>376 </button>377 </Show>378 </>379 }380 >381 {}382 <button383 class="peach-select-cancel-btn"384 onClick={exitSelectMode}385 >386 cancel387 </button>388 <span class="peach-select-count-label">389 {selectedIds().size > 0 ? `${selectedIds().size} selected` : "select entries"}390 </span>391 <button392 class="peach-select-all-btn"393 onClick={toggleSelectAll}394 >395 {allSelected() ? "none" : "all"}396 </button>397 </Show>398 </div>399400 <Show when={hasTags() && !selecting()}>401 <div class="peach-tag-filter-strip" style="display:flex;gap:6px;padding:4px 12px 6px;overflow-x:auto;flex-wrap:nowrap;scrollbar-width:none;">402 <For each={allTags()}>403 {(tag) => (404 <button405 class={`peach-tag-filter-chip ${selectedTags().has(tag) ? "is-selected" : ""}`}406 onClick={() => toggleTag(tag)}407 >408 {tag}409 </button>410 )}411 </For>412 </div>413 </Show>414415 <div416 class={`peach-vault-list${selecting() ? " is-selecting" : ""}`}417 ref={listRef}418 onKeyDown={handleListKeydown}419 >420 <Show421 when={filteredEntries().length > 0}422 fallback={423 <div class="peach-list-empty">424 <span>no entries yet</span>425 </div>426 }427 >428 <For each={filteredEntries()}>429 {(entry, index) => {430 const isNote = entry.type === "note";431 const isPasskey = entry.type === "passkey";432 const domain = entry.type === "note"433 ? (entry.notes?.slice(0, 40) ?? "note")434 : entry.type === "passkey"435 ? entry.rpId ?? "passkey"436 : entry.url437 ? getDisplayDomain(entry.url)438 : "saved entry";439 const isTabMatch = () =>440 props.currentTabDomain !== undefined &&441 props.currentTabDomain !== "" &&442 entryMatchesDomain(entry, props.currentTabDomain);443 const isSelected = () => selectedIds().has(entry.id);444445 return (446 <button447 class={`peach-entry-row${!selecting() && props.selectedEntryId === entry.id ? " is-active" : ""}${!selecting() && isTabMatch() ? " is-tab-match" : ""}${selecting() && isSelected() ? " is-selected" : ""}`}448 style={{ "animation-delay": `${Math.min(index(), 24) * 20}ms` }}449 onClick={() => {450 if (selecting()) {451 toggleId(entry.id);452 } else {453 props.onSelectEntry(entry);454 }455 }}456 >457 <div class={`peach-entry-row-icon${!selecting() && entry.breach ? " is-breach" : ""}${selecting() ? " is-select-icon" : ""}`}>458 <Show459 when={selecting()}460 fallback={461 <FaviconIcon462 domain={entry.url ? getDisplayDomain(entry.url) : ""}463 isNote={isNote}464 isPasskey={isPasskey}465 />466 }467 >468 {}469 <svg width="16" height="16" viewBox="0 0 16 16" fill="none">470 <circle471 cx="8" cy="8" r="7"472 stroke="currentColor"473 stroke-width="1"474 fill={isSelected() ? "var(--peach-accent)" : "transparent"}475 opacity={isSelected() ? "1" : "0.3"}476 />477 <Show when={isSelected()}>478 <path479 d="M5 8.5L7 10.5L11 6"480 stroke="white"481 stroke-width="1.2"482 stroke-linecap="round"483 stroke-linejoin="round"484 />485 </Show>486 </svg>487 </Show>488 </div>489 <div class="peach-entry-row-info">490 <span class="peach-entry-row-name">491 {entry.title}492 <Show when={isPasskey && !selecting()}>493 <span class="peach-passkey-badge">passkey</span>494 </Show>495 <Show when={isTabMatch() && !selecting()}>496 <span class="peach-tab-match-badge" title="Matches current tab">●</span>497 </Show>498 </span>499 <span class="peach-entry-row-meta">{domain}</span>500 </div>501 <Show when={entry.breach && !selecting()}>502 <span class="peach-entry-row-dot" aria-label="breach"></span>503 </Show>504 <Show when={entry.canary && !selecting()}>505 <span class="peach-entry-row-canary" title={`Canary: ${entry.canary?.label ?? "canary entry"}`}>🪤</span>506 </Show>507 </button>508 );509 }}510 </For>511 </Show>512 </div>513514 {}515 <Show516 when={selecting()}517 fallback={518 <button class="peach-new-entry-row" onClick={props.onAdd}>519 <svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round">520 <path d="M5 1v8M1 5h8" />521 </svg>522 new entry523 </button>524 }525 >526 <button527 class={`peach-select-action-bar${selectedIds().size === 0 ? " is-empty" : ""}`}528 disabled={selectedIds().size === 0 || deleting()}529 onClick={() => void handleDeleteSelected()}530 >531 <svg width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">532 <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />533 </svg>534 <Show535 when={selectedIds().size > 0}536 fallback={<span>select entries to delete</span>}537 >538 <span>539 {deleting()540 ? "moving to trash…"541 : `move ${selectedIds().size} to trash`}542 </span>543 </Show>544 </button>545 </Show>546 </div>547 );548}