1import { 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 onSelectEntry: (entry: VaultEntry) => void;46 onSortOrderChange: (order: SortOrder) => void;47 onAdd: () => void;48 onClose: () => void;49 onOpenTrash?: () => void;50 onDeleteMany?: (ids: string[]) => Promise<void>;51}5253export function VaultView(props: VaultProps) {54 const [search, setSearch] = createSignal("");55 const [selectedTags, setSelectedTags] = createSignal<Set<string>>(new Set());56 const [selecting, setSelecting] = createSignal(false);57 const [selectedIds, setSelectedIds] = createSignal<Set<string>>(new Set());58 const [deleting, setDeleting] = createSignal(false);5960 let searchRef: HTMLInputElement | undefined;61 let listRef: HTMLDivElement | undefined;6263 const focusSearch = (el: HTMLInputElement) => {64 searchRef = el;65 setTimeout(() => el.focus(), 50);66 };6768 function enterSelectMode() {69 setSelecting(true);70 setSelectedIds(new Set<string>());71 }7273 function exitSelectMode() {74 setSelecting(false);75 setSelectedIds(new Set<string>());76 }7778 function toggleId(id: string) {79 const s = new Set<string>(selectedIds());80 if (s.has(id)) s.delete(id); else s.add(id);81 setSelectedIds(s);82 }8384 const allTags = createMemo(() => {85 const tagSet = new Set<string>();86 for (const entry of props.entries) {87 for (const tag of entry.tags ?? []) {88 tagSet.add(tag);89 }90 }91 return Array.from(tagSet).sort();92 });9394 const hasTags = createMemo(() => allTags().length > 0);9596 function toggleTag(tag: string) {97 const current = new Set(selectedTags());98 if (current.has(tag)) {99 current.delete(tag);100 } else {101 current.add(tag);102 }103 setSelectedTags(current);104 }105106 function fuzzyMatch(query: string, target: string): boolean {107 query = query.toLowerCase();108 target = target.toLowerCase();109 let qi = 0;110 for (let i = 0; i < target.length && qi < query.length; i++) {111 if (target[i] === query[qi]) qi++;112 }113 return qi === query.length;114 }115116 function isExactPrefix(query: string, target: string): boolean {117 return target.toLowerCase().startsWith(query.toLowerCase());118 }119120 function entryDomain(entry: VaultEntry): string {121 if (entry.type === "login" && entry.url) return getDisplayDomain(entry.url);122 if (entry.type === "passkey" && entry.rpId) return entry.rpId;123 return "";124 }125126 const filteredEntries = createMemo(() => {127 let result = props.entries;128129 if (selectedTags().size > 0) {130 result = result.filter(entry =>131 Array.from(selectedTags()).every(tag => entry.tags?.includes(tag))132 );133 }134135 const q = search().trim();136 if (q) {137 result = result138 .filter((entry) => {139 const searchable = entry.type === "note"140 ? `${entry.title} ${entry.notes ?? ""}`141 : entry.type === "passkey"142 ? `${entry.title} ${entry.rpId ?? ""} ${entry.userName ?? ""} ${entry.userDisplayName ?? ""}`143 : `${entry.title} ${entry.url ?? ""} ${entry.username ?? ""}`;144 return fuzzyMatch(q, searchable);145 })146 .sort((a, b) => {147 const aSearchable = a.type === "note"148 ? `${a.title} ${a.notes ?? ""}`.toLowerCase()149 : a.type === "passkey"150 ? `${a.title} ${a.rpId ?? ""} ${a.userName ?? ""}`.toLowerCase()151 : `${a.title} ${a.url ?? ""} ${a.username ?? ""}`.toLowerCase();152 const bSearchable = b.type === "note"153 ? `${b.title} ${b.notes ?? ""}`.toLowerCase()154 : b.type === "passkey"155 ? `${b.title} ${b.rpId ?? ""} ${b.userName ?? ""}`.toLowerCase()156 : `${b.title} ${b.url ?? ""} ${b.username ?? ""}`.toLowerCase();157 const qLower = q.toLowerCase();158 const aExact = isExactPrefix(qLower, aSearchable);159 const bExact = isExactPrefix(qLower, bSearchable);160 if (aExact && !bExact) return -1;161 if (!aExact && bExact) return 1;162 return 0;163 });164 return result;165 }166167 const tabDomain = props.currentTabDomain ?? "";168 const order = props.sortOrder;169170 if (order === "az") {171 return [...result].sort((a, b) => a.title.localeCompare(b.title));172 }173 if (order === "za") {174 return [...result].sort((a, b) => b.title.localeCompare(a.title));175 }176 if (order === "recent") {177 return [...result].sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0));178 }179 if (tabDomain) {180 return [...result].sort((a, b) => {181 const aMatch = entryDomain(a) === tabDomain ? 0 : 1;182 const bMatch = entryDomain(b) === tabDomain ? 0 : 1;183 return aMatch - bMatch;184 });185 }186 return result;187 });188189 const allVisibleIds = createMemo(() => filteredEntries().map(e => e.id));190 const allSelected = createMemo(() =>191 allVisibleIds().length > 0 && allVisibleIds().every(id => selectedIds().has(id))192 );193194 function toggleSelectAll() {195 if (allSelected()) {196 setSelectedIds(new Set<string>());197 } else {198 setSelectedIds(new Set<string>(allVisibleIds()));199 }200 }201202 async function handleDeleteSelected() {203 const ids = Array.from(selectedIds());204 if (!ids.length || !props.onDeleteMany) return;205 setDeleting(true);206 await props.onDeleteMany(ids);207 setDeleting(false);208 exitSelectMode();209 }210211 function handleSearchKeydown(e: KeyboardEvent) {212 if (e.key === "ArrowDown") {213 e.preventDefault();214 const firstBtn = listRef?.querySelector<HTMLButtonElement>(".peach-entry-row");215 firstBtn?.focus();216 } else if (e.key === "ArrowUp") {217 e.preventDefault();218 const allBtns = listRef?.querySelectorAll<HTMLButtonElement>(".peach-entry-row");219 allBtns?.[allBtns.length - 1]?.focus();220 } else if (e.key === "Enter") {221 e.preventDefault();222 const firstEntry = filteredEntries()[0];223 if (firstEntry) props.onSelectEntry(firstEntry);224 } else if (e.key === "Escape" && selecting()) {225 exitSelectMode();226 }227 }228229 function handleListKeydown(e: KeyboardEvent) {230 if (!listRef) return;231 const btns = Array.from(listRef.querySelectorAll<HTMLButtonElement>(".peach-entry-row"));232 const idx = btns.indexOf(document.activeElement as HTMLButtonElement);233 if (e.key === "ArrowDown") {234 e.preventDefault();235 if (idx < btns.length - 1) btns[idx + 1]?.focus();236 } else if (e.key === "ArrowUp") {237 e.preventDefault();238 if (idx === 0) { searchRef?.focus(); }239 else btns[idx - 1]?.focus();240 } else if (e.key === "Escape") {241 if (selecting()) exitSelectMode();242 else props.onClose();243 }244 }245246 const sortLabels: { key: SortOrder; label: string }[] = [247 { key: "url-first", label: "smart" },248 { key: "az", label: "A–Z" },249 { key: "za", label: "Z–A" },250 { key: "recent", label: "newest" },251 ];252253 return (254 <div class="peach-overlay-view ov-enter-up" style="display:flex">255 <div class="peach-list-top">256 <button257 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"258 onClick={props.onClose}259 aria-label="Back"260 >261 <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">262 <path d="M6 1L1 6L6 11" />263 </svg>264 </button>265 <input266 class="peach-search-input"267 placeholder="search vault..."268 autocomplete="off"269 style="font-size:14px"270 ref={focusSearch}271 value={search()}272 onInput={(e) => setSearch((e.currentTarget as HTMLInputElement).value)}273 onKeyDown={handleSearchKeydown}274 />275 <span class="peach-search-counter peach-search-counter-list">276 {search() || selectedTags().size > 0277 ? `${filteredEntries().length} of ${props.entries.length}`278 : `${props.entries.length} entries`}279 </span>280 </div>281282 {}283 <div class="peach-sort-bar">284 <Show285 when={selecting()}286 fallback={287 <>288 <For each={sortLabels}>289 {({ key, label }) => (290 <button291 class={`peach-sort-btn${props.sortOrder === key ? " is-active" : ""}`}292 onClick={() => props.onSortOrderChange(key)}293 >294 {label}295 </button>296 )}297 </For>298 <span style="flex:1" />299 <Show when={props.onOpenTrash}>300 <button301 class="peach-sort-trash-btn"302 onClick={props.onOpenTrash}303 aria-label="View trash"304 title="View deleted entries"305 >306 <svg width="11" height="12" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">307 <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />308 </svg>309 </button>310 </Show>311 <Show when={props.onDeleteMany}>312 <button313 class="peach-sort-trash-btn"314 onClick={enterSelectMode}315 aria-label="Select entries"316 title="Select entries"317 >318 <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">319 <circle cx="4" cy="4" r="2.5" />320 <circle cx="9" cy="4" r="2.5" />321 <circle cx="4" cy="9" r="2.5" />322 <circle cx="9" cy="9" r="2.5" />323 </svg>324 </button>325 </Show>326 </>327 }328 >329 {}330 <button331 class="peach-select-cancel-btn"332 onClick={exitSelectMode}333 >334 cancel335 </button>336 <span class="peach-select-count-label">337 {selectedIds().size > 0 ? `${selectedIds().size} selected` : "select entries"}338 </span>339 <button340 class="peach-select-all-btn"341 onClick={toggleSelectAll}342 >343 {allSelected() ? "none" : "all"}344 </button>345 </Show>346 </div>347348 <Show when={hasTags() && !selecting()}>349 <div class="peach-tag-filter-strip" style="display:flex;gap:6px;padding:4px 12px 6px;overflow-x:auto;flex-wrap:nowrap;scrollbar-width:none;">350 <For each={allTags()}>351 {(tag) => (352 <button353 class={`peach-tag-filter-chip ${selectedTags().has(tag) ? "is-selected" : ""}`}354 onClick={() => toggleTag(tag)}355 >356 {tag}357 </button>358 )}359 </For>360 </div>361 </Show>362363 <div364 class={`peach-vault-list${selecting() ? " is-selecting" : ""}`}365 ref={listRef}366 onKeyDown={handleListKeydown}367 >368 <Show369 when={filteredEntries().length > 0}370 fallback={371 <div class="peach-list-empty">372 <span>no entries yet</span>373 </div>374 }375 >376 <For each={filteredEntries()}>377 {(entry, index) => {378 const isNote = entry.type === "note";379 const isPasskey = entry.type === "passkey";380 const domain = entry.type === "note"381 ? (entry.notes?.slice(0, 40) ?? "note")382 : entry.type === "passkey"383 ? entry.rpId ?? "passkey"384 : entry.url385 ? getDisplayDomain(entry.url)386 : "saved entry";387 const isTabMatch = () =>388 props.currentTabDomain !== undefined &&389 props.currentTabDomain !== "" &&390 entryDomain(entry) === props.currentTabDomain;391 const isSelected = () => selectedIds().has(entry.id);392393 return (394 <button395 class={`peach-entry-row${!selecting() && props.selectedEntryId === entry.id ? " is-active" : ""}${!selecting() && isTabMatch() ? " is-tab-match" : ""}${selecting() && isSelected() ? " is-selected" : ""}`}396 style={{ "animation-delay": `${Math.min(index(), 24) * 20}ms` }}397 onClick={() => {398 if (selecting()) {399 toggleId(entry.id);400 } else {401 props.onSelectEntry(entry);402 }403 }}404 >405 <div class={`peach-entry-row-icon${!selecting() && entry.breach ? " is-breach" : ""}${selecting() ? " is-select-icon" : ""}`}>406 <Show407 when={selecting()}408 fallback={409 <FaviconIcon410 domain={entry.url ? getDisplayDomain(entry.url) : ""}411 isNote={isNote}412 isPasskey={isPasskey}413 />414 }415 >416 {}417 <svg width="16" height="16" viewBox="0 0 16 16" fill="none">418 <circle419 cx="8" cy="8" r="7"420 stroke="currentColor"421 stroke-width="1"422 fill={isSelected() ? "var(--peach-accent)" : "transparent"}423 opacity={isSelected() ? "1" : "0.3"}424 />425 <Show when={isSelected()}>426 <path427 d="M5 8.5L7 10.5L11 6"428 stroke="white"429 stroke-width="1.2"430 stroke-linecap="round"431 stroke-linejoin="round"432 />433 </Show>434 </svg>435 </Show>436 </div>437 <div class="peach-entry-row-info">438 <span class="peach-entry-row-name">439 {entry.title}440 <Show when={isPasskey && !selecting()}>441 <span class="peach-passkey-badge">passkey</span>442 </Show>443 <Show when={isTabMatch() && !selecting()}>444 <span class="peach-tab-match-badge" title="Matches current tab">●</span>445 </Show>446 </span>447 <span class="peach-entry-row-meta">{domain}</span>448 </div>449 <Show when={entry.breach && !selecting()}>450 <span class="peach-entry-row-dot" aria-label="breach"></span>451 </Show>452 <Show when={entry.canary && !selecting()}>453 <span class="peach-entry-row-canary" title={`Canary: ${entry.canary?.label ?? "canary entry"}`}>🪤</span>454 </Show>455 </button>456 );457 }}458 </For>459 </Show>460 </div>461462 {}463 <Show464 when={selecting()}465 fallback={466 <button class="peach-new-entry-row" onClick={props.onAdd}>467 <svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round">468 <path d="M5 1v8M1 5h8" />469 </svg>470 new entry471 </button>472 }473 >474 <button475 class={`peach-select-action-bar${selectedIds().size === 0 ? " is-empty" : ""}`}476 disabled={selectedIds().size === 0 || deleting()}477 onClick={() => void handleDeleteSelected()}478 >479 <svg width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">480 <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />481 </svg>482 <Show483 when={selectedIds().size > 0}484 fallback={<span>select entries to delete</span>}485 >486 <span>487 {deleting()488 ? "moving to trash…"489 : `move ${selectedIds().size} to trash`}490 </span>491 </Show>492 </button>493 </Show>494 </div>495 );496}