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 [selecting, setSelecting] = createSignal(false);59 const [selectedIds, setSelectedIds] = createSignal<Set<string>>(new Set());60 const [deleting, setDeleting] = createSignal(false);6162 let searchRef: HTMLInputElement | undefined;63 let listRef: HTMLDivElement | undefined;6465 const focusSearch = (el: HTMLInputElement) => {66 searchRef = el;67 setTimeout(() => el.focus(), 50);68 };6970 createEffect(() => {71 void props.searchSeedNonce;72 const seed = props.searchSeed;73 if (!seed) return;74 setSearch(seed);75 requestAnimationFrame(() => {76 searchRef?.focus();77 searchRef?.setSelectionRange(seed.length, seed.length);78 });79 });8081 function enterSelectMode() {82 setSelecting(true);83 setSelectedIds(new Set<string>());84 }8586 function exitSelectMode() {87 setSelecting(false);88 setSelectedIds(new Set<string>());89 }9091 function toggleId(id: string) {92 const s = new Set<string>(selectedIds());93 if (s.has(id)) s.delete(id); else s.add(id);94 setSelectedIds(s);95 }9697 const allTags = createMemo(() => {98 const tagSet = new Set<string>();99 for (const entry of props.entries) {100 for (const tag of entry.tags ?? []) {101 tagSet.add(tag);102 }103 }104 return Array.from(tagSet).sort();105 });106107 const hasTags = createMemo(() => allTags().length > 0);108109 function toggleTag(tag: string) {110 const current = new Set(selectedTags());111 if (current.has(tag)) {112 current.delete(tag);113 } else {114 current.add(tag);115 }116 setSelectedTags(current);117 }118119 function fuzzyMatch(query: string, target: string): boolean {120 query = query.toLowerCase();121 target = target.toLowerCase();122 let qi = 0;123 for (let i = 0; i < target.length && qi < query.length; i++) {124 if (target[i] === query[qi]) qi++;125 }126 return qi === query.length;127 }128129 function isExactPrefix(query: string, target: string): boolean {130 return target.toLowerCase().startsWith(query.toLowerCase());131 }132133 function entryDomain(entry: VaultEntry): string {134 if (entry.type === "login" && entry.url) return getDisplayDomain(entry.url);135 if (entry.type === "passkey" && entry.rpId) return entry.rpId;136 return "";137 }138139 const filteredEntries = createMemo(() => {140 let result = props.entries;141142 if (selectedTags().size > 0) {143 result = result.filter(entry =>144 Array.from(selectedTags()).every(tag => entry.tags?.includes(tag))145 );146 }147148 const q = search().trim();149 if (q) {150 result = result151 .filter((entry) => {152 const searchable = entry.type === "note"153 ? `${entry.title} ${entry.notes ?? ""}`154 : entry.type === "passkey"155 ? `${entry.title} ${entry.rpId ?? ""} ${entry.userName ?? ""} ${entry.userDisplayName ?? ""}`156 : `${entry.title} ${entry.url ?? ""} ${entry.username ?? ""}`;157 return fuzzyMatch(q, searchable);158 })159 .sort((a, b) => {160 const aSearchable = a.type === "note"161 ? `${a.title} ${a.notes ?? ""}`.toLowerCase()162 : a.type === "passkey"163 ? `${a.title} ${a.rpId ?? ""} ${a.userName ?? ""}`.toLowerCase()164 : `${a.title} ${a.url ?? ""} ${a.username ?? ""}`.toLowerCase();165 const bSearchable = b.type === "note"166 ? `${b.title} ${b.notes ?? ""}`.toLowerCase()167 : b.type === "passkey"168 ? `${b.title} ${b.rpId ?? ""} ${b.userName ?? ""}`.toLowerCase()169 : `${b.title} ${b.url ?? ""} ${b.username ?? ""}`.toLowerCase();170 const qLower = q.toLowerCase();171 const aExact = isExactPrefix(qLower, aSearchable);172 const bExact = isExactPrefix(qLower, bSearchable);173 if (aExact && !bExact) return -1;174 if (!aExact && bExact) return 1;175 return 0;176 });177 return result;178 }179180 const tabDomain = props.currentTabDomain ?? "";181 const order = props.sortOrder;182183 if (order === "az") {184 return [...result].sort((a, b) => a.title.localeCompare(b.title));185 }186 if (order === "za") {187 return [...result].sort((a, b) => b.title.localeCompare(a.title));188 }189 if (order === "recent") {190 return [...result].sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0));191 }192 if (tabDomain) {193 return [...result].sort((a, b) => {194 const aMatch = entryDomain(a) === tabDomain ? 0 : 1;195 const bMatch = entryDomain(b) === tabDomain ? 0 : 1;196 return aMatch - bMatch;197 });198 }199 return result;200 });201202 const allVisibleIds = createMemo(() => filteredEntries().map(e => e.id));203 const allSelected = createMemo(() =>204 allVisibleIds().length > 0 && allVisibleIds().every(id => selectedIds().has(id))205 );206207 function toggleSelectAll() {208 if (allSelected()) {209 setSelectedIds(new Set<string>());210 } else {211 setSelectedIds(new Set<string>(allVisibleIds()));212 }213 }214215 async function handleDeleteSelected() {216 const ids = Array.from(selectedIds());217 if (!ids.length || !props.onDeleteMany) return;218 setDeleting(true);219 await props.onDeleteMany(ids);220 setDeleting(false);221 exitSelectMode();222 }223224 function handleSearchKeydown(e: KeyboardEvent) {225 if (e.key === "ArrowDown") {226 e.preventDefault();227 const firstBtn = listRef?.querySelector<HTMLButtonElement>(".peach-entry-row");228 firstBtn?.focus();229 } else if (e.key === "ArrowUp") {230 e.preventDefault();231 const allBtns = listRef?.querySelectorAll<HTMLButtonElement>(".peach-entry-row");232 allBtns?.[allBtns.length - 1]?.focus();233 } else if (e.key === "Enter") {234 e.preventDefault();235 const firstEntry = filteredEntries()[0];236 if (firstEntry) props.onSelectEntry(firstEntry);237 } else if (e.key === "Escape" && selecting()) {238 exitSelectMode();239 }240 }241242 function handleListKeydown(e: KeyboardEvent) {243 if (!listRef) return;244 const btns = Array.from(listRef.querySelectorAll<HTMLButtonElement>(".peach-entry-row"));245 const idx = btns.indexOf(document.activeElement as HTMLButtonElement);246 if (e.key === "ArrowDown") {247 e.preventDefault();248 if (idx < btns.length - 1) btns[idx + 1]?.focus();249 } else if (e.key === "ArrowUp") {250 e.preventDefault();251 if (idx === 0) { searchRef?.focus(); }252 else btns[idx - 1]?.focus();253 } else if (e.key === "Escape") {254 if (selecting()) exitSelectMode();255 else props.onClose();256 }257 }258259 const sortLabels: { key: SortOrder; label: string }[] = [260 { key: "url-first", label: "smart" },261 { key: "az", label: "A–Z" },262 { key: "za", label: "Z–A" },263 { key: "recent", label: "newest" },264 ];265266 return (267 <div class="peach-overlay-view ov-enter-up" style="display:flex">268 <div class="peach-list-top">269 <button270 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"271 onClick={props.onClose}272 aria-label="Back"273 >274 <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">275 <path d="M6 1L1 6L6 11" />276 </svg>277 </button>278 <input279 class="peach-search-input"280 placeholder="search vault..."281 autocomplete="off"282 style="font-size:14px"283 ref={focusSearch}284 value={search()}285 onInput={(e) => setSearch((e.currentTarget as HTMLInputElement).value)}286 onKeyDown={handleSearchKeydown}287 />288 <span class="peach-search-counter peach-search-counter-list">289 {search() || selectedTags().size > 0290 ? `${filteredEntries().length} of ${props.entries.length}`291 : `${props.entries.length} entries`}292 </span>293 </div>294295 {}296 <div class="peach-sort-bar">297 <Show298 when={selecting()}299 fallback={300 <>301 <For each={sortLabels}>302 {({ key, label }) => (303 <button304 class={`peach-sort-btn${props.sortOrder === key ? " is-active" : ""}`}305 onClick={() => props.onSortOrderChange(key)}306 >307 {label}308 </button>309 )}310 </For>311 <span style="flex:1" />312 <Show when={props.onOpenTrash}>313 <button314 class="peach-sort-trash-btn"315 onClick={props.onOpenTrash}316 aria-label="View trash"317 title="View deleted entries"318 >319 <svg width="11" height="12" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">320 <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />321 </svg>322 </button>323 </Show>324 <Show when={props.onDeleteMany}>325 <button326 class="peach-sort-trash-btn"327 onClick={enterSelectMode}328 aria-label="Select entries"329 title="Select entries"330 >331 <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">332 <circle cx="4" cy="4" r="2.5" />333 <circle cx="9" cy="4" r="2.5" />334 <circle cx="4" cy="9" r="2.5" />335 <circle cx="9" cy="9" r="2.5" />336 </svg>337 </button>338 </Show>339 </>340 }341 >342 {}343 <button344 class="peach-select-cancel-btn"345 onClick={exitSelectMode}346 >347 cancel348 </button>349 <span class="peach-select-count-label">350 {selectedIds().size > 0 ? `${selectedIds().size} selected` : "select entries"}351 </span>352 <button353 class="peach-select-all-btn"354 onClick={toggleSelectAll}355 >356 {allSelected() ? "none" : "all"}357 </button>358 </Show>359 </div>360361 <Show when={hasTags() && !selecting()}>362 <div class="peach-tag-filter-strip" style="display:flex;gap:6px;padding:4px 12px 6px;overflow-x:auto;flex-wrap:nowrap;scrollbar-width:none;">363 <For each={allTags()}>364 {(tag) => (365 <button366 class={`peach-tag-filter-chip ${selectedTags().has(tag) ? "is-selected" : ""}`}367 onClick={() => toggleTag(tag)}368 >369 {tag}370 </button>371 )}372 </For>373 </div>374 </Show>375376 <div377 class={`peach-vault-list${selecting() ? " is-selecting" : ""}`}378 ref={listRef}379 onKeyDown={handleListKeydown}380 >381 <Show382 when={filteredEntries().length > 0}383 fallback={384 <div class="peach-list-empty">385 <span>no entries yet</span>386 </div>387 }388 >389 <For each={filteredEntries()}>390 {(entry, index) => {391 const isNote = entry.type === "note";392 const isPasskey = entry.type === "passkey";393 const domain = entry.type === "note"394 ? (entry.notes?.slice(0, 40) ?? "note")395 : entry.type === "passkey"396 ? entry.rpId ?? "passkey"397 : entry.url398 ? getDisplayDomain(entry.url)399 : "saved entry";400 const isTabMatch = () =>401 props.currentTabDomain !== undefined &&402 props.currentTabDomain !== "" &&403 entryDomain(entry) === props.currentTabDomain;404 const isSelected = () => selectedIds().has(entry.id);405406 return (407 <button408 class={`peach-entry-row${!selecting() && props.selectedEntryId === entry.id ? " is-active" : ""}${!selecting() && isTabMatch() ? " is-tab-match" : ""}${selecting() && isSelected() ? " is-selected" : ""}`}409 style={{ "animation-delay": `${Math.min(index(), 24) * 20}ms` }}410 onClick={() => {411 if (selecting()) {412 toggleId(entry.id);413 } else {414 props.onSelectEntry(entry);415 }416 }}417 >418 <div class={`peach-entry-row-icon${!selecting() && entry.breach ? " is-breach" : ""}${selecting() ? " is-select-icon" : ""}`}>419 <Show420 when={selecting()}421 fallback={422 <FaviconIcon423 domain={entry.url ? getDisplayDomain(entry.url) : ""}424 isNote={isNote}425 isPasskey={isPasskey}426 />427 }428 >429 {}430 <svg width="16" height="16" viewBox="0 0 16 16" fill="none">431 <circle432 cx="8" cy="8" r="7"433 stroke="currentColor"434 stroke-width="1"435 fill={isSelected() ? "var(--peach-accent)" : "transparent"}436 opacity={isSelected() ? "1" : "0.3"}437 />438 <Show when={isSelected()}>439 <path440 d="M5 8.5L7 10.5L11 6"441 stroke="white"442 stroke-width="1.2"443 stroke-linecap="round"444 stroke-linejoin="round"445 />446 </Show>447 </svg>448 </Show>449 </div>450 <div class="peach-entry-row-info">451 <span class="peach-entry-row-name">452 {entry.title}453 <Show when={isPasskey && !selecting()}>454 <span class="peach-passkey-badge">passkey</span>455 </Show>456 <Show when={isTabMatch() && !selecting()}>457 <span class="peach-tab-match-badge" title="Matches current tab">●</span>458 </Show>459 </span>460 <span class="peach-entry-row-meta">{domain}</span>461 </div>462 <Show when={entry.breach && !selecting()}>463 <span class="peach-entry-row-dot" aria-label="breach"></span>464 </Show>465 <Show when={entry.canary && !selecting()}>466 <span class="peach-entry-row-canary" title={`Canary: ${entry.canary?.label ?? "canary entry"}`}>🪤</span>467 </Show>468 </button>469 );470 }}471 </For>472 </Show>473 </div>474475 {}476 <Show477 when={selecting()}478 fallback={479 <button class="peach-new-entry-row" onClick={props.onAdd}>480 <svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round">481 <path d="M5 1v8M1 5h8" />482 </svg>483 new entry484 </button>485 }486 >487 <button488 class={`peach-select-action-bar${selectedIds().size === 0 ? " is-empty" : ""}`}489 disabled={selectedIds().size === 0 || deleting()}490 onClick={() => void handleDeleteSelected()}491 >492 <svg width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">493 <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />494 </svg>495 <Show496 when={selectedIds().size > 0}497 fallback={<span>select entries to delete</span>}498 >499 <span>500 {deleting()501 ? "moving to trash…"502 : `move ${selectedIds().size} to trash`}503 </span>504 </Show>505 </button>506 </Show>507 </div>508 );509}