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(entry.password, "password");180 }181 }182183 let searchRef: HTMLInputElement | undefined;184 let listRef: HTMLDivElement | undefined;185186 const focusSearch = (el: HTMLInputElement) => {187 searchRef = el;188 setTimeout(() => el.focus(), 50);189 };190191 createEffect(() => {192 void props.searchSeedNonce;193 const seed = props.searchSeed;194 if (!seed) return;195 setSearch(seed);196 requestAnimationFrame(() => {197 searchRef?.focus();198 searchRef?.setSelectionRange(seed.length, seed.length);199 });200 });201202 function enterSelectMode() {203 setSelecting(true);204 setSelectedIds(new Set<string>());205 }206207 function exitSelectMode() {208 setSelecting(false);209 setSelectedIds(new Set<string>());210 }211212 function toggleId(id: string) {213 const s = new Set<string>(selectedIds());214 if (s.has(id)) s.delete(id); else s.add(id);215 setSelectedIds(s);216 }217218 const allTags = createMemo(() => {219 const tagSet = new Set<string>();220 for (const entry of props.entries) {221 for (const tag of entry.tags ?? []) {222 tagSet.add(tag);223 }224 }225 return Array.from(tagSet).sort();226 });227228 const hasTags = createMemo(() => allTags().length > 0);229230 function toggleTag(tag: string) {231 const current = new Set(selectedTags());232 if (current.has(tag)) {233 current.delete(tag);234 } else {235 current.add(tag);236 }237 setSelectedTags(current);238 }239240 const filteredEntries = createMemo(() => {241 return filterAndSortVaultEntries(props.entries, {242 notesOnly: notesOnly(),243 selectedTags: selectedTags(),244 query: search(),245 searchScope: "both",246 sortOrder: props.sortOrder,247 currentTabDomain: props.currentTabDomain,248 });249 });250251 252 253 254 255 const allVisibleIds = createMemo(() => filteredEntries().map(e => e.id));256 const allSelected = createMemo(() =>257 allVisibleIds().length > 0 && allVisibleIds().every(id => selectedIds().has(id))258 );259260 function toggleSelectAll() {261 if (allSelected()) {262 setSelectedIds(new Set<string>());263 } else {264 setSelectedIds(new Set<string>(allVisibleIds()));265 }266 }267268 async function handleDeleteSelected() {269 const ids = Array.from(selectedIds());270 if (!ids.length || !props.onDeleteMany) return;271 setDeleting(true);272 await props.onDeleteMany(ids);273 setDeleting(false);274 exitSelectMode();275 }276277 278 279 280 281 282 const VIRTUAL_THRESHOLD = 100;283 const ROW_HEIGHT = 56;284 const BUFFER_ROWS = 20;285286 const isVirtualized = createMemo(() => filteredEntries().length > VIRTUAL_THRESHOLD);287288 const visibleRange = createMemo(() => {289 const total = filteredEntries().length;290 if (!isVirtualized()) return { start: 0, end: total };291 const start = Math.max(0, Math.floor(scrollTop() / ROW_HEIGHT) - BUFFER_ROWS);292 const end = Math.min(total, Math.ceil((scrollTop() + viewportHeight()) / ROW_HEIGHT) + BUFFER_ROWS);293 return { start, end };294 });295296 const windowedEntries = createMemo(() => {297 const { start, end } = visibleRange();298 return filteredEntries().slice(start, end);299 });300301 function handleListScroll(e: Event) {302 if (!isVirtualized()) return;303 const target = e.currentTarget as HTMLDivElement;304 setScrollTop(target.scrollTop);305 setViewportHeight(target.clientHeight);306 }307308 function handleSearchKeydown(e: KeyboardEvent) {309 if (e.key === "ArrowDown") {310 e.preventDefault();311 const firstBtn = listRef?.querySelector<HTMLButtonElement>(".peach-entry-row");312 firstBtn?.focus();313 } else if (e.key === "ArrowUp") {314 e.preventDefault();315 const allBtns = listRef?.querySelectorAll<HTMLButtonElement>(".peach-entry-row");316 allBtns?.[allBtns.length - 1]?.focus();317 } else if (e.key === "Enter") {318 e.preventDefault();319 const firstEntry = filteredEntries()[0];320 if (firstEntry) props.onSelectEntry(firstEntry);321 } else if (e.key === "Escape" && selecting()) {322 exitSelectMode();323 }324 }325326 function handleListKeydown(e: KeyboardEvent) {327 if (!listRef) return;328 const btns = Array.from(listRef.querySelectorAll<HTMLButtonElement>(".peach-entry-row"));329 const idx = btns.indexOf(document.activeElement as HTMLButtonElement);330 if (e.key === "ArrowDown") {331 e.preventDefault();332 if (idx < btns.length - 1) btns[idx + 1]?.focus();333 } else if (e.key === "ArrowUp") {334 e.preventDefault();335 if (idx === 0) { searchRef?.focus(); }336 else btns[idx - 1]?.focus();337 } else if (e.key === "Escape") {338 if (selecting()) exitSelectMode();339 else props.onClose();340 }341 }342343 return (344 <div class="peach-overlay-view ov-enter-up" style="display:flex">345 <div class="peach-list-top">346 <button347 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"348 onClick={props.onClose}349 aria-label="Back"350 >351 <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">352 <path d="M6 1L1 6L6 11" />353 </svg>354 </button>355 <input356 class="peach-search-input"357 placeholder="search vault..."358 autocomplete="off"359 style="font-size:14px"360 ref={focusSearch}361 value={search()}362 onInput={(e) => setSearch((e.currentTarget as HTMLInputElement).value)}363 onKeyDown={handleSearchKeydown}364 />365 <span class="peach-search-counter peach-search-counter-list">366 {search() || selectedTags().size > 0367 ? `${filteredEntries().length} of ${props.entries.length}`368 : `${props.entries.length} entries`}369 </span>370 </div>371372 {}373 <div class="peach-sort-bar">374 <Show375 when={selecting()}376 fallback={377 <>378 <For each={VAULT_SORT_OPTIONS}>379 {({ key, label }) => (380 <button381 class={`peach-sort-btn${props.sortOrder === key ? " is-active" : ""}`}382 onClick={() => props.onSortOrderChange(key)}383 >384 {label}385 </button>386 )}387 </For>388 <span style="flex:1" />389 <button390 class={`peach-sort-trash-btn${notesOnly() ? " is-active" : ""}`}391 onClick={() => setNotesOnly(v => !v)}392 aria-label="Notes only"393 title="Show notes only"394 >395 <NoteIcon />396 </button>397 <Show when={props.onOpenTrash}>398 <button399 class="peach-sort-trash-btn"400 onClick={props.onOpenTrash}401 aria-label="View trash"402 title="View deleted entries"403 >404 <svg width="11" height="12" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">405 <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />406 </svg>407 </button>408 </Show>409 <Show when={props.onDeleteMany}>410 <button411 class="peach-sort-trash-btn"412 onClick={enterSelectMode}413 aria-label="Select entries"414 title="Select entries"415 >416 <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">417 <circle cx="4" cy="4" r="2.5" />418 <circle cx="9" cy="4" r="2.5" />419 <circle cx="4" cy="9" r="2.5" />420 <circle cx="9" cy="9" r="2.5" />421 </svg>422 </button>423 </Show>424 </>425 }426 >427 {}428 <button429 class="peach-select-cancel-btn"430 onClick={exitSelectMode}431 >432 cancel433 </button>434 <span class="peach-select-count-label">435 {selectedIds().size > 0 ? `${selectedIds().size} selected` : "select entries"}436 </span>437 <button438 class="peach-select-all-btn"439 onClick={toggleSelectAll}440 >441 {allSelected() ? "none" : "all"}442 </button>443 </Show>444 </div>445446 <Show when={hasTags() && !selecting()}>447 <div class="peach-tag-filter-strip" style="display:flex;gap:6px;padding:4px 12px 6px;overflow-x:auto;flex-wrap:nowrap;scrollbar-width:none;">448 <For each={allTags()}>449 {(tag) => (450 <button451 class={`peach-tag-filter-chip ${selectedTags().has(tag) ? "is-selected" : ""}`}452 onClick={() => toggleTag(tag)}453 >454 {tag}455 </button>456 )}457 </For>458 </div>459 </Show>460461 <div462 class={`peach-vault-list${selecting() ? " is-selecting" : ""}`}463 ref={(el) => { listRef = el; setViewportHeight(el.clientHeight); }}464 onKeyDown={handleListKeydown}465 onScroll={handleListScroll}466 >467 <Show468 when={filteredEntries().length > 0}469 fallback={470 <div class="peach-list-empty">471 <span>no entries yet</span>472 </div>473 }474 >475 <Show when={isVirtualized()}>476 <div style={{ height: `${visibleRange().start * ROW_HEIGHT}px` }} aria-hidden="true" />477 </Show>478 <For each={windowedEntries()}>479 {(entry, localIndex) => {480 const index = () => visibleRange().start + localIndex();481 const isNote = entry.type === "note";482 const isPasskey = entry.passkey !== undefined;483 const domain = entry.type === "note"484 ? (entry.notes?.slice(0, 40) ?? "note")485 : entry.url486 ? getDisplayDomain(entry.url)487 : entry.passkey?.rpId488 ? entry.passkey.rpId489 : "saved entry";490 const isTabMatch = () =>491 props.currentTabDomain !== undefined &&492 props.currentTabDomain !== "" &&493 entryMatchesDomain(entry, props.currentTabDomain);494 const isSelected = () => selectedIds().has(entry.id);495496 const ariaLabel = (() => {497 const tag = isPasskey ? "passkey" : isNote ? "note" : "login";498 const sub = entry.username || domain || "";499 return sub500 ? `${entry.title} — ${sub} (${tag})`501 : `${entry.title} (${tag})`;502 })();503 return (504 <button505 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" : ""}`}506 style={isVirtualized()507 ? { animation: "none" }508 : { "animation-delay": `${Math.min(index(), 24) * 20}ms` }509 }510 aria-label={ariaLabel}511 aria-pressed={selecting() ? isSelected() : undefined}512 onTouchStart={(e) => handleRowTouchStart(entry, e)}513 onTouchMove={handleRowTouchMove}514 onTouchEnd={(e) => void handleRowTouchEnd(entry, e)}515 onTouchCancel={() => { clearLongPressTimer(); touchStart = null; }}516 onContextMenu={(e) => {517 e.preventDefault();518 519 520 521 522 523 if (touchStart !== null) { touchStart = null; return; }524 if (!selecting()) setContextEntry(entry);525 }}526 onClick={() => {527 if (suppressNextClick) { suppressNextClick = false; return; }528 if (selecting()) {529 toggleId(entry.id);530 } else {531 props.onSelectEntry(entry);532 }533 }}534 >535 <div class={`peach-entry-row-icon${!selecting() && entry.breach ? " is-breach" : ""}${selecting() ? " is-select-icon" : ""}`}>536 <Show537 when={selecting()}538 fallback={539 <FaviconIcon540 domain={entry.type === "note" ? "" : (entry.url ? getDisplayDomain(entry.url) : entry.passkey?.rpId ?? "")}541 title={entry.title}542 isNote={isNote}543 isPasskey={isPasskey}544 />545 }546 >547 {}548 <svg width="16" height="16" viewBox="0 0 16 16" fill="none">549 <circle550 cx="8" cy="8" r="7"551 stroke="currentColor"552 stroke-width="1"553 fill={isSelected() ? "var(--peach-accent)" : "transparent"}554 opacity={isSelected() ? "1" : "0.3"}555 />556 <Show when={isSelected()}>557 <path558 d="M5 8.5L7 10.5L11 6"559 stroke="white"560 stroke-width="1.2"561 stroke-linecap="round"562 stroke-linejoin="round"563 />564 </Show>565 </svg>566 </Show>567 </div>568 <div class="peach-entry-row-info">569 <span class="peach-entry-row-name">570 {entry.title}571 <Show when={isPasskey && !selecting()}>572 <span class="peach-passkey-badge">passkey</span>573 </Show>574 <Show when={isTabMatch() && !selecting()}>575 <span class="peach-tab-match-badge" title="Matches current tab">●</span>576 </Show>577 </span>578 <span class="peach-entry-row-meta">{domain}</span>579 </div>580 <Show when={entry.breach && !selecting()}>581 <span class="peach-entry-row-dot" aria-label="breach"></span>582 </Show>583 <Show when={entry.canary && !selecting()}>584 <span class="peach-entry-row-canary" title={`Canary: ${entry.canary?.label ?? "canary entry"}`}>🪤</span>585 </Show>586 </button>587 );588 }}589 </For>590 <Show when={isVirtualized()}>591 <div592 style={{ height: `${(filteredEntries().length - visibleRange().end) * ROW_HEIGHT}px` }}593 aria-hidden="true"594 />595 </Show>596 </Show>597 </div>598599 {}600 <Show601 when={selecting()}602 fallback={603 <button class="peach-new-entry-row" onClick={props.onAdd}>604 <svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round">605 <path d="M5 1v8M1 5h8" />606 </svg>607 new entry608 </button>609 }610 >611 <button612 class={`peach-select-action-bar${selectedIds().size === 0 ? " is-empty" : ""}`}613 disabled={selectedIds().size === 0 || deleting()}614 onClick={() => void handleDeleteSelected()}615 >616 <svg width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">617 <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />618 </svg>619 <Show620 when={selectedIds().size > 0}621 fallback={<span>select entries to delete</span>}622 >623 <span>624 {deleting()625 ? "moving to trash…"626 : `move ${selectedIds().size} to trash`}627 </span>628 </Show>629 </button>630 </Show>631632 <Show when={contextEntry() !== null}>633 <div634 class="peach-context-menu-backdrop"635 onClick={() => setContextEntry(null)}636 role="presentation"637 >638 <div639 class="peach-context-menu"640 role="menu"641 aria-label="Entry actions"642 onClick={(e) => e.stopPropagation()}643 >644 <Show when={contextEntry()!.username}>645 <button646 role="menuitem"647 class="peach-context-menu-item"648 onClick={async () => {649 const e = contextEntry();650 if (!e?.username) return;651 await popupCopy(e.username, "username");652 setContextEntry(null);653 }}654 >655 Copy username656 </button>657 </Show>658 <Show when={contextEntry()!.password}>659 <button660 role="menuitem"661 class="peach-context-menu-item"662 onClick={async () => {663 const e = contextEntry();664 if (!e?.password) return;665 await popupCopy(e.password, "password");666 setContextEntry(null);667 }}668 >669 Copy password670 </button>671 </Show>672 <Show when={contextEntry()!.totp}>673 <button674 role="menuitem"675 class="peach-context-menu-item"676 onClick={async () => {677 const e = contextEntry();678 if (!e?.totp) return;679 await popupCopy(e.totp, "TOTP secret");680 setContextEntry(null);681 }}682 >683 Copy TOTP secret684 </button>685 </Show>686 <button687 role="menuitem"688 class="peach-context-menu-item"689 onClick={() => {690 const e = contextEntry();691 if (e) props.onSelectEntry(e);692 setContextEntry(null);693 }}694 >695 Open696 </button>697 <Show when={props.onDeleteMany}>698 <button699 role="menuitem"700 class="peach-context-menu-item peach-context-menu-item--danger"701 onClick={async () => {702 const e = contextEntry();703 if (!e) return;704 setContextEntry(null);705 await props.onDeleteMany?.([e.id]);706 }}707 >708 Move to trash709 </button>710 </Show>711 <button712 role="menuitem"713 class="peach-context-menu-item peach-context-menu-item--cancel"714 onClick={() => setContextEntry(null)}715 >716 Cancel717 </button>718 </div>719 </div>720 </Show>721 </div>722 );723}