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 <Show when={props.currentTabDomain && props.sortOrder === "url-first"}>299 <span class="peach-sort-domain-hint">{props.currentTabDomain}</span>300 </Show>301 <span style="flex:1" />302 <Show when={props.onOpenTrash}>303 <button304 class="peach-sort-trash-btn"305 onClick={props.onOpenTrash}306 aria-label="View trash"307 title="View deleted entries"308 >309 <svg width="11" height="12" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">310 <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />311 </svg>312 </button>313 </Show>314 <Show when={props.onDeleteMany}>315 <button316 class="peach-sort-trash-btn"317 onClick={enterSelectMode}318 aria-label="Select entries"319 title="Select entries"320 >321 <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">322 <circle cx="4" cy="4" r="2.5" />323 <circle cx="9" cy="4" r="2.5" />324 <circle cx="4" cy="9" r="2.5" />325 <circle cx="9" cy="9" r="2.5" />326 </svg>327 </button>328 </Show>329 </>330 }331 >332 {}333 <button334 class="peach-select-cancel-btn"335 onClick={exitSelectMode}336 >337 cancel338 </button>339 <span class="peach-select-count-label">340 {selectedIds().size > 0 ? `${selectedIds().size} selected` : "select entries"}341 </span>342 <button343 class="peach-select-all-btn"344 onClick={toggleSelectAll}345 >346 {allSelected() ? "none" : "all"}347 </button>348 </Show>349 </div>350351 <Show when={hasTags() && !selecting()}>352 <div class="peach-tag-filter-strip" style="display:flex;gap:6px;padding:4px 12px 6px;overflow-x:auto;flex-wrap:nowrap;scrollbar-width:none;">353 <For each={allTags()}>354 {(tag) => (355 <button356 class={`peach-tag-filter-chip ${selectedTags().has(tag) ? "is-selected" : ""}`}357 onClick={() => toggleTag(tag)}358 >359 {tag}360 </button>361 )}362 </For>363 </div>364 </Show>365366 <div367 class={`peach-vault-list${selecting() ? " is-selecting" : ""}`}368 ref={listRef}369 onKeyDown={handleListKeydown}370 >371 <Show372 when={filteredEntries().length > 0}373 fallback={374 <div class="peach-list-empty">375 <span>no entries yet</span>376 </div>377 }378 >379 <For each={filteredEntries()}>380 {(entry, index) => {381 const isNote = entry.type === "note";382 const isPasskey = entry.type === "passkey";383 const domain = entry.type === "note"384 ? (entry.notes?.slice(0, 40) ?? "note")385 : entry.type === "passkey"386 ? entry.rpId ?? "passkey"387 : entry.url388 ? getDisplayDomain(entry.url)389 : "saved entry";390 const isTabMatch = () =>391 props.currentTabDomain !== undefined &&392 props.currentTabDomain !== "" &&393 entryDomain(entry) === props.currentTabDomain;394 const isSelected = () => selectedIds().has(entry.id);395396 return (397 <button398 class={`peach-entry-row${!selecting() && props.selectedEntryId === entry.id ? " is-active" : ""}${!selecting() && isTabMatch() ? " is-tab-match" : ""}${selecting() && isSelected() ? " is-selected" : ""}`}399 style={{ "animation-delay": `${Math.min(index(), 24) * 20}ms` }}400 onClick={() => {401 if (selecting()) {402 toggleId(entry.id);403 } else {404 props.onSelectEntry(entry);405 }406 }}407 >408 <div class={`peach-entry-row-icon${!selecting() && entry.breach ? " is-breach" : ""}${selecting() ? " is-select-icon" : ""}`}>409 <Show410 when={selecting()}411 fallback={412 <FaviconIcon413 domain={entry.url ? getDisplayDomain(entry.url) : ""}414 isNote={isNote}415 isPasskey={isPasskey}416 />417 }418 >419 {}420 <svg width="16" height="16" viewBox="0 0 16 16" fill="none">421 <circle422 cx="8" cy="8" r="7"423 stroke="currentColor"424 stroke-width="1"425 fill={isSelected() ? "var(--peach-accent)" : "transparent"}426 opacity={isSelected() ? "1" : "0.3"}427 />428 <Show when={isSelected()}>429 <path430 d="M5 8.5L7 10.5L11 6"431 stroke="white"432 stroke-width="1.2"433 stroke-linecap="round"434 stroke-linejoin="round"435 />436 </Show>437 </svg>438 </Show>439 </div>440 <div class="peach-entry-row-info">441 <span class="peach-entry-row-name">442 {entry.title}443 <Show when={isPasskey && !selecting()}>444 <span class="peach-passkey-badge">passkey</span>445 </Show>446 <Show when={isTabMatch() && !selecting()}>447 <span class="peach-tab-match-badge" title="Matches current tab">●</span>448 </Show>449 </span>450 <span class="peach-entry-row-meta">{domain}</span>451 </div>452 <Show when={entry.breach && !selecting()}>453 <span class="peach-entry-row-dot" aria-label="breach"></span>454 </Show>455 <Show when={entry.canary && !selecting()}>456 <span class="peach-entry-row-canary" title={`Canary: ${entry.canary?.label ?? "canary entry"}`}>🪤</span>457 </Show>458 </button>459 );460 }}461 </For>462 </Show>463 </div>464465 {}466 <Show467 when={selecting()}468 fallback={469 <button class="peach-new-entry-row" onClick={props.onAdd}>470 <svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round">471 <path d="M5 1v8M1 5h8" />472 </svg>473 new entry474 </button>475 }476 >477 <button478 class={`peach-select-action-bar${selectedIds().size === 0 ? " is-empty" : ""}`}479 disabled={selectedIds().size === 0 || deleting()}480 onClick={() => void handleDeleteSelected()}481 >482 <svg width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">483 <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />484 </svg>485 <Show486 when={selectedIds().size > 0}487 fallback={<span>select entries to delete</span>}488 >489 <span>490 {deleting()491 ? "moving to trash…"492 : `move ${selectedIds().size} to trash`}493 </span>494 </Show>495 </button>496 </Show>497 </div>498 );499}