1import { createEffect, createMemo, createSignal, For, Show } from "solid-js";2import type { VaultEntry } from "../../shared/types";3import { getDisplayDomain } from "../../shared/domain";4import { faviconUrl, faviconImgSig, globeFaviconSig, titleInitials, titleBackground } from "../../shared/favicon";5import { popupCopy } from "../copy";6import {7 filterAndSortVaultEntries,8 entryMatchesDomain,9 VAULT_SORT_OPTIONS,10 type SortOrder,11} from "./vault-search";1213export type { SortOrder } from "./vault-search";1415function vibrate(ms: number) {16 try { navigator.vibrate?.(ms); } catch { }17}1819function FaviconIcon(props: { domain: string; title?: string; isNote: boolean; isPasskey: boolean }) {20 const src = props.domain && !props.isNote21 ? faviconUrl(props.domain)22 : null;23 const [isReal, setIsReal] = createSignal(false);2425 26 27 28 29 30 return (31 <Show32 when={isReal()}33 fallback={34 <>35 {props.isNote36 ? <NoteIcon />37 : <InitialsChip title={props.title ?? props.domain ?? ""} />}38 {src && (39 <img40 src={src}41 style="display:none"42 onError={() => {}}43 onLoad={async (e) => {44 const img = e.currentTarget as HTMLImageElement;45 46 47 if (img.naturalWidth <= 16) return;48 49 50 const sig = faviconImgSig(img);51 if (sig !== null) {52 const gSig = await globeFaviconSig();53 if (gSig !== null && sig === gSig) return;54 }55 setIsReal(true);56 }}57 />58 )}59 </>60 }61 >62 <img src={src!} width="16" height="16" alt="" style="border-radius:3px;" />63 </Show>64 );65}666768function InitialsChip(props: { title: string }) {69 return (70 <span71 class="peach-favicon-initials"72 style={{ background: titleBackground(props.title) }}73 aria-hidden="true"74 >75 {titleInitials(props.title)}76 </span>77 );78}798081const NoteIcon = () => (82 <svg width="11" height="12" viewBox="0 0 11 12" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round" aria-hidden="true">83 <rect x="1.5" y="1.5" width="8" height="9" rx="1.5" />84 <path d="M3.5 4.5h4M3.5 6.5h4M3.5 8.5h2" />85 </svg>86);8788interface VaultProps {89 entries: VaultEntry[];90 selectedEntryId?: string;91 currentTabDomain?: string;92 sortOrder: SortOrder;93 searchSeed?: string;94 searchSeedNonce?: number;95 onSelectEntry: (entry: VaultEntry) => void;96 onSortOrderChange: (order: SortOrder) => void;97 onAdd: () => void;98 onClose: () => void;99 onOpenTrash?: () => void;100 onDeleteMany?: (ids: string[]) => Promise<void>;101}102103export function VaultView(props: VaultProps) {104 const [search, setSearch] = createSignal("");105 const [selectedTags, setSelectedTags] = createSignal<Set<string>>(new Set());106 const [notesOnly, setNotesOnly] = createSignal(false);107 const [selecting, setSelecting] = createSignal(false);108 const [selectedIds, setSelectedIds] = createSignal<Set<string>>(new Set());109 const [deleting, setDeleting] = createSignal(false);110 const [contextEntry, setContextEntry] = createSignal<VaultEntry | null>(null);111 const [flashId, setFlashId] = createSignal<string | null>(null);112 const [scrollTop, setScrollTop] = createSignal(0);113 const [viewportHeight, setViewportHeight] = createSignal(600);114 let longPressTimer: number | null = null;115 let touchStart: { x: number; y: number; t: number } | null = null;116 let suppressNextClick = false;117118 function clearLongPressTimer() {119 if (longPressTimer !== null) {120 window.clearTimeout(longPressTimer);121 longPressTimer = null;122 }123 }124125 function handleRowTouchStart(entry: VaultEntry, e: TouchEvent) {126 if (selecting()) return;127 const t = e.touches[0];128 if (!t) return;129 touchStart = { x: t.clientX, y: t.clientY, t: Date.now() };130 clearLongPressTimer();131 longPressTimer = window.setTimeout(() => {132 suppressNextClick = true;133 134 135 136 137 138 enterSelectMode();139 setSelectedIds(new Set([entry.id]));140 vibrate(20);141 longPressTimer = null;142 }, 500);143 }144145 function handleRowTouchMove(e: TouchEvent) {146 if (!touchStart) return;147 const t = e.touches[0];148 if (!t) return;149 const dx = Math.abs(t.clientX - touchStart.x);150 const dy = Math.abs(t.clientY - touchStart.y);151 if (dx > 8 || dy > 8) clearLongPressTimer();152 }153154 async function handleRowTouchEnd(entry: VaultEntry, e: TouchEvent) {155 clearLongPressTimer();156 if (!touchStart) return;157 const t = e.changedTouches[0];158 if (!t) { touchStart = null; return; }159 const dx = t.clientX - touchStart.x;160 const dy = Math.abs(t.clientY - touchStart.y);161 const dt = Date.now() - touchStart.t;162 touchStart = null;163 164 const isCompact = !document.body.classList.contains("layout-medium")165 && !document.body.classList.contains("layout-expanded");166 if (167 isCompact168 && !selecting()169 && dx > 80170 && dy < 30171 && dt < 400172 && entry.type === "login"173 && entry.password174 ) {175 suppressNextClick = true;176 vibrate(15);177 setFlashId(entry.id);178 window.setTimeout(() => setFlashId((id) => id === entry.id ? null : id), 600);179 await popupCopy(180 entry.password,181 "password",182 entry.canary?.webhookUrl ? { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title } : undefined,183 );184 }185 }186187 let searchRef: HTMLInputElement | undefined;188 let listRef: HTMLDivElement | undefined;189190 const focusSearch = (el: HTMLInputElement) => {191 searchRef = el;192 setTimeout(() => el.focus(), 50);193 };194195 createEffect(() => {196 void props.searchSeedNonce;197 const seed = props.searchSeed;198 if (!seed) return;199 setSearch(seed);200 requestAnimationFrame(() => {201 searchRef?.focus();202 searchRef?.setSelectionRange(seed.length, seed.length);203 });204 });205206 function enterSelectMode() {207 setSelecting(true);208 setSelectedIds(new Set<string>());209 }210211 function exitSelectMode() {212 setSelecting(false);213 setSelectedIds(new Set<string>());214 }215216 function toggleId(id: string) {217 const s = new Set<string>(selectedIds());218 if (s.has(id)) s.delete(id); else s.add(id);219 setSelectedIds(s);220 }221222 const allTags = createMemo(() => {223 const tagSet = new Set<string>();224 for (const entry of props.entries) {225 for (const tag of entry.tags ?? []) {226 tagSet.add(tag);227 }228 }229 return Array.from(tagSet).sort();230 });231232 const hasTags = createMemo(() => allTags().length > 0);233234 function toggleTag(tag: string) {235 const current = new Set(selectedTags());236 if (current.has(tag)) {237 current.delete(tag);238 } else {239 current.add(tag);240 }241 setSelectedTags(current);242 }243244 const filteredEntries = createMemo(() => {245 return filterAndSortVaultEntries(props.entries, {246 notesOnly: notesOnly(),247 selectedTags: selectedTags(),248 query: search(),249 searchScope: "both",250 sortOrder: props.sortOrder,251 currentTabDomain: props.currentTabDomain,252 });253 });254255 256 257 258 259 const allVisibleIds = createMemo(() => filteredEntries().map(e => e.id));260 const allSelected = createMemo(() =>261 allVisibleIds().length > 0 && allVisibleIds().every(id => selectedIds().has(id))262 );263264 function toggleSelectAll() {265 if (allSelected()) {266 setSelectedIds(new Set<string>());267 } else {268 setSelectedIds(new Set<string>(allVisibleIds()));269 }270 }271272 async function handleDeleteSelected() {273 const ids = Array.from(selectedIds());274 if (!ids.length || !props.onDeleteMany) return;275 setDeleting(true);276 await props.onDeleteMany(ids);277 setDeleting(false);278 exitSelectMode();279 }280281 282 283 284 285 286 const VIRTUAL_THRESHOLD = 100;287 const ROW_HEIGHT = 56;288 const BUFFER_ROWS = 20;289290 const isVirtualized = createMemo(() => filteredEntries().length > VIRTUAL_THRESHOLD);291292 const visibleRange = createMemo(() => {293 const total = filteredEntries().length;294 if (!isVirtualized()) return { start: 0, end: total };295 const start = Math.max(0, Math.floor(scrollTop() / ROW_HEIGHT) - BUFFER_ROWS);296 const end = Math.min(total, Math.ceil((scrollTop() + viewportHeight()) / ROW_HEIGHT) + BUFFER_ROWS);297 return { start, end };298 });299300 const windowedEntries = createMemo(() => {301 const { start, end } = visibleRange();302 return filteredEntries().slice(start, end);303 });304305 function handleListScroll(e: Event) {306 if (!isVirtualized()) return;307 const target = e.currentTarget as HTMLDivElement;308 setScrollTop(target.scrollTop);309 setViewportHeight(target.clientHeight);310 }311312 function handleSearchKeydown(e: KeyboardEvent) {313 if (e.key === "ArrowDown") {314 e.preventDefault();315 const firstBtn = listRef?.querySelector<HTMLButtonElement>(".peach-entry-row");316 firstBtn?.focus();317 } else if (e.key === "ArrowUp") {318 e.preventDefault();319 const allBtns = listRef?.querySelectorAll<HTMLButtonElement>(".peach-entry-row");320 allBtns?.[allBtns.length - 1]?.focus();321 } else if (e.key === "Enter") {322 e.preventDefault();323 const firstEntry = filteredEntries()[0];324 if (firstEntry) props.onSelectEntry(firstEntry);325 } else if (e.key === "Escape" && selecting()) {326 exitSelectMode();327 }328 }329330 function handleListKeydown(e: KeyboardEvent) {331 if (!listRef) return;332 const btns = Array.from(listRef.querySelectorAll<HTMLButtonElement>(".peach-entry-row"));333 const idx = btns.indexOf(document.activeElement as HTMLButtonElement);334 if (e.key === "ArrowDown") {335 e.preventDefault();336 if (idx < btns.length - 1) btns[idx + 1]?.focus();337 } else if (e.key === "ArrowUp") {338 e.preventDefault();339 if (idx === 0) { searchRef?.focus(); }340 else btns[idx - 1]?.focus();341 } else if (e.key === "Escape") {342 if (selecting()) exitSelectMode();343 else props.onClose();344 }345 }346347 return (348 <div class="peach-overlay-view ov-enter-up" style="display:flex">349 <div class="peach-list-top">350 <button351 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"352 onClick={props.onClose}353 aria-label="Back"354 >355 <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">356 <path d="M6 1L1 6L6 11" />357 </svg>358 </button>359 <input360 class="peach-search-input"361 placeholder="search vault..."362 autocomplete="off"363 style="font-size:14px"364 ref={focusSearch}365 value={search()}366 onInput={(e) => setSearch((e.currentTarget as HTMLInputElement).value)}367 onKeyDown={handleSearchKeydown}368 />369 <span class="peach-search-counter peach-search-counter-list">370 {search() || selectedTags().size > 0371 ? `${filteredEntries().length} of ${props.entries.length}`372 : `${props.entries.length} entries`}373 </span>374 </div>375376 {}377 <div class="peach-sort-bar">378 <Show379 when={selecting()}380 fallback={381 <>382 <For each={VAULT_SORT_OPTIONS}>383 {({ key, label }) => (384 <button385 class={`peach-sort-btn${props.sortOrder === key ? " is-active" : ""}`}386 onClick={() => props.onSortOrderChange(key)}387 >388 {label}389 </button>390 )}391 </For>392 <span style="flex:1" />393 <button394 class={`peach-sort-trash-btn${notesOnly() ? " is-active" : ""}`}395 onClick={() => setNotesOnly(v => !v)}396 aria-label="Notes only"397 title="Show notes only"398 >399 <NoteIcon />400 </button>401 <Show when={props.onOpenTrash}>402 <button403 class="peach-sort-trash-btn"404 onClick={props.onOpenTrash}405 aria-label="View trash"406 title="View deleted entries"407 >408 <svg width="11" height="12" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">409 <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />410 </svg>411 </button>412 </Show>413 <Show when={props.onDeleteMany}>414 <button415 class="peach-sort-trash-btn"416 onClick={enterSelectMode}417 aria-label="Select entries"418 title="Select entries"419 >420 <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">421 <circle cx="4" cy="4" r="2.5" />422 <circle cx="9" cy="4" r="2.5" />423 <circle cx="4" cy="9" r="2.5" />424 <circle cx="9" cy="9" r="2.5" />425 </svg>426 </button>427 </Show>428 </>429 }430 >431 {}432 <button433 class="peach-select-cancel-btn"434 onClick={exitSelectMode}435 >436 cancel437 </button>438 <span class="peach-select-count-label">439 {selectedIds().size > 0 ? `${selectedIds().size} selected` : "select entries"}440 </span>441 <button442 class="peach-select-all-btn"443 onClick={toggleSelectAll}444 >445 {allSelected() ? "none" : "all"}446 </button>447 </Show>448 </div>449450 <Show when={hasTags() && !selecting()}>451 <div class="peach-tag-filter-strip" style="display:flex;gap:6px;padding:4px 12px 6px;overflow-x:auto;flex-wrap:nowrap;scrollbar-width:none;">452 <For each={allTags()}>453 {(tag) => (454 <button455 class={`peach-tag-filter-chip ${selectedTags().has(tag) ? "is-selected" : ""}`}456 onClick={() => toggleTag(tag)}457 >458 {tag}459 </button>460 )}461 </For>462 </div>463 </Show>464465 <div466 class={`peach-vault-list${selecting() ? " is-selecting" : ""}`}467 ref={(el) => { listRef = el; setViewportHeight(el.clientHeight); }}468 onKeyDown={handleListKeydown}469 onScroll={handleListScroll}470 >471 <Show472 when={filteredEntries().length > 0}473 fallback={474 <div class="peach-list-empty">475 <span>no entries yet</span>476 </div>477 }478 >479 <Show when={isVirtualized()}>480 <div style={{ height: `${visibleRange().start * ROW_HEIGHT}px` }} aria-hidden="true" />481 </Show>482 <For each={windowedEntries()}>483 {(entry, localIndex) => {484 const index = () => visibleRange().start + localIndex();485 const isNote = entry.type === "note";486 const isPasskey = entry.passkey !== undefined;487 const domain = entry.type === "note"488 ? (entry.notes?.slice(0, 40) ?? "note")489 : entry.url490 ? getDisplayDomain(entry.url)491 : entry.passkey?.rpId492 ? entry.passkey.rpId493 : "saved entry";494 const isTabMatch = () =>495 props.currentTabDomain !== undefined &&496 props.currentTabDomain !== "" &&497 entryMatchesDomain(entry, props.currentTabDomain);498 const isSelected = () => selectedIds().has(entry.id);499500 const ariaLabel = (() => {501 const tag = isPasskey ? "passkey" : isNote ? "note" : "login";502 const sub = entry.username || domain || "";503 return sub504 ? `${entry.title} — ${sub} (${tag})`505 : `${entry.title} (${tag})`;506 })();507 return (508 <button509 class={`peach-entry-row${!selecting() && props.selectedEntryId === entry.id ? " is-active" : ""}${!selecting() && isTabMatch() ? " is-tab-match" : ""}${selecting() && isSelected() ? " is-selected" : ""}${flashId() === entry.id ? " is-copy-flash" : ""}`}510 style={isVirtualized()511 ? { animation: "none" }512 : { "animation-delay": `${Math.min(index(), 24) * 20}ms` }513 }514 aria-label={ariaLabel}515 aria-pressed={selecting() ? isSelected() : undefined}516 onTouchStart={(e) => handleRowTouchStart(entry, e)}517 onTouchMove={handleRowTouchMove}518 onTouchEnd={(e) => void handleRowTouchEnd(entry, e)}519 onTouchCancel={() => { clearLongPressTimer(); touchStart = null; }}520 onContextMenu={(e) => {521 e.preventDefault();522 523 524 525 526 527 if (touchStart !== null) { touchStart = null; return; }528 if (!selecting()) setContextEntry(entry);529 }}530 onClick={() => {531 if (suppressNextClick) { suppressNextClick = false; return; }532 if (selecting()) {533 toggleId(entry.id);534 } else {535 props.onSelectEntry(entry);536 }537 }}538 >539 <div class={`peach-entry-row-icon${!selecting() && entry.breach ? " is-breach" : ""}${selecting() ? " is-select-icon" : ""}`}>540 <Show541 when={selecting()}542 fallback={543 <FaviconIcon544 domain={entry.type === "note" ? "" : (entry.url ? getDisplayDomain(entry.url) : entry.passkey?.rpId ?? "")}545 title={entry.title}546 isNote={isNote}547 isPasskey={isPasskey}548 />549 }550 >551 {}552 <svg width="16" height="16" viewBox="0 0 16 16" fill="none">553 <circle554 cx="8" cy="8" r="7"555 stroke="currentColor"556 stroke-width="1"557 fill={isSelected() ? "var(--peach-accent)" : "transparent"}558 opacity={isSelected() ? "1" : "0.3"}559 />560 <Show when={isSelected()}>561 <path562 d="M5 8.5L7 10.5L11 6"563 stroke="white"564 stroke-width="1.2"565 stroke-linecap="round"566 stroke-linejoin="round"567 />568 </Show>569 </svg>570 </Show>571 </div>572 <div class="peach-entry-row-info">573 <span class="peach-entry-row-name">574 {entry.title}575 <Show when={isPasskey && !selecting()}>576 <span class="peach-passkey-badge">passkey</span>577 </Show>578 <Show when={isTabMatch() && !selecting()}>579 <span class="peach-tab-match-badge" title="Matches current tab">●</span>580 </Show>581 </span>582 <span class="peach-entry-row-meta">{domain}</span>583 </div>584 <Show when={entry.breach && !selecting()}>585 <span class="peach-entry-row-dot" aria-label="breach"></span>586 </Show>587 <Show when={entry.canary && !selecting()}>588 <span class="peach-entry-row-canary" title={`Canary: ${entry.canary?.label ?? "canary entry"}`}>🪤</span>589 </Show>590 </button>591 );592 }}593 </For>594 <Show when={isVirtualized()}>595 <div596 style={{ height: `${(filteredEntries().length - visibleRange().end) * ROW_HEIGHT}px` }}597 aria-hidden="true"598 />599 </Show>600 </Show>601 </div>602603 {}604 <Show605 when={selecting()}606 fallback={607 <button class="peach-new-entry-row" onClick={props.onAdd}>608 <svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round">609 <path d="M5 1v8M1 5h8" />610 </svg>611 new entry612 </button>613 }614 >615 <button616 class={`peach-select-action-bar${selectedIds().size === 0 ? " is-empty" : ""}`}617 disabled={selectedIds().size === 0 || deleting()}618 onClick={() => void handleDeleteSelected()}619 >620 <svg width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">621 <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />622 </svg>623 <Show624 when={selectedIds().size > 0}625 fallback={<span>select entries to delete</span>}626 >627 <span>628 {deleting()629 ? "moving to trash…"630 : `move ${selectedIds().size} to trash`}631 </span>632 </Show>633 </button>634 </Show>635636 <Show when={contextEntry() !== null}>637 <div638 class="peach-context-menu-backdrop"639 onClick={() => setContextEntry(null)}640 role="presentation"641 >642 <div643 class="peach-context-menu"644 role="menu"645 aria-label="Entry actions"646 onClick={(e) => e.stopPropagation()}647 >648 <Show when={contextEntry()!.username}>649 <button650 role="menuitem"651 class="peach-context-menu-item"652 onClick={async () => {653 const e = contextEntry();654 if (!e?.username) return;655 await popupCopy(e.username, "username");656 setContextEntry(null);657 }}658 >659 Copy username660 </button>661 </Show>662 <Show when={contextEntry()!.password}>663 <button664 role="menuitem"665 class="peach-context-menu-item"666 onClick={async () => {667 const e = contextEntry();668 if (!e?.password) return;669 await popupCopy(e.password, "password");670 setContextEntry(null);671 }}672 >673 Copy password674 </button>675 </Show>676 <Show when={contextEntry()!.totp}>677 <button678 role="menuitem"679 class="peach-context-menu-item"680 onClick={async () => {681 const e = contextEntry();682 if (!e?.totp) return;683 await popupCopy(e.totp, "TOTP secret");684 setContextEntry(null);685 }}686 >687 Copy TOTP secret688 </button>689 </Show>690 <button691 role="menuitem"692 class="peach-context-menu-item"693 onClick={() => {694 const e = contextEntry();695 if (e) props.onSelectEntry(e);696 setContextEntry(null);697 }}698 >699 Open700 </button>701 <Show when={props.onDeleteMany}>702 <button703 role="menuitem"704 class="peach-context-menu-item peach-context-menu-item--danger"705 onClick={async () => {706 const e = contextEntry();707 if (!e) return;708 setContextEntry(null);709 await props.onDeleteMany?.([e.id]);710 }}711 >712 Move to trash713 </button>714 </Show>715 <button716 role="menuitem"717 class="peach-context-menu-item peach-context-menu-item--cancel"718 onClick={() => setContextEntry(null)}719 >720 Cancel721 </button>722 </div>723 </div>724 </Show>725 </div>726 );727}