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 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 function entryMatchesDomain(entry: VaultEntry, domain: string): boolean {141 if (!domain) return false;142 if (entry.type === "passkey") return entry.rpId === domain;143 if (entry.type !== "login") return false;144 145 146 if (entry.url && extractDomain(entry.url) === domain) return true;147 for (const extra of entry.additionalUrls ?? []) {148 if (extractDomain(extra) === domain) return true;149 }150 return false;151 }152153 const filteredEntries = createMemo(() => {154 let result = props.entries;155156 if (notesOnly()) {157 result = result.filter(entry => entry.type === "note");158 }159160 if (selectedTags().size > 0) {161 result = result.filter(entry =>162 Array.from(selectedTags()).every(tag => entry.tags?.includes(tag))163 );164 }165166 const q = search().trim();167 if (q) {168 result = result169 .filter((entry) => {170 const searchable = entry.type === "note"171 ? `${entry.title} ${entry.notes ?? ""}`172 : entry.type === "passkey"173 ? `${entry.title} ${entry.rpId ?? ""} ${entry.userName ?? ""} ${entry.userDisplayName ?? ""}`174 : `${entry.title} ${entry.url ?? ""} ${entry.username ?? ""}`;175 return fuzzyMatch(q, searchable);176 })177 .sort((a, b) => {178 const aSearchable = a.type === "note"179 ? `${a.title} ${a.notes ?? ""}`.toLowerCase()180 : a.type === "passkey"181 ? `${a.title} ${a.rpId ?? ""} ${a.userName ?? ""}`.toLowerCase()182 : `${a.title} ${a.url ?? ""} ${a.username ?? ""}`.toLowerCase();183 const bSearchable = b.type === "note"184 ? `${b.title} ${b.notes ?? ""}`.toLowerCase()185 : b.type === "passkey"186 ? `${b.title} ${b.rpId ?? ""} ${b.userName ?? ""}`.toLowerCase()187 : `${b.title} ${b.url ?? ""} ${b.username ?? ""}`.toLowerCase();188 const qLower = q.toLowerCase();189 const aExact = isExactPrefix(qLower, aSearchable);190 const bExact = isExactPrefix(qLower, bSearchable);191 if (aExact && !bExact) return -1;192 if (!aExact && bExact) return 1;193 return 0;194 });195 return result;196 }197198 const tabDomain = props.currentTabDomain ?? "";199 const order = props.sortOrder;200201 if (order === "az") {202 return [...result].sort((a, b) => a.title.localeCompare(b.title));203 }204 if (order === "za") {205 return [...result].sort((a, b) => b.title.localeCompare(a.title));206 }207 if (order === "recent") {208 return [...result].sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0));209 }210 if (tabDomain) {211 return [...result].sort((a, b) => {212 const aMatch = entryMatchesDomain(a, tabDomain) ? 0 : 1;213 const bMatch = entryMatchesDomain(b, tabDomain) ? 0 : 1;214 return aMatch - bMatch;215 });216 }217 return result;218 });219220 221 222 223 224 const allVisibleIds = createMemo(() => filteredEntries().map(e => e.id));225 const allSelected = createMemo(() =>226 allVisibleIds().length > 0 && allVisibleIds().every(id => selectedIds().has(id))227 );228229 function toggleSelectAll() {230 if (allSelected()) {231 setSelectedIds(new Set<string>());232 } else {233 setSelectedIds(new Set<string>(allVisibleIds()));234 }235 }236237 async function handleDeleteSelected() {238 const ids = Array.from(selectedIds());239 if (!ids.length || !props.onDeleteMany) return;240 setDeleting(true);241 await props.onDeleteMany(ids);242 setDeleting(false);243 exitSelectMode();244 }245246 function handleSearchKeydown(e: KeyboardEvent) {247 if (e.key === "ArrowDown") {248 e.preventDefault();249 const firstBtn = listRef?.querySelector<HTMLButtonElement>(".peach-entry-row");250 firstBtn?.focus();251 } else if (e.key === "ArrowUp") {252 e.preventDefault();253 const allBtns = listRef?.querySelectorAll<HTMLButtonElement>(".peach-entry-row");254 allBtns?.[allBtns.length - 1]?.focus();255 } else if (e.key === "Enter") {256 e.preventDefault();257 const firstEntry = filteredEntries()[0];258 if (firstEntry) props.onSelectEntry(firstEntry);259 } else if (e.key === "Escape" && selecting()) {260 exitSelectMode();261 }262 }263264 function handleListKeydown(e: KeyboardEvent) {265 if (!listRef) return;266 const btns = Array.from(listRef.querySelectorAll<HTMLButtonElement>(".peach-entry-row"));267 const idx = btns.indexOf(document.activeElement as HTMLButtonElement);268 if (e.key === "ArrowDown") {269 e.preventDefault();270 if (idx < btns.length - 1) btns[idx + 1]?.focus();271 } else if (e.key === "ArrowUp") {272 e.preventDefault();273 if (idx === 0) { searchRef?.focus(); }274 else btns[idx - 1]?.focus();275 } else if (e.key === "Escape") {276 if (selecting()) exitSelectMode();277 else props.onClose();278 }279 }280281 const sortLabels: { key: SortOrder; label: string }[] = [282 { key: "url-first", label: "smart" },283 { key: "az", label: "A–Z" },284 { key: "za", label: "Z–A" },285 { key: "recent", label: "newest" },286 ];287288 return (289 <div class="peach-overlay-view ov-enter-up" style="display:flex">290 <div class="peach-list-top">291 <button292 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"293 onClick={props.onClose}294 aria-label="Back"295 >296 <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">297 <path d="M6 1L1 6L6 11" />298 </svg>299 </button>300 <input301 class="peach-search-input"302 placeholder="search vault..."303 autocomplete="off"304 style="font-size:14px"305 ref={focusSearch}306 value={search()}307 onInput={(e) => setSearch((e.currentTarget as HTMLInputElement).value)}308 onKeyDown={handleSearchKeydown}309 />310 <span class="peach-search-counter peach-search-counter-list">311 {search() || selectedTags().size > 0312 ? `${filteredEntries().length} of ${props.entries.length}`313 : `${props.entries.length} entries`}314 </span>315 </div>316317 {}318 <div class="peach-sort-bar">319 <Show320 when={selecting()}321 fallback={322 <>323 <For each={sortLabels}>324 {({ key, label }) => (325 <button326 class={`peach-sort-btn${props.sortOrder === key ? " is-active" : ""}`}327 onClick={() => props.onSortOrderChange(key)}328 >329 {label}330 </button>331 )}332 </For>333 <span style="flex:1" />334 <button335 class={`peach-sort-trash-btn${notesOnly() ? " is-active" : ""}`}336 onClick={() => setNotesOnly(v => !v)}337 aria-label="Notes only"338 title="Show notes only"339 >340 {NOTE_ICON}341 </button>342 <Show when={props.onOpenTrash}>343 <button344 class="peach-sort-trash-btn"345 onClick={props.onOpenTrash}346 aria-label="View trash"347 title="View deleted entries"348 >349 <svg width="11" height="12" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">350 <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />351 </svg>352 </button>353 </Show>354 <Show when={props.onDeleteMany}>355 <button356 class="peach-sort-trash-btn"357 onClick={enterSelectMode}358 aria-label="Select entries"359 title="Select entries"360 >361 <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">362 <circle cx="4" cy="4" r="2.5" />363 <circle cx="9" cy="4" r="2.5" />364 <circle cx="4" cy="9" r="2.5" />365 <circle cx="9" cy="9" r="2.5" />366 </svg>367 </button>368 </Show>369 </>370 }371 >372 {}373 <button374 class="peach-select-cancel-btn"375 onClick={exitSelectMode}376 >377 cancel378 </button>379 <span class="peach-select-count-label">380 {selectedIds().size > 0 ? `${selectedIds().size} selected` : "select entries"}381 </span>382 <button383 class="peach-select-all-btn"384 onClick={toggleSelectAll}385 >386 {allSelected() ? "none" : "all"}387 </button>388 </Show>389 </div>390391 <Show when={hasTags() && !selecting()}>392 <div class="peach-tag-filter-strip" style="display:flex;gap:6px;padding:4px 12px 6px;overflow-x:auto;flex-wrap:nowrap;scrollbar-width:none;">393 <For each={allTags()}>394 {(tag) => (395 <button396 class={`peach-tag-filter-chip ${selectedTags().has(tag) ? "is-selected" : ""}`}397 onClick={() => toggleTag(tag)}398 >399 {tag}400 </button>401 )}402 </For>403 </div>404 </Show>405406 <div407 class={`peach-vault-list${selecting() ? " is-selecting" : ""}`}408 ref={listRef}409 onKeyDown={handleListKeydown}410 >411 <Show412 when={filteredEntries().length > 0}413 fallback={414 <div class="peach-list-empty">415 <span>no entries yet</span>416 </div>417 }418 >419 <For each={filteredEntries()}>420 {(entry, index) => {421 const isNote = entry.type === "note";422 const isPasskey = entry.type === "passkey";423 const domain = entry.type === "note"424 ? (entry.notes?.slice(0, 40) ?? "note")425 : entry.type === "passkey"426 ? entry.rpId ?? "passkey"427 : entry.url428 ? getDisplayDomain(entry.url)429 : "saved entry";430 const isTabMatch = () =>431 props.currentTabDomain !== undefined &&432 props.currentTabDomain !== "" &&433 entryMatchesDomain(entry, props.currentTabDomain);434 const isSelected = () => selectedIds().has(entry.id);435436 return (437 <button438 class={`peach-entry-row${!selecting() && props.selectedEntryId === entry.id ? " is-active" : ""}${!selecting() && isTabMatch() ? " is-tab-match" : ""}${selecting() && isSelected() ? " is-selected" : ""}`}439 style={{ "animation-delay": `${Math.min(index(), 24) * 20}ms` }}440 onClick={() => {441 if (selecting()) {442 toggleId(entry.id);443 } else {444 props.onSelectEntry(entry);445 }446 }}447 >448 <div class={`peach-entry-row-icon${!selecting() && entry.breach ? " is-breach" : ""}${selecting() ? " is-select-icon" : ""}`}>449 <Show450 when={selecting()}451 fallback={452 <FaviconIcon453 domain={entry.url ? getDisplayDomain(entry.url) : ""}454 isNote={isNote}455 isPasskey={isPasskey}456 />457 }458 >459 {}460 <svg width="16" height="16" viewBox="0 0 16 16" fill="none">461 <circle462 cx="8" cy="8" r="7"463 stroke="currentColor"464 stroke-width="1"465 fill={isSelected() ? "var(--peach-accent)" : "transparent"}466 opacity={isSelected() ? "1" : "0.3"}467 />468 <Show when={isSelected()}>469 <path470 d="M5 8.5L7 10.5L11 6"471 stroke="white"472 stroke-width="1.2"473 stroke-linecap="round"474 stroke-linejoin="round"475 />476 </Show>477 </svg>478 </Show>479 </div>480 <div class="peach-entry-row-info">481 <span class="peach-entry-row-name">482 {entry.title}483 <Show when={isPasskey && !selecting()}>484 <span class="peach-passkey-badge">passkey</span>485 </Show>486 <Show when={isTabMatch() && !selecting()}>487 <span class="peach-tab-match-badge" title="Matches current tab">●</span>488 </Show>489 </span>490 <span class="peach-entry-row-meta">{domain}</span>491 </div>492 <Show when={entry.breach && !selecting()}>493 <span class="peach-entry-row-dot" aria-label="breach"></span>494 </Show>495 <Show when={entry.canary && !selecting()}>496 <span class="peach-entry-row-canary" title={`Canary: ${entry.canary?.label ?? "canary entry"}`}>🪤</span>497 </Show>498 </button>499 );500 }}501 </For>502 </Show>503 </div>504505 {}506 <Show507 when={selecting()}508 fallback={509 <button class="peach-new-entry-row" onClick={props.onAdd}>510 <svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round">511 <path d="M5 1v8M1 5h8" />512 </svg>513 new entry514 </button>515 }516 >517 <button518 class={`peach-select-action-bar${selectedIds().size === 0 ? " is-empty" : ""}`}519 disabled={selectedIds().size === 0 || deleting()}520 onClick={() => void handleDeleteSelected()}521 >522 <svg width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">523 <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />524 </svg>525 <Show526 when={selectedIds().size > 0}527 fallback={<span>select entries to delete</span>}528 >529 <span>530 {deleting()531 ? "moving to trash…"532 : `move ${selectedIds().size} to trash`}533 </span>534 </Show>535 </button>536 </Show>537 </div>538 );539}