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.isNote && !props.isPasskey21 ? faviconUrl(props.domain)22 : null;23 const [isReal, setIsReal] = createSignal(false);2425 26 27 28 return (29 <Show30 when={isReal()}31 fallback={32 <>33 {props.isPasskey34 ? <PasskeyIcon />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 PasskeyIcon = () => (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 <circle cx="4" cy="4.5" r="3" />84 <path d="M6.5 7l3.5 3.5M8.5 8.5L10 10" />85 </svg>86);8788const NoteIcon = () => (89 <svg width="11" height="12" viewBox="0 0 11 12" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round" aria-hidden="true">90 <rect x="1.5" y="1.5" width="8" height="9" rx="1.5" />91 <path d="M3.5 4.5h4M3.5 6.5h4M3.5 8.5h2" />92 </svg>93);9495interface VaultProps {96 entries: VaultEntry[];97 selectedEntryId?: string;98 currentTabDomain?: string;99 sortOrder: SortOrder;100 searchSeed?: string;101 searchSeedNonce?: number;102 103 104 105 isNative?: boolean;106 onSelectEntry: (entry: VaultEntry) => void;107 onSortOrderChange: (order: SortOrder) => void;108 onAdd: () => void;109 onClose: () => void;110 onOpenTrash?: () => void;111 onDeleteMany?: (ids: string[]) => Promise<void>;112}113114export function VaultView(props: VaultProps) {115 const [search, setSearch] = createSignal("");116 const [selectedTags, setSelectedTags] = createSignal<Set<string>>(new Set());117 const [notesOnly, setNotesOnly] = createSignal(false);118 const [selecting, setSelecting] = createSignal(false);119 const [selectedIds, setSelectedIds] = createSignal<Set<string>>(new Set());120 const [deleting, setDeleting] = createSignal(false);121 const [contextEntry, setContextEntry] = createSignal<VaultEntry | null>(null);122 const [flashId, setFlashId] = createSignal<string | null>(null);123 const [scrollTop, setScrollTop] = createSignal(0);124 const [viewportHeight, setViewportHeight] = createSignal(600);125 let longPressTimer: number | null = null;126 let touchStart: { x: number; y: number; t: number } | null = null;127 let suppressNextClick = false;128129 function clearLongPressTimer() {130 if (longPressTimer !== null) {131 window.clearTimeout(longPressTimer);132 longPressTimer = null;133 }134 }135136 function handleRowTouchStart(entry: VaultEntry, e: TouchEvent) {137 if (selecting()) return;138 const t = e.touches[0];139 if (!t) return;140 touchStart = { x: t.clientX, y: t.clientY, t: Date.now() };141 clearLongPressTimer();142 longPressTimer = window.setTimeout(() => {143 suppressNextClick = true;144 vibrate(20);145 setContextEntry(entry);146 longPressTimer = null;147 }, 500);148 }149150 function handleRowTouchMove(e: TouchEvent) {151 if (!touchStart) return;152 const t = e.touches[0];153 if (!t) return;154 const dx = Math.abs(t.clientX - touchStart.x);155 const dy = Math.abs(t.clientY - touchStart.y);156 if (dx > 8 || dy > 8) clearLongPressTimer();157 }158159 async function handleRowTouchEnd(entry: VaultEntry, e: TouchEvent) {160 clearLongPressTimer();161 if (!touchStart) return;162 const t = e.changedTouches[0];163 if (!t) { touchStart = null; return; }164 const dx = t.clientX - touchStart.x;165 const dy = Math.abs(t.clientY - touchStart.y);166 const dt = Date.now() - touchStart.t;167 touchStart = null;168 169 const isCompact = !document.body.classList.contains("layout-medium")170 && !document.body.classList.contains("layout-expanded");171 if (172 isCompact173 && !selecting()174 && dx > 80175 && dy < 30176 && dt < 400177 && entry.type === "login"178 && entry.password179 ) {180 suppressNextClick = true;181 vibrate(15);182 setFlashId(entry.id);183 window.setTimeout(() => setFlashId((id) => id === entry.id ? null : id), 600);184 await popupCopy(entry.password, "password");185 }186 }187188 let searchRef: HTMLInputElement | undefined;189 let listRef: HTMLDivElement | undefined;190191 const focusSearch = (el: HTMLInputElement) => {192 searchRef = el;193 setTimeout(() => el.focus(), 50);194 };195196 createEffect(() => {197 void props.searchSeedNonce;198 const seed = props.searchSeed;199 if (!seed) return;200 setSearch(seed);201 requestAnimationFrame(() => {202 searchRef?.focus();203 searchRef?.setSelectionRange(seed.length, seed.length);204 });205 });206207 function enterSelectMode() {208 setSelecting(true);209 setSelectedIds(new Set<string>());210 }211212 function exitSelectMode() {213 setSelecting(false);214 setSelectedIds(new Set<string>());215 }216217 function toggleId(id: string) {218 const s = new Set<string>(selectedIds());219 if (s.has(id)) s.delete(id); else s.add(id);220 setSelectedIds(s);221 }222223 const allTags = createMemo(() => {224 const tagSet = new Set<string>();225 for (const entry of props.entries) {226 for (const tag of entry.tags ?? []) {227 tagSet.add(tag);228 }229 }230 return Array.from(tagSet).sort();231 });232233 const hasTags = createMemo(() => allTags().length > 0);234235 function toggleTag(tag: string) {236 const current = new Set(selectedTags());237 if (current.has(tag)) {238 current.delete(tag);239 } else {240 current.add(tag);241 }242 setSelectedTags(current);243 }244245 const filteredEntries = createMemo(() => {246 return filterAndSortVaultEntries(props.entries, {247 notesOnly: notesOnly(),248 selectedTags: selectedTags(),249 query: search(),250 searchScope: "both",251 sortOrder: props.sortOrder,252 currentTabDomain: props.currentTabDomain,253 });254 });255256 257 258 259 260 const allVisibleIds = createMemo(() => filteredEntries().map(e => e.id));261 const allSelected = createMemo(() =>262 allVisibleIds().length > 0 && allVisibleIds().every(id => selectedIds().has(id))263 );264265 function toggleSelectAll() {266 if (allSelected()) {267 setSelectedIds(new Set<string>());268 } else {269 setSelectedIds(new Set<string>(allVisibleIds()));270 }271 }272273 async function handleDeleteSelected() {274 const ids = Array.from(selectedIds());275 if (!ids.length || !props.onDeleteMany) return;276 setDeleting(true);277 await props.onDeleteMany(ids);278 setDeleting(false);279 exitSelectMode();280 }281282 283 284 285 286 287 const VIRTUAL_THRESHOLD = 100;288 const ROW_HEIGHT = 56;289 const BUFFER_ROWS = 20;290291 const isVirtualized = createMemo(() => filteredEntries().length > VIRTUAL_THRESHOLD);292293 const visibleRange = createMemo(() => {294 const total = filteredEntries().length;295 if (!isVirtualized()) return { start: 0, end: total };296 const start = Math.max(0, Math.floor(scrollTop() / ROW_HEIGHT) - BUFFER_ROWS);297 const end = Math.min(total, Math.ceil((scrollTop() + viewportHeight()) / ROW_HEIGHT) + BUFFER_ROWS);298 return { start, end };299 });300301 const windowedEntries = createMemo(() => {302 const { start, end } = visibleRange();303 return filteredEntries().slice(start, end);304 });305306 function handleListScroll(e: Event) {307 if (!isVirtualized()) return;308 const target = e.currentTarget as HTMLDivElement;309 setScrollTop(target.scrollTop);310 setViewportHeight(target.clientHeight);311 }312313 function handleSearchKeydown(e: KeyboardEvent) {314 if (e.key === "ArrowDown") {315 e.preventDefault();316 const firstBtn = listRef?.querySelector<HTMLButtonElement>(".peach-entry-row");317 firstBtn?.focus();318 } else if (e.key === "ArrowUp") {319 e.preventDefault();320 const allBtns = listRef?.querySelectorAll<HTMLButtonElement>(".peach-entry-row");321 allBtns?.[allBtns.length - 1]?.focus();322 } else if (e.key === "Enter") {323 e.preventDefault();324 const firstEntry = filteredEntries()[0];325 if (firstEntry) props.onSelectEntry(firstEntry);326 } else if (e.key === "Escape" && selecting()) {327 exitSelectMode();328 }329 }330331 function handleListKeydown(e: KeyboardEvent) {332 if (!listRef) return;333 const btns = Array.from(listRef.querySelectorAll<HTMLButtonElement>(".peach-entry-row"));334 const idx = btns.indexOf(document.activeElement as HTMLButtonElement);335 if (e.key === "ArrowDown") {336 e.preventDefault();337 if (idx < btns.length - 1) btns[idx + 1]?.focus();338 } else if (e.key === "ArrowUp") {339 e.preventDefault();340 if (idx === 0) { searchRef?.focus(); }341 else btns[idx - 1]?.focus();342 } else if (e.key === "Escape") {343 if (selecting()) exitSelectMode();344 else props.onClose();345 }346 }347348 return (349 <div class="peach-overlay-view ov-enter-up" style="display:flex">350 <div class="peach-list-top" classList={{ "peach-list-top--native": !!props.isNative }}>351 <Show352 when={!props.isNative}353 fallback={354 <span class="peach-search-icon" aria-hidden="true">355 <svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round">356 <circle cx="5.5" cy="5.5" r="3.75" />357 <path d="M8.75 8.75L11.5 11.5" />358 </svg>359 </span>360 }361 >362 <button363 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"364 onClick={props.onClose}365 aria-label="Back"366 >367 <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">368 <path d="M6 1L1 6L6 11" />369 </svg>370 </button>371 </Show>372 <input373 class="peach-search-input"374 placeholder="search vault..."375 autocomplete="off"376 style={props.isNative ? undefined : "font-size:14px"}377 ref={focusSearch}378 value={search()}379 onInput={(e) => setSearch((e.currentTarget as HTMLInputElement).value)}380 onKeyDown={handleSearchKeydown}381 />382 <span class="peach-search-counter peach-search-counter-list">383 {search() || selectedTags().size > 0384 ? `${filteredEntries().length} of ${props.entries.length}`385 : props.isNative386 ? `${props.entries.length}`387 : `${props.entries.length} entries`}388 </span>389 </div>390391 {}392 <div class="peach-sort-bar">393 <Show394 when={selecting()}395 fallback={396 <>397 <For each={VAULT_SORT_OPTIONS}>398 {({ key, label }) => (399 <button400 class={`peach-sort-btn${props.sortOrder === key ? " is-active" : ""}`}401 onClick={() => props.onSortOrderChange(key)}402 >403 {label}404 </button>405 )}406 </For>407 <span style="flex:1" />408 <button409 class={`peach-sort-trash-btn${notesOnly() ? " is-active" : ""}`}410 onClick={() => setNotesOnly(v => !v)}411 aria-label="Notes only"412 title="Show notes only"413 >414 <NoteIcon />415 </button>416 <Show when={props.onOpenTrash}>417 <button418 class="peach-sort-trash-btn"419 onClick={props.onOpenTrash}420 aria-label="View trash"421 title="View deleted entries"422 >423 <svg width="11" height="12" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">424 <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />425 </svg>426 </button>427 </Show>428 <Show when={props.onDeleteMany}>429 <button430 class="peach-sort-trash-btn"431 onClick={enterSelectMode}432 aria-label="Select entries"433 title="Select entries"434 >435 <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">436 <circle cx="4" cy="4" r="2.5" />437 <circle cx="9" cy="4" r="2.5" />438 <circle cx="4" cy="9" r="2.5" />439 <circle cx="9" cy="9" r="2.5" />440 </svg>441 </button>442 </Show>443 </>444 }445 >446 {}447 <button448 class="peach-select-cancel-btn"449 onClick={exitSelectMode}450 >451 cancel452 </button>453 <span class="peach-select-count-label">454 {selectedIds().size > 0 ? `${selectedIds().size} selected` : "select entries"}455 </span>456 <button457 class="peach-select-all-btn"458 onClick={toggleSelectAll}459 >460 {allSelected() ? "none" : "all"}461 </button>462 </Show>463 </div>464465 <Show when={hasTags() && !selecting()}>466 <div class="peach-tag-filter-strip" style="display:flex;gap:6px;padding:4px 12px 6px;overflow-x:auto;flex-wrap:nowrap;scrollbar-width:none;">467 <For each={allTags()}>468 {(tag) => (469 <button470 class={`peach-tag-filter-chip ${selectedTags().has(tag) ? "is-selected" : ""}`}471 onClick={() => toggleTag(tag)}472 >473 {tag}474 </button>475 )}476 </For>477 </div>478 </Show>479480 <div481 class={`peach-vault-list${selecting() ? " is-selecting" : ""}`}482 ref={(el) => { listRef = el; setViewportHeight(el.clientHeight); }}483 onKeyDown={handleListKeydown}484 onScroll={handleListScroll}485 >486 <Show487 when={filteredEntries().length > 0}488 fallback={489 <div class="peach-list-empty">490 <span>no entries yet</span>491 </div>492 }493 >494 <Show when={isVirtualized()}>495 <div style={{ height: `${visibleRange().start * ROW_HEIGHT}px` }} aria-hidden="true" />496 </Show>497 <For each={windowedEntries()}>498 {(entry, localIndex) => {499 const index = () => visibleRange().start + localIndex();500 const isNote = entry.type === "note";501 const isPasskey = entry.passkey !== undefined;502 const domain = entry.type === "note"503 ? (entry.notes?.slice(0, 40) ?? "note")504 : entry.url505 ? getDisplayDomain(entry.url)506 : entry.passkey?.rpId507 ? entry.passkey.rpId508 : "saved entry";509 const isTabMatch = () =>510 props.currentTabDomain !== undefined &&511 props.currentTabDomain !== "" &&512 entryMatchesDomain(entry, props.currentTabDomain);513 const isSelected = () => selectedIds().has(entry.id);514515 const ariaLabel = (() => {516 const tag = isPasskey ? "passkey" : isNote ? "note" : "login";517 const sub = entry.username || domain || "";518 return sub519 ? `${entry.title} — ${sub} (${tag})`520 : `${entry.title} (${tag})`;521 })();522 return (523 <button524 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" : ""}`}525 style={isVirtualized()526 ? { animation: "none" }527 : { "animation-delay": `${Math.min(index(), 24) * 20}ms` }528 }529 aria-label={ariaLabel}530 aria-pressed={selecting() ? isSelected() : undefined}531 onTouchStart={(e) => handleRowTouchStart(entry, e)}532 onTouchMove={handleRowTouchMove}533 onTouchEnd={(e) => void handleRowTouchEnd(entry, e)}534 onTouchCancel={() => { clearLongPressTimer(); touchStart = null; }}535 onContextMenu={(e) => { e.preventDefault(); if (!selecting()) setContextEntry(entry); }}536 onClick={() => {537 if (suppressNextClick) { suppressNextClick = false; return; }538 if (selecting()) {539 toggleId(entry.id);540 } else {541 props.onSelectEntry(entry);542 }543 }}544 >545 <div class={`peach-entry-row-icon${!selecting() && entry.breach ? " is-breach" : ""}${selecting() ? " is-select-icon" : ""}`}>546 <Show547 when={selecting()}548 fallback={549 <FaviconIcon550 domain={entry.url ? getDisplayDomain(entry.url) : ""}551 title={entry.title}552 isNote={isNote}553 isPasskey={isPasskey}554 />555 }556 >557 {}558 <svg width="16" height="16" viewBox="0 0 16 16" fill="none">559 <circle560 cx="8" cy="8" r="7"561 stroke="currentColor"562 stroke-width="1"563 fill={isSelected() ? "var(--peach-accent)" : "transparent"}564 opacity={isSelected() ? "1" : "0.3"}565 />566 <Show when={isSelected()}>567 <path568 d="M5 8.5L7 10.5L11 6"569 stroke="white"570 stroke-width="1.2"571 stroke-linecap="round"572 stroke-linejoin="round"573 />574 </Show>575 </svg>576 </Show>577 </div>578 <div class="peach-entry-row-info">579 <span class="peach-entry-row-name">580 {entry.title}581 <Show when={isPasskey && !selecting()}>582 <span class="peach-passkey-badge">passkey</span>583 </Show>584 <Show when={isTabMatch() && !selecting()}>585 <span class="peach-tab-match-badge" title="Matches current tab">●</span>586 </Show>587 </span>588 <span class="peach-entry-row-meta">{domain}</span>589 </div>590 <Show when={entry.breach && !selecting()}>591 <span class="peach-entry-row-dot" aria-label="breach"></span>592 </Show>593 <Show when={entry.canary && !selecting()}>594 <span class="peach-entry-row-canary" title={`Canary: ${entry.canary?.label ?? "canary entry"}`}>🪤</span>595 </Show>596 </button>597 );598 }}599 </For>600 <Show when={isVirtualized()}>601 <div602 style={{ height: `${(filteredEntries().length - visibleRange().end) * ROW_HEIGHT}px` }}603 aria-hidden="true"604 />605 </Show>606 </Show>607 </div>608609 {}610 <Show611 when={selecting()}612 fallback={613 <button class="peach-new-entry-row" onClick={props.onAdd}>614 <svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round">615 <path d="M5 1v8M1 5h8" />616 </svg>617 new entry618 </button>619 }620 >621 <button622 class={`peach-select-action-bar${selectedIds().size === 0 ? " is-empty" : ""}`}623 disabled={selectedIds().size === 0 || deleting()}624 onClick={() => void handleDeleteSelected()}625 >626 <svg width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">627 <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />628 </svg>629 <Show630 when={selectedIds().size > 0}631 fallback={<span>select entries to delete</span>}632 >633 <span>634 {deleting()635 ? "moving to trash…"636 : `move ${selectedIds().size} to trash`}637 </span>638 </Show>639 </button>640 </Show>641642 <Show when={contextEntry() !== null}>643 <div644 class="peach-context-menu-backdrop"645 onClick={() => setContextEntry(null)}646 role="presentation"647 >648 <div649 class="peach-context-menu"650 role="menu"651 aria-label="Entry actions"652 onClick={(e) => e.stopPropagation()}653 >654 <Show when={contextEntry()!.username}>655 <button656 role="menuitem"657 class="peach-context-menu-item"658 onClick={async () => {659 const e = contextEntry();660 if (!e?.username) return;661 await popupCopy(e.username, "username");662 setContextEntry(null);663 }}664 >665 Copy username666 </button>667 </Show>668 <Show when={contextEntry()!.password}>669 <button670 role="menuitem"671 class="peach-context-menu-item"672 onClick={async () => {673 const e = contextEntry();674 if (!e?.password) return;675 await popupCopy(e.password, "password");676 setContextEntry(null);677 }}678 >679 Copy password680 </button>681 </Show>682 <Show when={contextEntry()!.totp}>683 <button684 role="menuitem"685 class="peach-context-menu-item"686 onClick={async () => {687 const e = contextEntry();688 if (!e?.totp) return;689 await popupCopy(e.totp, "TOTP secret");690 setContextEntry(null);691 }}692 >693 Copy TOTP secret694 </button>695 </Show>696 <button697 role="menuitem"698 class="peach-context-menu-item"699 onClick={() => {700 const e = contextEntry();701 if (e) props.onSelectEntry(e);702 setContextEntry(null);703 }}704 >705 Open706 </button>707 <Show when={props.onDeleteMany}>708 <button709 role="menuitem"710 class="peach-context-menu-item peach-context-menu-item--danger"711 onClick={async () => {712 const e = contextEntry();713 if (!e) return;714 setContextEntry(null);715 await props.onDeleteMany?.([e.id]);716 }}717 >718 Move to trash719 </button>720 </Show>721 <button722 role="menuitem"723 class="peach-context-menu-item peach-context-menu-item--cancel"724 onClick={() => setContextEntry(null)}725 >726 Cancel727 </button>728 </div>729 </div>730 </Show>731 </div>732 );733}