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_SEARCH_SCOPE_OPTIONS,10 VAULT_SORT_OPTIONS,11 type SearchScope,12 type SortOrder,13} from "./vault-search";1415export type { SortOrder } from "./vault-search";1617function vibrate(ms: number) {18 try { navigator.vibrate?.(ms); } catch { }19}2021function FaviconIcon(props: { domain: string; title?: string; isNote: boolean; isPasskey: boolean }) {22 const src = props.domain && !props.isNote && !props.isPasskey23 ? faviconUrl(props.domain)24 : null;25 const [isReal, setIsReal] = createSignal(false);2627 28 29 30 return (31 <Show32 when={isReal()}33 fallback={34 <>35 {props.isPasskey36 ? <PasskeyIcon />37 : props.isNote38 ? <NoteIcon />39 : <InitialsChip title={props.title ?? props.domain ?? ""} />}40 {src && (41 <img42 src={src}43 style="display:none"44 onError={() => {}}45 onLoad={async (e) => {46 const img = e.currentTarget as HTMLImageElement;47 48 49 if (img.naturalWidth <= 16) return;50 51 52 const sig = faviconImgSig(img);53 if (sig !== null) {54 const gSig = await globeFaviconSig();55 if (gSig !== null && sig === gSig) return;56 }57 setIsReal(true);58 }}59 />60 )}61 </>62 }63 >64 <img src={src!} width="16" height="16" alt="" style="border-radius:3px;" />65 </Show>66 );67}686970function InitialsChip(props: { title: string }) {71 return (72 <span73 class="peach-favicon-initials"74 style={{ background: titleBackground(props.title) }}75 aria-hidden="true"76 >77 {titleInitials(props.title)}78 </span>79 );80}818283const PasskeyIcon = () => (84 <svg width="11" height="12" viewBox="0 0 11 12" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round" aria-hidden="true">85 <circle cx="4" cy="4.5" r="3" />86 <path d="M6.5 7l3.5 3.5M8.5 8.5L10 10" />87 </svg>88);8990const NoteIcon = () => (91 <svg width="11" height="12" viewBox="0 0 11 12" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round" aria-hidden="true">92 <rect x="1.5" y="1.5" width="8" height="9" rx="1.5" />93 <path d="M3.5 4.5h4M3.5 6.5h4M3.5 8.5h2" />94 </svg>95);9697interface VaultProps {98 entries: VaultEntry[];99 selectedEntryId?: string;100 currentTabDomain?: string;101 sortOrder: SortOrder;102 searchSeed?: string;103 searchSeedNonce?: number;104 105 106 107 isNative?: boolean;108 onSelectEntry: (entry: VaultEntry) => void;109 onSortOrderChange: (order: SortOrder) => void;110 onAdd: () => void;111 onClose: () => void;112 onOpenTrash?: () => void;113 onDeleteMany?: (ids: string[]) => Promise<void>;114}115116export function VaultView(props: VaultProps) {117 const [search, setSearch] = createSignal("");118 const [searchScope, setSearchScope] = createSignal<SearchScope>("both");119 const [selectedTags, setSelectedTags] = createSignal<Set<string>>(new Set());120 const [notesOnly, setNotesOnly] = createSignal(false);121 const [selecting, setSelecting] = createSignal(false);122 const [selectedIds, setSelectedIds] = createSignal<Set<string>>(new Set());123 const [deleting, setDeleting] = createSignal(false);124 const [contextEntry, setContextEntry] = createSignal<VaultEntry | null>(null);125 const [flashId, setFlashId] = createSignal<string | null>(null);126 const [scrollTop, setScrollTop] = createSignal(0);127 const [viewportHeight, setViewportHeight] = createSignal(600);128 let longPressTimer: number | null = null;129 let touchStart: { x: number; y: number; t: number } | null = null;130 let suppressNextClick = false;131132 function clearLongPressTimer() {133 if (longPressTimer !== null) {134 window.clearTimeout(longPressTimer);135 longPressTimer = null;136 }137 }138139 function handleRowTouchStart(entry: VaultEntry, e: TouchEvent) {140 if (selecting()) return;141 const t = e.touches[0];142 if (!t) return;143 touchStart = { x: t.clientX, y: t.clientY, t: Date.now() };144 clearLongPressTimer();145 longPressTimer = window.setTimeout(() => {146 suppressNextClick = true;147 vibrate(20);148 setContextEntry(entry);149 longPressTimer = null;150 }, 500);151 }152153 function handleRowTouchMove(e: TouchEvent) {154 if (!touchStart) return;155 const t = e.touches[0];156 if (!t) return;157 const dx = Math.abs(t.clientX - touchStart.x);158 const dy = Math.abs(t.clientY - touchStart.y);159 if (dx > 8 || dy > 8) clearLongPressTimer();160 }161162 async function handleRowTouchEnd(entry: VaultEntry, e: TouchEvent) {163 clearLongPressTimer();164 if (!touchStart) return;165 const t = e.changedTouches[0];166 if (!t) { touchStart = null; return; }167 const dx = t.clientX - touchStart.x;168 const dy = Math.abs(t.clientY - touchStart.y);169 const dt = Date.now() - touchStart.t;170 touchStart = null;171 172 const isCompact = !document.body.classList.contains("layout-medium")173 && !document.body.classList.contains("layout-expanded");174 if (175 isCompact176 && !selecting()177 && dx > 80178 && dy < 30179 && dt < 400180 && entry.type === "login"181 && entry.password182 ) {183 suppressNextClick = true;184 vibrate(15);185 setFlashId(entry.id);186 window.setTimeout(() => setFlashId((id) => id === entry.id ? null : id), 600);187 await popupCopy(entry.password, "password");188 }189 }190191 let searchRef: HTMLInputElement | undefined;192 let listRef: HTMLDivElement | undefined;193194 const focusSearch = (el: HTMLInputElement) => {195 searchRef = el;196 setTimeout(() => el.focus(), 50);197 };198199 createEffect(() => {200 void props.searchSeedNonce;201 const seed = props.searchSeed;202 if (!seed) return;203 setSearch(seed);204 requestAnimationFrame(() => {205 searchRef?.focus();206 searchRef?.setSelectionRange(seed.length, seed.length);207 });208 });209210 function enterSelectMode() {211 setSelecting(true);212 setSelectedIds(new Set<string>());213 }214215 function exitSelectMode() {216 setSelecting(false);217 setSelectedIds(new Set<string>());218 }219220 function toggleId(id: string) {221 const s = new Set<string>(selectedIds());222 if (s.has(id)) s.delete(id); else s.add(id);223 setSelectedIds(s);224 }225226 const allTags = createMemo(() => {227 const tagSet = new Set<string>();228 for (const entry of props.entries) {229 for (const tag of entry.tags ?? []) {230 tagSet.add(tag);231 }232 }233 return Array.from(tagSet).sort();234 });235236 const hasTags = createMemo(() => allTags().length > 0);237238 function toggleTag(tag: string) {239 const current = new Set(selectedTags());240 if (current.has(tag)) {241 current.delete(tag);242 } else {243 current.add(tag);244 }245 setSelectedTags(current);246 }247248 const filteredEntries = createMemo(() => {249 return filterAndSortVaultEntries(props.entries, {250 notesOnly: notesOnly(),251 selectedTags: selectedTags(),252 query: search(),253 searchScope: searchScope(),254 sortOrder: props.sortOrder,255 currentTabDomain: props.currentTabDomain,256 });257 });258259 260 261 262 263 const allVisibleIds = createMemo(() => filteredEntries().map(e => e.id));264 const allSelected = createMemo(() =>265 allVisibleIds().length > 0 && allVisibleIds().every(id => selectedIds().has(id))266 );267268 function toggleSelectAll() {269 if (allSelected()) {270 setSelectedIds(new Set<string>());271 } else {272 setSelectedIds(new Set<string>(allVisibleIds()));273 }274 }275276 async function handleDeleteSelected() {277 const ids = Array.from(selectedIds());278 if (!ids.length || !props.onDeleteMany) return;279 setDeleting(true);280 await props.onDeleteMany(ids);281 setDeleting(false);282 exitSelectMode();283 }284285 286 287 288 289 290 const VIRTUAL_THRESHOLD = 100;291 const ROW_HEIGHT = 56;292 const BUFFER_ROWS = 20;293294 const isVirtualized = createMemo(() => filteredEntries().length > VIRTUAL_THRESHOLD);295296 const visibleRange = createMemo(() => {297 const total = filteredEntries().length;298 if (!isVirtualized()) return { start: 0, end: total };299 const start = Math.max(0, Math.floor(scrollTop() / ROW_HEIGHT) - BUFFER_ROWS);300 const end = Math.min(total, Math.ceil((scrollTop() + viewportHeight()) / ROW_HEIGHT) + BUFFER_ROWS);301 return { start, end };302 });303304 const windowedEntries = createMemo(() => {305 const { start, end } = visibleRange();306 return filteredEntries().slice(start, end);307 });308309 function handleListScroll(e: Event) {310 if (!isVirtualized()) return;311 const target = e.currentTarget as HTMLDivElement;312 setScrollTop(target.scrollTop);313 setViewportHeight(target.clientHeight);314 }315316 function handleSearchKeydown(e: KeyboardEvent) {317 if (e.key === "ArrowDown") {318 e.preventDefault();319 const firstBtn = listRef?.querySelector<HTMLButtonElement>(".peach-entry-row");320 firstBtn?.focus();321 } else if (e.key === "ArrowUp") {322 e.preventDefault();323 const allBtns = listRef?.querySelectorAll<HTMLButtonElement>(".peach-entry-row");324 allBtns?.[allBtns.length - 1]?.focus();325 } else if (e.key === "Enter") {326 e.preventDefault();327 const firstEntry = filteredEntries()[0];328 if (firstEntry) props.onSelectEntry(firstEntry);329 } else if (e.key === "Escape" && selecting()) {330 exitSelectMode();331 }332 }333334 function handleListKeydown(e: KeyboardEvent) {335 if (!listRef) return;336 const btns = Array.from(listRef.querySelectorAll<HTMLButtonElement>(".peach-entry-row"));337 const idx = btns.indexOf(document.activeElement as HTMLButtonElement);338 if (e.key === "ArrowDown") {339 e.preventDefault();340 if (idx < btns.length - 1) btns[idx + 1]?.focus();341 } else if (e.key === "ArrowUp") {342 e.preventDefault();343 if (idx === 0) { searchRef?.focus(); }344 else btns[idx - 1]?.focus();345 } else if (e.key === "Escape") {346 if (selecting()) exitSelectMode();347 else props.onClose();348 }349 }350351 return (352 <div class="peach-overlay-view ov-enter-up" style="display:flex">353 <div class="peach-list-top" classList={{ "peach-list-top--native": !!props.isNative }}>354 <Show355 when={!props.isNative}356 fallback={357 <span class="peach-search-icon" aria-hidden="true">358 <svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round">359 <circle cx="5.5" cy="5.5" r="3.75" />360 <path d="M8.75 8.75L11.5 11.5" />361 </svg>362 </span>363 }364 >365 <button366 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"367 onClick={props.onClose}368 aria-label="Back"369 >370 <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">371 <path d="M6 1L1 6L6 11" />372 </svg>373 </button>374 </Show>375 <input376 class="peach-search-input"377 placeholder="search vault..."378 autocomplete="off"379 style={props.isNative ? undefined : "font-size:14px"}380 ref={focusSearch}381 value={search()}382 onInput={(e) => setSearch((e.currentTarget as HTMLInputElement).value)}383 onKeyDown={handleSearchKeydown}384 />385 <span class="peach-search-counter peach-search-counter-list">386 {search() || selectedTags().size > 0387 ? `${filteredEntries().length} of ${props.entries.length}`388 : props.isNative389 ? `${props.entries.length}`390 : `${props.entries.length} entries`}391 </span>392 </div>393394 {}395 <div class="peach-sort-bar">396 <Show397 when={selecting()}398 fallback={399 <>400 <For each={VAULT_SORT_OPTIONS}>401 {({ key, label }) => (402 <button403 class={`peach-sort-btn${props.sortOrder === key ? " is-active" : ""}`}404 onClick={() => props.onSortOrderChange(key)}405 >406 {label}407 </button>408 )}409 </For>410 <For each={VAULT_SEARCH_SCOPE_OPTIONS}>411 {({ key, label }) => (412 <button413 class={`peach-sort-btn peach-sort-scope-btn${searchScope() === key ? " is-active" : ""}`}414 onClick={() => setSearchScope(key)}415 aria-label={`Search ${label}`}416 title={`Search ${label}`}417 >418 {label}419 </button>420 )}421 </For>422 <span style="flex:1" />423 <button424 class={`peach-sort-trash-btn${notesOnly() ? " is-active" : ""}`}425 onClick={() => setNotesOnly(v => !v)}426 aria-label="Notes only"427 title="Show notes only"428 >429 <NoteIcon />430 </button>431 <Show when={props.onOpenTrash}>432 <button433 class="peach-sort-trash-btn"434 onClick={props.onOpenTrash}435 aria-label="View trash"436 title="View deleted entries"437 >438 <svg width="11" height="12" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">439 <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />440 </svg>441 </button>442 </Show>443 <Show when={props.onDeleteMany}>444 <button445 class="peach-sort-trash-btn"446 onClick={enterSelectMode}447 aria-label="Select entries"448 title="Select entries"449 >450 <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">451 <circle cx="4" cy="4" r="2.5" />452 <circle cx="9" cy="4" r="2.5" />453 <circle cx="4" cy="9" r="2.5" />454 <circle cx="9" cy="9" r="2.5" />455 </svg>456 </button>457 </Show>458 </>459 }460 >461 {}462 <button463 class="peach-select-cancel-btn"464 onClick={exitSelectMode}465 >466 cancel467 </button>468 <span class="peach-select-count-label">469 {selectedIds().size > 0 ? `${selectedIds().size} selected` : "select entries"}470 </span>471 <button472 class="peach-select-all-btn"473 onClick={toggleSelectAll}474 >475 {allSelected() ? "none" : "all"}476 </button>477 </Show>478 </div>479480 <Show when={hasTags() && !selecting()}>481 <div class="peach-tag-filter-strip" style="display:flex;gap:6px;padding:4px 12px 6px;overflow-x:auto;flex-wrap:nowrap;scrollbar-width:none;">482 <For each={allTags()}>483 {(tag) => (484 <button485 class={`peach-tag-filter-chip ${selectedTags().has(tag) ? "is-selected" : ""}`}486 onClick={() => toggleTag(tag)}487 >488 {tag}489 </button>490 )}491 </For>492 </div>493 </Show>494495 <div496 class={`peach-vault-list${selecting() ? " is-selecting" : ""}`}497 ref={(el) => { listRef = el; setViewportHeight(el.clientHeight); }}498 onKeyDown={handleListKeydown}499 onScroll={handleListScroll}500 >501 <Show502 when={filteredEntries().length > 0}503 fallback={504 <div class="peach-list-empty">505 <span>no entries yet</span>506 </div>507 }508 >509 <Show when={isVirtualized()}>510 <div style={{ height: `${visibleRange().start * ROW_HEIGHT}px` }} aria-hidden="true" />511 </Show>512 <For each={windowedEntries()}>513 {(entry, localIndex) => {514 const index = () => visibleRange().start + localIndex();515 const isNote = entry.type === "note";516 const isPasskey = entry.passkey !== undefined;517 const domain = entry.type === "note"518 ? (entry.notes?.slice(0, 40) ?? "note")519 : entry.url520 ? getDisplayDomain(entry.url)521 : entry.passkey?.rpId522 ? entry.passkey.rpId523 : "saved entry";524 const isTabMatch = () =>525 props.currentTabDomain !== undefined &&526 props.currentTabDomain !== "" &&527 entryMatchesDomain(entry, props.currentTabDomain);528 const isSelected = () => selectedIds().has(entry.id);529530 const ariaLabel = (() => {531 const tag = isPasskey ? "passkey" : isNote ? "note" : "login";532 const sub = entry.username || domain || "";533 return sub534 ? `${entry.title} — ${sub} (${tag})`535 : `${entry.title} (${tag})`;536 })();537 return (538 <button539 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" : ""}`}540 style={isVirtualized()541 ? { animation: "none" }542 : { "animation-delay": `${Math.min(index(), 24) * 20}ms` }543 }544 aria-label={ariaLabel}545 aria-pressed={selecting() ? isSelected() : undefined}546 onTouchStart={(e) => handleRowTouchStart(entry, e)}547 onTouchMove={handleRowTouchMove}548 onTouchEnd={(e) => void handleRowTouchEnd(entry, e)}549 onTouchCancel={() => { clearLongPressTimer(); touchStart = null; }}550 onContextMenu={(e) => { e.preventDefault(); if (!selecting()) setContextEntry(entry); }}551 onClick={() => {552 if (suppressNextClick) { suppressNextClick = false; return; }553 if (selecting()) {554 toggleId(entry.id);555 } else {556 props.onSelectEntry(entry);557 }558 }}559 >560 <div class={`peach-entry-row-icon${!selecting() && entry.breach ? " is-breach" : ""}${selecting() ? " is-select-icon" : ""}`}>561 <Show562 when={selecting()}563 fallback={564 <FaviconIcon565 domain={entry.url ? getDisplayDomain(entry.url) : ""}566 title={entry.title}567 isNote={isNote}568 isPasskey={isPasskey}569 />570 }571 >572 {}573 <svg width="16" height="16" viewBox="0 0 16 16" fill="none">574 <circle575 cx="8" cy="8" r="7"576 stroke="currentColor"577 stroke-width="1"578 fill={isSelected() ? "var(--peach-accent)" : "transparent"}579 opacity={isSelected() ? "1" : "0.3"}580 />581 <Show when={isSelected()}>582 <path583 d="M5 8.5L7 10.5L11 6"584 stroke="white"585 stroke-width="1.2"586 stroke-linecap="round"587 stroke-linejoin="round"588 />589 </Show>590 </svg>591 </Show>592 </div>593 <div class="peach-entry-row-info">594 <span class="peach-entry-row-name">595 {entry.title}596 <Show when={isPasskey && !selecting()}>597 <span class="peach-passkey-badge">passkey</span>598 </Show>599 <Show when={isTabMatch() && !selecting()}>600 <span class="peach-tab-match-badge" title="Matches current tab">●</span>601 </Show>602 </span>603 <span class="peach-entry-row-meta">{domain}</span>604 </div>605 <Show when={entry.breach && !selecting()}>606 <span class="peach-entry-row-dot" aria-label="breach"></span>607 </Show>608 <Show when={entry.canary && !selecting()}>609 <span class="peach-entry-row-canary" title={`Canary: ${entry.canary?.label ?? "canary entry"}`}>🪤</span>610 </Show>611 </button>612 );613 }}614 </For>615 <Show when={isVirtualized()}>616 <div617 style={{ height: `${(filteredEntries().length - visibleRange().end) * ROW_HEIGHT}px` }}618 aria-hidden="true"619 />620 </Show>621 </Show>622 </div>623624 {}625 <Show626 when={selecting()}627 fallback={628 <button class="peach-new-entry-row" onClick={props.onAdd}>629 <svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round">630 <path d="M5 1v8M1 5h8" />631 </svg>632 new entry633 </button>634 }635 >636 <button637 class={`peach-select-action-bar${selectedIds().size === 0 ? " is-empty" : ""}`}638 disabled={selectedIds().size === 0 || deleting()}639 onClick={() => void handleDeleteSelected()}640 >641 <svg width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">642 <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />643 </svg>644 <Show645 when={selectedIds().size > 0}646 fallback={<span>select entries to delete</span>}647 >648 <span>649 {deleting()650 ? "moving to trash…"651 : `move ${selectedIds().size} to trash`}652 </span>653 </Show>654 </button>655 </Show>656657 <Show when={contextEntry() !== null}>658 <div659 class="peach-context-menu-backdrop"660 onClick={() => setContextEntry(null)}661 role="presentation"662 >663 <div664 class="peach-context-menu"665 role="menu"666 aria-label="Entry actions"667 onClick={(e) => e.stopPropagation()}668 >669 <Show when={contextEntry()!.username}>670 <button671 role="menuitem"672 class="peach-context-menu-item"673 onClick={async () => {674 const e = contextEntry();675 if (!e?.username) return;676 await popupCopy(e.username, "username");677 setContextEntry(null);678 }}679 >680 Copy username681 </button>682 </Show>683 <Show when={contextEntry()!.password}>684 <button685 role="menuitem"686 class="peach-context-menu-item"687 onClick={async () => {688 const e = contextEntry();689 if (!e?.password) return;690 await popupCopy(e.password, "password");691 setContextEntry(null);692 }}693 >694 Copy password695 </button>696 </Show>697 <Show when={contextEntry()!.totp}>698 <button699 role="menuitem"700 class="peach-context-menu-item"701 onClick={async () => {702 const e = contextEntry();703 if (!e?.totp) return;704 await popupCopy(e.totp, "TOTP secret");705 setContextEntry(null);706 }}707 >708 Copy TOTP secret709 </button>710 </Show>711 <button712 role="menuitem"713 class="peach-context-menu-item"714 onClick={() => {715 const e = contextEntry();716 if (e) props.onSelectEntry(e);717 setContextEntry(null);718 }}719 >720 Open721 </button>722 <Show when={props.onDeleteMany}>723 <button724 role="menuitem"725 class="peach-context-menu-item peach-context-menu-item--danger"726 onClick={async () => {727 const e = contextEntry();728 if (!e) return;729 setContextEntry(null);730 await props.onDeleteMany?.([e.id]);731 }}732 >733 Move to trash734 </button>735 </Show>736 <button737 role="menuitem"738 class="peach-context-menu-item peach-context-menu-item--cancel"739 onClick={() => setContextEntry(null)}740 >741 Cancel742 </button>743 </div>744 </div>745 </Show>746 </div>747 );748}