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 [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 const filteredEntries = createMemo(() => {141 let result = props.entries;142143 if (notesOnly()) {144 result = result.filter(entry => entry.type === "note");145 }146147 if (selectedTags().size > 0) {148 result = result.filter(entry =>149 Array.from(selectedTags()).every(tag => entry.tags?.includes(tag))150 );151 }152153 const q = search().trim();154 if (q) {155 result = result156 .filter((entry) => {157 const searchable = entry.type === "note"158 ? `${entry.title} ${entry.notes ?? ""}`159 : entry.type === "passkey"160 ? `${entry.title} ${entry.rpId ?? ""} ${entry.userName ?? ""} ${entry.userDisplayName ?? ""}`161 : `${entry.title} ${entry.url ?? ""} ${entry.username ?? ""}`;162 return fuzzyMatch(q, searchable);163 })164 .sort((a, b) => {165 const aSearchable = a.type === "note"166 ? `${a.title} ${a.notes ?? ""}`.toLowerCase()167 : a.type === "passkey"168 ? `${a.title} ${a.rpId ?? ""} ${a.userName ?? ""}`.toLowerCase()169 : `${a.title} ${a.url ?? ""} ${a.username ?? ""}`.toLowerCase();170 const bSearchable = b.type === "note"171 ? `${b.title} ${b.notes ?? ""}`.toLowerCase()172 : b.type === "passkey"173 ? `${b.title} ${b.rpId ?? ""} ${b.userName ?? ""}`.toLowerCase()174 : `${b.title} ${b.url ?? ""} ${b.username ?? ""}`.toLowerCase();175 const qLower = q.toLowerCase();176 const aExact = isExactPrefix(qLower, aSearchable);177 const bExact = isExactPrefix(qLower, bSearchable);178 if (aExact && !bExact) return -1;179 if (!aExact && bExact) return 1;180 return 0;181 });182 return result;183 }184185 const tabDomain = props.currentTabDomain ?? "";186 const order = props.sortOrder;187188 if (order === "az") {189 return [...result].sort((a, b) => a.title.localeCompare(b.title));190 }191 if (order === "za") {192 return [...result].sort((a, b) => b.title.localeCompare(a.title));193 }194 if (order === "recent") {195 return [...result].sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0));196 }197 if (tabDomain) {198 return [...result].sort((a, b) => {199 const aMatch = entryDomain(a) === tabDomain ? 0 : 1;200 const bMatch = entryDomain(b) === tabDomain ? 0 : 1;201 return aMatch - bMatch;202 });203 }204 return result;205 });206207 208 209 210 211 const allVisibleIds = createMemo(() => filteredEntries().map(e => e.id));212 const allSelected = createMemo(() =>213 allVisibleIds().length > 0 && allVisibleIds().every(id => selectedIds().has(id))214 );215216 function toggleSelectAll() {217 if (allSelected()) {218 setSelectedIds(new Set<string>());219 } else {220 setSelectedIds(new Set<string>(allVisibleIds()));221 }222 }223224 async function handleDeleteSelected() {225 const ids = Array.from(selectedIds());226 if (!ids.length || !props.onDeleteMany) return;227 setDeleting(true);228 await props.onDeleteMany(ids);229 setDeleting(false);230 exitSelectMode();231 }232233 function handleSearchKeydown(e: KeyboardEvent) {234 if (e.key === "ArrowDown") {235 e.preventDefault();236 const firstBtn = listRef?.querySelector<HTMLButtonElement>(".peach-entry-row");237 firstBtn?.focus();238 } else if (e.key === "ArrowUp") {239 e.preventDefault();240 const allBtns = listRef?.querySelectorAll<HTMLButtonElement>(".peach-entry-row");241 allBtns?.[allBtns.length - 1]?.focus();242 } else if (e.key === "Enter") {243 e.preventDefault();244 const firstEntry = filteredEntries()[0];245 if (firstEntry) props.onSelectEntry(firstEntry);246 } else if (e.key === "Escape" && selecting()) {247 exitSelectMode();248 }249 }250251 function handleListKeydown(e: KeyboardEvent) {252 if (!listRef) return;253 const btns = Array.from(listRef.querySelectorAll<HTMLButtonElement>(".peach-entry-row"));254 const idx = btns.indexOf(document.activeElement as HTMLButtonElement);255 if (e.key === "ArrowDown") {256 e.preventDefault();257 if (idx < btns.length - 1) btns[idx + 1]?.focus();258 } else if (e.key === "ArrowUp") {259 e.preventDefault();260 if (idx === 0) { searchRef?.focus(); }261 else btns[idx - 1]?.focus();262 } else if (e.key === "Escape") {263 if (selecting()) exitSelectMode();264 else props.onClose();265 }266 }267268 const sortLabels: { key: SortOrder; label: string }[] = [269 { key: "url-first", label: "smart" },270 { key: "az", label: "A–Z" },271 { key: "za", label: "Z–A" },272 { key: "recent", label: "newest" },273 ];274275 return (276 <div class="peach-overlay-view ov-enter-up" style="display:flex">277 <div class="peach-list-top">278 <button279 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"280 onClick={props.onClose}281 aria-label="Back"282 >283 <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">284 <path d="M6 1L1 6L6 11" />285 </svg>286 </button>287 <input288 class="peach-search-input"289 placeholder="search vault..."290 autocomplete="off"291 style="font-size:14px"292 ref={focusSearch}293 value={search()}294 onInput={(e) => setSearch((e.currentTarget as HTMLInputElement).value)}295 onKeyDown={handleSearchKeydown}296 />297 <span class="peach-search-counter peach-search-counter-list">298 {search() || selectedTags().size > 0299 ? `${filteredEntries().length} of ${props.entries.length}`300 : `${props.entries.length} entries`}301 </span>302 </div>303304 {}305 <div class="peach-sort-bar">306 <Show307 when={selecting()}308 fallback={309 <>310 <For each={sortLabels}>311 {({ key, label }) => (312 <button313 class={`peach-sort-btn${props.sortOrder === key ? " is-active" : ""}`}314 onClick={() => props.onSortOrderChange(key)}315 >316 {label}317 </button>318 )}319 </For>320 <span style="flex:1" />321 <button322 class={`peach-sort-trash-btn${notesOnly() ? " is-active" : ""}`}323 onClick={() => setNotesOnly(v => !v)}324 aria-label="Notes only"325 title="Show notes only"326 >327 {NOTE_ICON}328 </button>329 <Show when={props.onOpenTrash}>330 <button331 class="peach-sort-trash-btn"332 onClick={props.onOpenTrash}333 aria-label="View trash"334 title="View deleted entries"335 >336 <svg width="11" height="12" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">337 <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />338 </svg>339 </button>340 </Show>341 <Show when={props.onDeleteMany}>342 <button343 class="peach-sort-trash-btn"344 onClick={enterSelectMode}345 aria-label="Select entries"346 title="Select entries"347 >348 <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">349 <circle cx="4" cy="4" r="2.5" />350 <circle cx="9" cy="4" r="2.5" />351 <circle cx="4" cy="9" r="2.5" />352 <circle cx="9" cy="9" r="2.5" />353 </svg>354 </button>355 </Show>356 </>357 }358 >359 {}360 <button361 class="peach-select-cancel-btn"362 onClick={exitSelectMode}363 >364 cancel365 </button>366 <span class="peach-select-count-label">367 {selectedIds().size > 0 ? `${selectedIds().size} selected` : "select entries"}368 </span>369 <button370 class="peach-select-all-btn"371 onClick={toggleSelectAll}372 >373 {allSelected() ? "none" : "all"}374 </button>375 </Show>376 </div>377378 <Show when={hasTags() && !selecting()}>379 <div class="peach-tag-filter-strip" style="display:flex;gap:6px;padding:4px 12px 6px;overflow-x:auto;flex-wrap:nowrap;scrollbar-width:none;">380 <For each={allTags()}>381 {(tag) => (382 <button383 class={`peach-tag-filter-chip ${selectedTags().has(tag) ? "is-selected" : ""}`}384 onClick={() => toggleTag(tag)}385 >386 {tag}387 </button>388 )}389 </For>390 </div>391 </Show>392393 <div394 class={`peach-vault-list${selecting() ? " is-selecting" : ""}`}395 ref={listRef}396 onKeyDown={handleListKeydown}397 >398 <Show399 when={filteredEntries().length > 0}400 fallback={401 <div class="peach-list-empty">402 <span>no entries yet</span>403 </div>404 }405 >406 <For each={filteredEntries()}>407 {(entry, index) => {408 const isNote = entry.type === "note";409 const isPasskey = entry.type === "passkey";410 const domain = entry.type === "note"411 ? (entry.notes?.slice(0, 40) ?? "note")412 : entry.type === "passkey"413 ? entry.rpId ?? "passkey"414 : entry.url415 ? getDisplayDomain(entry.url)416 : "saved entry";417 const isTabMatch = () =>418 props.currentTabDomain !== undefined &&419 props.currentTabDomain !== "" &&420 entryDomain(entry) === props.currentTabDomain;421 const isSelected = () => selectedIds().has(entry.id);422423 return (424 <button425 class={`peach-entry-row${!selecting() && props.selectedEntryId === entry.id ? " is-active" : ""}${!selecting() && isTabMatch() ? " is-tab-match" : ""}${selecting() && isSelected() ? " is-selected" : ""}`}426 style={{ "animation-delay": `${Math.min(index(), 24) * 20}ms` }}427 onClick={() => {428 if (selecting()) {429 toggleId(entry.id);430 } else {431 props.onSelectEntry(entry);432 }433 }}434 >435 <div class={`peach-entry-row-icon${!selecting() && entry.breach ? " is-breach" : ""}${selecting() ? " is-select-icon" : ""}`}>436 <Show437 when={selecting()}438 fallback={439 <FaviconIcon440 domain={entry.url ? getDisplayDomain(entry.url) : ""}441 isNote={isNote}442 isPasskey={isPasskey}443 />444 }445 >446 {}447 <svg width="16" height="16" viewBox="0 0 16 16" fill="none">448 <circle449 cx="8" cy="8" r="7"450 stroke="currentColor"451 stroke-width="1"452 fill={isSelected() ? "var(--peach-accent)" : "transparent"}453 opacity={isSelected() ? "1" : "0.3"}454 />455 <Show when={isSelected()}>456 <path457 d="M5 8.5L7 10.5L11 6"458 stroke="white"459 stroke-width="1.2"460 stroke-linecap="round"461 stroke-linejoin="round"462 />463 </Show>464 </svg>465 </Show>466 </div>467 <div class="peach-entry-row-info">468 <span class="peach-entry-row-name">469 {entry.title}470 <Show when={isPasskey && !selecting()}>471 <span class="peach-passkey-badge">passkey</span>472 </Show>473 <Show when={isTabMatch() && !selecting()}>474 <span class="peach-tab-match-badge" title="Matches current tab">●</span>475 </Show>476 </span>477 <span class="peach-entry-row-meta">{domain}</span>478 </div>479 <Show when={entry.breach && !selecting()}>480 <span class="peach-entry-row-dot" aria-label="breach"></span>481 </Show>482 <Show when={entry.canary && !selecting()}>483 <span class="peach-entry-row-canary" title={`Canary: ${entry.canary?.label ?? "canary entry"}`}>🪤</span>484 </Show>485 </button>486 );487 }}488 </For>489 </Show>490 </div>491492 {}493 <Show494 when={selecting()}495 fallback={496 <button class="peach-new-entry-row" onClick={props.onAdd}>497 <svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round">498 <path d="M5 1v8M1 5h8" />499 </svg>500 new entry501 </button>502 }503 >504 <button505 class={`peach-select-action-bar${selectedIds().size === 0 ? " is-empty" : ""}`}506 disabled={selectedIds().size === 0 || deleting()}507 onClick={() => void handleDeleteSelected()}508 >509 <svg width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">510 <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />511 </svg>512 <Show513 when={selectedIds().size > 0}514 fallback={<span>select entries to delete</span>}515 >516 <span>517 {deleting()518 ? "moving to trash…"519 : `move ${selectedIds().size} to trash`}520 </span>521 </Show>522 </button>523 </Show>524 </div>525 );526}