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.passkey?.rpId) return entry.passkey.rpId;146 return "";147 }148149 function entryMatchesDomain(entry: VaultEntry, domain: string): boolean {150 if (!domain) return false;151 if (entry.passkey?.rpId === domain) return true;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 const searchableFor = (e: VaultEntry): string => {178 if (e.type === "note") return `${e.title} ${e.notes ?? ""}`;179 const passkeyText = e.passkey180 ? ` ${e.passkey.rpId} ${e.passkey.userName} ${e.passkey.userDisplayName}`181 : "";182 return `${e.title} ${e.url ?? ""} ${e.username ?? ""}${passkeyText}`;183 };184 result = result185 .filter((entry) => fuzzyMatch(q, searchableFor(entry)))186 .sort((a, b) => {187 const aSearchable = searchableFor(a).toLowerCase();188 const bSearchable = searchableFor(b).toLowerCase();189 const qLower = q.toLowerCase();190 const aExact = isExactPrefix(qLower, aSearchable);191 const bExact = isExactPrefix(qLower, bSearchable);192 if (aExact && !bExact) return -1;193 if (!aExact && bExact) return 1;194 return 0;195 });196 return result;197 }198199 const tabDomain = props.currentTabDomain ?? "";200 const order = props.sortOrder;201202 if (order === "az") {203 return [...result].sort((a, b) => a.title.localeCompare(b.title));204 }205 if (order === "za") {206 return [...result].sort((a, b) => b.title.localeCompare(a.title));207 }208 if (order === "recent") {209 return [...result].sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0));210 }211 if (tabDomain) {212 return [...result].sort((a, b) => {213 const aMatch = entryMatchesDomain(a, tabDomain) ? 0 : 1;214 const bMatch = entryMatchesDomain(b, tabDomain) ? 0 : 1;215 return aMatch - bMatch;216 });217 }218 return result;219 });220221 222 223 224 225 const allVisibleIds = createMemo(() => filteredEntries().map(e => e.id));226 const allSelected = createMemo(() =>227 allVisibleIds().length > 0 && allVisibleIds().every(id => selectedIds().has(id))228 );229230 function toggleSelectAll() {231 if (allSelected()) {232 setSelectedIds(new Set<string>());233 } else {234 setSelectedIds(new Set<string>(allVisibleIds()));235 }236 }237238 async function handleDeleteSelected() {239 const ids = Array.from(selectedIds());240 if (!ids.length || !props.onDeleteMany) return;241 setDeleting(true);242 await props.onDeleteMany(ids);243 setDeleting(false);244 exitSelectMode();245 }246247 function handleSearchKeydown(e: KeyboardEvent) {248 if (e.key === "ArrowDown") {249 e.preventDefault();250 const firstBtn = listRef?.querySelector<HTMLButtonElement>(".peach-entry-row");251 firstBtn?.focus();252 } else if (e.key === "ArrowUp") {253 e.preventDefault();254 const allBtns = listRef?.querySelectorAll<HTMLButtonElement>(".peach-entry-row");255 allBtns?.[allBtns.length - 1]?.focus();256 } else if (e.key === "Enter") {257 e.preventDefault();258 const firstEntry = filteredEntries()[0];259 if (firstEntry) props.onSelectEntry(firstEntry);260 } else if (e.key === "Escape" && selecting()) {261 exitSelectMode();262 }263 }264265 function handleListKeydown(e: KeyboardEvent) {266 if (!listRef) return;267 const btns = Array.from(listRef.querySelectorAll<HTMLButtonElement>(".peach-entry-row"));268 const idx = btns.indexOf(document.activeElement as HTMLButtonElement);269 if (e.key === "ArrowDown") {270 e.preventDefault();271 if (idx < btns.length - 1) btns[idx + 1]?.focus();272 } else if (e.key === "ArrowUp") {273 e.preventDefault();274 if (idx === 0) { searchRef?.focus(); }275 else btns[idx - 1]?.focus();276 } else if (e.key === "Escape") {277 if (selecting()) exitSelectMode();278 else props.onClose();279 }280 }281282 const sortLabels: { key: SortOrder; label: string }[] = [283 { key: "url-first", label: "smart" },284 { key: "az", label: "A–Z" },285 { key: "za", label: "Z–A" },286 { key: "recent", label: "newest" },287 ];288289 return (290 <div class="peach-overlay-view ov-enter-up" style="display:flex">291 <div class="peach-list-top">292 <button293 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"294 onClick={props.onClose}295 aria-label="Back"296 >297 <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">298 <path d="M6 1L1 6L6 11" />299 </svg>300 </button>301 <input302 class="peach-search-input"303 placeholder="search vault..."304 autocomplete="off"305 style="font-size:14px"306 ref={focusSearch}307 value={search()}308 onInput={(e) => setSearch((e.currentTarget as HTMLInputElement).value)}309 onKeyDown={handleSearchKeydown}310 />311 <span class="peach-search-counter peach-search-counter-list">312 {search() || selectedTags().size > 0313 ? `${filteredEntries().length} of ${props.entries.length}`314 : `${props.entries.length} entries`}315 </span>316 </div>317318 {}319 <div class="peach-sort-bar">320 <Show321 when={selecting()}322 fallback={323 <>324 <For each={sortLabels}>325 {({ key, label }) => (326 <button327 class={`peach-sort-btn${props.sortOrder === key ? " is-active" : ""}`}328 onClick={() => props.onSortOrderChange(key)}329 >330 {label}331 </button>332 )}333 </For>334 <span style="flex:1" />335 <button336 class={`peach-sort-trash-btn${notesOnly() ? " is-active" : ""}`}337 onClick={() => setNotesOnly(v => !v)}338 aria-label="Notes only"339 title="Show notes only"340 >341 {NOTE_ICON}342 </button>343 <Show when={props.onOpenTrash}>344 <button345 class="peach-sort-trash-btn"346 onClick={props.onOpenTrash}347 aria-label="View trash"348 title="View deleted entries"349 >350 <svg width="11" height="12" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">351 <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />352 </svg>353 </button>354 </Show>355 <Show when={props.onDeleteMany}>356 <button357 class="peach-sort-trash-btn"358 onClick={enterSelectMode}359 aria-label="Select entries"360 title="Select entries"361 >362 <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">363 <circle cx="4" cy="4" r="2.5" />364 <circle cx="9" cy="4" r="2.5" />365 <circle cx="4" cy="9" r="2.5" />366 <circle cx="9" cy="9" r="2.5" />367 </svg>368 </button>369 </Show>370 </>371 }372 >373 {}374 <button375 class="peach-select-cancel-btn"376 onClick={exitSelectMode}377 >378 cancel379 </button>380 <span class="peach-select-count-label">381 {selectedIds().size > 0 ? `${selectedIds().size} selected` : "select entries"}382 </span>383 <button384 class="peach-select-all-btn"385 onClick={toggleSelectAll}386 >387 {allSelected() ? "none" : "all"}388 </button>389 </Show>390 </div>391392 <Show when={hasTags() && !selecting()}>393 <div class="peach-tag-filter-strip" style="display:flex;gap:6px;padding:4px 12px 6px;overflow-x:auto;flex-wrap:nowrap;scrollbar-width:none;">394 <For each={allTags()}>395 {(tag) => (396 <button397 class={`peach-tag-filter-chip ${selectedTags().has(tag) ? "is-selected" : ""}`}398 onClick={() => toggleTag(tag)}399 >400 {tag}401 </button>402 )}403 </For>404 </div>405 </Show>406407 <div408 class={`peach-vault-list${selecting() ? " is-selecting" : ""}`}409 ref={listRef}410 onKeyDown={handleListKeydown}411 >412 <Show413 when={filteredEntries().length > 0}414 fallback={415 <div class="peach-list-empty">416 <span>no entries yet</span>417 </div>418 }419 >420 <For each={filteredEntries()}>421 {(entry, index) => {422 const isNote = entry.type === "note";423 const isPasskey = entry.passkey !== undefined;424 const domain = entry.type === "note"425 ? (entry.notes?.slice(0, 40) ?? "note")426 : entry.url427 ? getDisplayDomain(entry.url)428 : entry.passkey?.rpId429 ? entry.passkey.rpId430 : "saved entry";431 const isTabMatch = () =>432 props.currentTabDomain !== undefined &&433 props.currentTabDomain !== "" &&434 entryMatchesDomain(entry, props.currentTabDomain);435 const isSelected = () => selectedIds().has(entry.id);436437 return (438 <button439 class={`peach-entry-row${!selecting() && props.selectedEntryId === entry.id ? " is-active" : ""}${!selecting() && isTabMatch() ? " is-tab-match" : ""}${selecting() && isSelected() ? " is-selected" : ""}`}440 style={{ "animation-delay": `${Math.min(index(), 24) * 20}ms` }}441 onClick={() => {442 if (selecting()) {443 toggleId(entry.id);444 } else {445 props.onSelectEntry(entry);446 }447 }}448 >449 <div class={`peach-entry-row-icon${!selecting() && entry.breach ? " is-breach" : ""}${selecting() ? " is-select-icon" : ""}`}>450 <Show451 when={selecting()}452 fallback={453 <FaviconIcon454 domain={entry.url ? getDisplayDomain(entry.url) : ""}455 isNote={isNote}456 isPasskey={isPasskey}457 />458 }459 >460 {}461 <svg width="16" height="16" viewBox="0 0 16 16" fill="none">462 <circle463 cx="8" cy="8" r="7"464 stroke="currentColor"465 stroke-width="1"466 fill={isSelected() ? "var(--peach-accent)" : "transparent"}467 opacity={isSelected() ? "1" : "0.3"}468 />469 <Show when={isSelected()}>470 <path471 d="M5 8.5L7 10.5L11 6"472 stroke="white"473 stroke-width="1.2"474 stroke-linecap="round"475 stroke-linejoin="round"476 />477 </Show>478 </svg>479 </Show>480 </div>481 <div class="peach-entry-row-info">482 <span class="peach-entry-row-name">483 {entry.title}484 <Show when={isPasskey && !selecting()}>485 <span class="peach-passkey-badge">passkey</span>486 </Show>487 <Show when={isTabMatch() && !selecting()}>488 <span class="peach-tab-match-badge" title="Matches current tab">●</span>489 </Show>490 </span>491 <span class="peach-entry-row-meta">{domain}</span>492 </div>493 <Show when={entry.breach && !selecting()}>494 <span class="peach-entry-row-dot" aria-label="breach"></span>495 </Show>496 <Show when={entry.canary && !selecting()}>497 <span class="peach-entry-row-canary" title={`Canary: ${entry.canary?.label ?? "canary entry"}`}>🪤</span>498 </Show>499 </button>500 );501 }}502 </For>503 </Show>504 </div>505506 {}507 <Show508 when={selecting()}509 fallback={510 <button class="peach-new-entry-row" onClick={props.onAdd}>511 <svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round">512 <path d="M5 1v8M1 5h8" />513 </svg>514 new entry515 </button>516 }517 >518 <button519 class={`peach-select-action-bar${selectedIds().size === 0 ? " is-empty" : ""}`}520 disabled={selectedIds().size === 0 || deleting()}521 onClick={() => void handleDeleteSelected()}522 >523 <svg width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">524 <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />525 </svg>526 <Show527 when={selectedIds().size > 0}528 fallback={<span>select entries to delete</span>}529 >530 <span>531 {deleting()532 ? "moving to trash…"533 : `move ${selectedIds().size} to trash`}534 </span>535 </Show>536 </button>537 </Show>538 </div>539 );540}