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 96 97 98 isNative?: boolean;99 onSelectEntry: (entry: VaultEntry) => void;100 onSortOrderChange: (order: SortOrder) => void;101 onAdd: () => void;102 onClose: () => void;103 onOpenTrash?: () => void;104 onDeleteMany?: (ids: string[]) => Promise<void>;105}106107export function VaultView(props: VaultProps) {108 const [search, setSearch] = createSignal("");109 const [selectedTags, setSelectedTags] = createSignal<Set<string>>(new Set());110 const [notesOnly, setNotesOnly] = createSignal(false);111 const [selecting, setSelecting] = createSignal(false);112 const [selectedIds, setSelectedIds] = createSignal<Set<string>>(new Set());113 const [deleting, setDeleting] = createSignal(false);114 const [contextEntry, setContextEntry] = createSignal<VaultEntry | null>(null);115 const [flashId, setFlashId] = createSignal<string | null>(null);116 const [scrollTop, setScrollTop] = createSignal(0);117 const [viewportHeight, setViewportHeight] = createSignal(600);118 let longPressTimer: number | null = null;119 let touchStart: { x: number; y: number; t: number } | null = null;120 let suppressNextClick = false;121122 function clearLongPressTimer() {123 if (longPressTimer !== null) {124 window.clearTimeout(longPressTimer);125 longPressTimer = null;126 }127 }128129 function handleRowTouchStart(entry: VaultEntry, e: TouchEvent) {130 if (selecting()) return;131 const t = e.touches[0];132 if (!t) return;133 touchStart = { x: t.clientX, y: t.clientY, t: Date.now() };134 clearLongPressTimer();135 longPressTimer = window.setTimeout(() => {136 suppressNextClick = true;137 138 139 140 141 142 enterSelectMode();143 setSelectedIds(new Set([entry.id]));144 vibrate(20);145 longPressTimer = null;146 }, 500);147 }148149 function handleRowTouchMove(e: TouchEvent) {150 if (!touchStart) return;151 const t = e.touches[0];152 if (!t) return;153 const dx = Math.abs(t.clientX - touchStart.x);154 const dy = Math.abs(t.clientY - touchStart.y);155 if (dx > 8 || dy > 8) clearLongPressTimer();156 }157158 async function handleRowTouchEnd(entry: VaultEntry, e: TouchEvent) {159 clearLongPressTimer();160 if (!touchStart) return;161 const t = e.changedTouches[0];162 if (!t) { touchStart = null; return; }163 const dx = t.clientX - touchStart.x;164 const dy = Math.abs(t.clientY - touchStart.y);165 const dt = Date.now() - touchStart.t;166 touchStart = null;167 168 const isCompact = !document.body.classList.contains("layout-medium")169 && !document.body.classList.contains("layout-expanded");170 if (171 isCompact172 && !selecting()173 && dx > 80174 && dy < 30175 && dt < 400176 && entry.type === "login"177 && entry.password178 ) {179 suppressNextClick = true;180 vibrate(15);181 setFlashId(entry.id);182 window.setTimeout(() => setFlashId((id) => id === entry.id ? null : id), 600);183 await popupCopy(entry.password, "password");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" classList={{ "peach-list-top--native": !!props.isNative }}>350 <Show351 when={!props.isNative}352 fallback={353 <span class="peach-search-icon" aria-hidden="true">354 <svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round">355 <circle cx="5.5" cy="5.5" r="3.75" />356 <path d="M8.75 8.75L11.5 11.5" />357 </svg>358 </span>359 }360 >361 <button362 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"363 onClick={props.onClose}364 aria-label="Back"365 >366 <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">367 <path d="M6 1L1 6L6 11" />368 </svg>369 </button>370 </Show>371 <input372 class="peach-search-input"373 placeholder="search vault..."374 autocomplete="off"375 style={props.isNative ? undefined : "font-size:14px"}376 ref={focusSearch}377 value={search()}378 onInput={(e) => setSearch((e.currentTarget as HTMLInputElement).value)}379 onKeyDown={handleSearchKeydown}380 />381 <span class="peach-search-counter peach-search-counter-list">382 {search() || selectedTags().size > 0383 ? `${filteredEntries().length} of ${props.entries.length}`384 : props.isNative385 ? `${props.entries.length}`386 : `${props.entries.length} entries`}387 </span>388 </div>389390 {}391 <div class="peach-sort-bar">392 <Show393 when={selecting()}394 fallback={395 <>396 <For each={VAULT_SORT_OPTIONS}>397 {({ key, label }) => (398 <button399 class={`peach-sort-btn${props.sortOrder === key ? " is-active" : ""}`}400 onClick={() => props.onSortOrderChange(key)}401 >402 {label}403 </button>404 )}405 </For>406 <span style="flex:1" />407 <button408 class={`peach-sort-trash-btn${notesOnly() ? " is-active" : ""}`}409 onClick={() => setNotesOnly(v => !v)}410 aria-label="Notes only"411 title="Show notes only"412 >413 <NoteIcon />414 </button>415 <Show when={props.onOpenTrash}>416 <button417 class="peach-sort-trash-btn"418 onClick={props.onOpenTrash}419 aria-label="View trash"420 title="View deleted entries"421 >422 <svg width="11" height="12" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">423 <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />424 </svg>425 </button>426 </Show>427 <Show when={props.onDeleteMany}>428 <button429 class="peach-sort-trash-btn"430 onClick={enterSelectMode}431 aria-label="Select entries"432 title="Select entries"433 >434 <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">435 <circle cx="4" cy="4" r="2.5" />436 <circle cx="9" cy="4" r="2.5" />437 <circle cx="4" cy="9" r="2.5" />438 <circle cx="9" cy="9" r="2.5" />439 </svg>440 </button>441 </Show>442 </>443 }444 >445 {}446 <button447 class="peach-select-cancel-btn"448 onClick={exitSelectMode}449 >450 cancel451 </button>452 <span class="peach-select-count-label">453 {selectedIds().size > 0 ? `${selectedIds().size} selected` : "select entries"}454 </span>455 <button456 class="peach-select-all-btn"457 onClick={toggleSelectAll}458 >459 {allSelected() ? "none" : "all"}460 </button>461 </Show>462 </div>463464 <Show when={hasTags() && !selecting()}>465 <div class="peach-tag-filter-strip" style="display:flex;gap:6px;padding:4px 12px 6px;overflow-x:auto;flex-wrap:nowrap;scrollbar-width:none;">466 <For each={allTags()}>467 {(tag) => (468 <button469 class={`peach-tag-filter-chip ${selectedTags().has(tag) ? "is-selected" : ""}`}470 onClick={() => toggleTag(tag)}471 >472 {tag}473 </button>474 )}475 </For>476 </div>477 </Show>478479 <div480 class={`peach-vault-list${selecting() ? " is-selecting" : ""}`}481 ref={(el) => { listRef = el; setViewportHeight(el.clientHeight); }}482 onKeyDown={handleListKeydown}483 onScroll={handleListScroll}484 >485 <Show486 when={filteredEntries().length > 0}487 fallback={488 <div class="peach-list-empty">489 <span>no entries yet</span>490 </div>491 }492 >493 <Show when={isVirtualized()}>494 <div style={{ height: `${visibleRange().start * ROW_HEIGHT}px` }} aria-hidden="true" />495 </Show>496 <For each={windowedEntries()}>497 {(entry, localIndex) => {498 const index = () => visibleRange().start + localIndex();499 const isNote = entry.type === "note";500 const isPasskey = entry.passkey !== undefined;501 const domain = entry.type === "note"502 ? (entry.notes?.slice(0, 40) ?? "note")503 : entry.url504 ? getDisplayDomain(entry.url)505 : entry.passkey?.rpId506 ? entry.passkey.rpId507 : "saved entry";508 const isTabMatch = () =>509 props.currentTabDomain !== undefined &&510 props.currentTabDomain !== "" &&511 entryMatchesDomain(entry, props.currentTabDomain);512 const isSelected = () => selectedIds().has(entry.id);513514 const ariaLabel = (() => {515 const tag = isPasskey ? "passkey" : isNote ? "note" : "login";516 const sub = entry.username || domain || "";517 return sub518 ? `${entry.title} — ${sub} (${tag})`519 : `${entry.title} (${tag})`;520 })();521 return (522 <button523 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" : ""}`}524 style={isVirtualized()525 ? { animation: "none" }526 : { "animation-delay": `${Math.min(index(), 24) * 20}ms` }527 }528 aria-label={ariaLabel}529 aria-pressed={selecting() ? isSelected() : undefined}530 onTouchStart={(e) => handleRowTouchStart(entry, e)}531 onTouchMove={handleRowTouchMove}532 onTouchEnd={(e) => void handleRowTouchEnd(entry, e)}533 onTouchCancel={() => { clearLongPressTimer(); touchStart = null; }}534 onContextMenu={(e) => {535 e.preventDefault();536 537 538 539 540 541 if (touchStart !== null) { touchStart = null; return; }542 if (!selecting()) setContextEntry(entry);543 }}544 onClick={() => {545 if (suppressNextClick) { suppressNextClick = false; return; }546 if (selecting()) {547 toggleId(entry.id);548 } else {549 props.onSelectEntry(entry);550 }551 }}552 >553 <div class={`peach-entry-row-icon${!selecting() && entry.breach ? " is-breach" : ""}${selecting() ? " is-select-icon" : ""}`}>554 <Show555 when={selecting()}556 fallback={557 <FaviconIcon558 domain={entry.type === "note" ? "" : (entry.url ? getDisplayDomain(entry.url) : entry.passkey?.rpId ?? "")}559 title={entry.title}560 isNote={isNote}561 isPasskey={isPasskey}562 />563 }564 >565 {}566 <svg width="16" height="16" viewBox="0 0 16 16" fill="none">567 <circle568 cx="8" cy="8" r="7"569 stroke="currentColor"570 stroke-width="1"571 fill={isSelected() ? "var(--peach-accent)" : "transparent"}572 opacity={isSelected() ? "1" : "0.3"}573 />574 <Show when={isSelected()}>575 <path576 d="M5 8.5L7 10.5L11 6"577 stroke="white"578 stroke-width="1.2"579 stroke-linecap="round"580 stroke-linejoin="round"581 />582 </Show>583 </svg>584 </Show>585 </div>586 <div class="peach-entry-row-info">587 <span class="peach-entry-row-name">588 {entry.title}589 <Show when={isPasskey && !selecting()}>590 <span class="peach-passkey-badge">passkey</span>591 </Show>592 <Show when={isTabMatch() && !selecting()}>593 <span class="peach-tab-match-badge" title="Matches current tab">●</span>594 </Show>595 </span>596 <span class="peach-entry-row-meta">{domain}</span>597 </div>598 <Show when={entry.breach && !selecting()}>599 <span class="peach-entry-row-dot" aria-label="breach"></span>600 </Show>601 <Show when={entry.canary && !selecting()}>602 <span class="peach-entry-row-canary" title={`Canary: ${entry.canary?.label ?? "canary entry"}`}>🪤</span>603 </Show>604 </button>605 );606 }}607 </For>608 <Show when={isVirtualized()}>609 <div610 style={{ height: `${(filteredEntries().length - visibleRange().end) * ROW_HEIGHT}px` }}611 aria-hidden="true"612 />613 </Show>614 </Show>615 </div>616617 {}618 <Show619 when={selecting()}620 fallback={621 <button class="peach-new-entry-row" onClick={props.onAdd}>622 <svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round">623 <path d="M5 1v8M1 5h8" />624 </svg>625 new entry626 </button>627 }628 >629 <button630 class={`peach-select-action-bar${selectedIds().size === 0 ? " is-empty" : ""}`}631 disabled={selectedIds().size === 0 || deleting()}632 onClick={() => void handleDeleteSelected()}633 >634 <svg width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">635 <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />636 </svg>637 <Show638 when={selectedIds().size > 0}639 fallback={<span>select entries to delete</span>}640 >641 <span>642 {deleting()643 ? "moving to trash…"644 : `move ${selectedIds().size} to trash`}645 </span>646 </Show>647 </button>648 </Show>649650 <Show when={contextEntry() !== null}>651 <div652 class="peach-context-menu-backdrop"653 onClick={() => setContextEntry(null)}654 role="presentation"655 >656 <div657 class="peach-context-menu"658 role="menu"659 aria-label="Entry actions"660 onClick={(e) => e.stopPropagation()}661 >662 <Show when={contextEntry()!.username}>663 <button664 role="menuitem"665 class="peach-context-menu-item"666 onClick={async () => {667 const e = contextEntry();668 if (!e?.username) return;669 await popupCopy(e.username, "username");670 setContextEntry(null);671 }}672 >673 Copy username674 </button>675 </Show>676 <Show when={contextEntry()!.password}>677 <button678 role="menuitem"679 class="peach-context-menu-item"680 onClick={async () => {681 const e = contextEntry();682 if (!e?.password) return;683 await popupCopy(e.password, "password");684 setContextEntry(null);685 }}686 >687 Copy password688 </button>689 </Show>690 <Show when={contextEntry()!.totp}>691 <button692 role="menuitem"693 class="peach-context-menu-item"694 onClick={async () => {695 const e = contextEntry();696 if (!e?.totp) return;697 await popupCopy(e.totp, "TOTP secret");698 setContextEntry(null);699 }}700 >701 Copy TOTP secret702 </button>703 </Show>704 <button705 role="menuitem"706 class="peach-context-menu-item"707 onClick={() => {708 const e = contextEntry();709 if (e) props.onSelectEntry(e);710 setContextEntry(null);711 }}712 >713 Open714 </button>715 <Show when={props.onDeleteMany}>716 <button717 role="menuitem"718 class="peach-context-menu-item peach-context-menu-item--danger"719 onClick={async () => {720 const e = contextEntry();721 if (!e) return;722 setContextEntry(null);723 await props.onDeleteMany?.([e.id]);724 }}725 >726 Move to trash727 </button>728 </Show>729 <button730 role="menuitem"731 class="peach-context-menu-item peach-context-menu-item--cancel"732 onClick={() => setContextEntry(null)}733 >734 Cancel735 </button>736 </div>737 </div>738 </Show>739 </div>740 );741}