1import { createEffect, createMemo, createSignal, For, Show } from "solid-js";2import type { VaultEntry } from "../../shared/types";3import { extractDomain, getDisplayDomain } from "../../shared/domain";4import { faviconUrl } from "../../shared/favicon";5import { popupCopy } from "../copy";67function vibrate(ms: number) {8 try { navigator.vibrate?.(ms); } catch { }9}1011function FaviconIcon(props: { domain: string; title?: string; isNote: boolean; isPasskey: boolean }) {12 const src = props.domain && !props.isNote && !props.isPasskey13 ? faviconUrl(props.domain)14 : null;15 16 17 18 19 const fallback = props.isPasskey20 ? PASSKEY_ICON21 : props.isNote22 ? NOTE_ICON23 : <InitialsChip title={props.title ?? props.domain ?? ""} />;24 const [failed, setFailed] = createSignal(false);25 return (26 <Show when={src && !failed()} fallback={fallback}>27 <img28 src={src!}29 width="16"30 height="16"31 alt=""32 style="border-radius:3px;"33 onError={() => setFailed(true)}34 />35 </Show>36 );37}3839404142434445function titleInitials(source: string): string {46 const cleaned = source.replace(/^https?:\/\//, "").replace(/^www\./, "").trim();47 if (!cleaned) return "·";48 const words = cleaned.split(/[\s.\-_/]+/).filter(Boolean);49 if (words.length >= 2 && words[0]![0] && words[1]![0]) {50 return (words[0]![0]! + words[1]![0]!).toUpperCase();51 }52 const single = words[0] ?? cleaned;53 return single.slice(0, 2).toUpperCase();54}55565758596061function titleBackground(source: string): string {62 let hash = 0;63 for (let i = 0; i < source.length; i++) {64 hash = ((hash << 5) - hash + source.charCodeAt(i)) | 0;65 }66 const hue = ((hash % 360) + 360) % 360;67 return `hsl(${hue}, 32%, 26%)`;68}6970function 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}8182const LOGIN_ICON = (83 <svg width="11" height="12" viewBox="0 0 11 12" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round" aria-hidden="true">84 <rect x="2" y="5" width="7" height="5" rx="1.2" />85 <path d="M3.5 5V3.75A2 2 0 0 1 5.5 1.75A2 2 0 0 1 7.5 3.75V5" />86 </svg>87);8889const PASSKEY_ICON = (90 <svg width="11" height="12" viewBox="0 0 11 12" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round" aria-hidden="true">91 <circle cx="4" cy="4.5" r="3" />92 <path d="M6.5 7l3.5 3.5M8.5 8.5L10 10" />93 </svg>94);9596const NOTE_ICON = (97 <svg width="11" height="12" viewBox="0 0 11 12" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round" aria-hidden="true">98 <rect x="1.5" y="1.5" width="8" height="9" rx="1.5" />99 <path d="M3.5 4.5h4M3.5 6.5h4M3.5 8.5h2" />100 </svg>101);102103export type SortOrder = "url-first" | "az" | "za" | "recent";104105interface VaultProps {106 entries: VaultEntry[];107 selectedEntryId?: string;108 currentTabDomain?: string;109 sortOrder: SortOrder;110 searchSeed?: string;111 searchSeedNonce?: number;112 onSelectEntry: (entry: VaultEntry) => void;113 onSortOrderChange: (order: SortOrder) => void;114 onAdd: () => void;115 onClose: () => void;116 onOpenTrash?: () => void;117 onDeleteMany?: (ids: string[]) => Promise<void>;118}119120export function VaultView(props: VaultProps) {121 const [search, setSearch] = createSignal("");122 const [selectedTags, setSelectedTags] = createSignal<Set<string>>(new Set());123 const [notesOnly, setNotesOnly] = createSignal(false);124 const [selecting, setSelecting] = createSignal(false);125 const [selectedIds, setSelectedIds] = createSignal<Set<string>>(new Set());126 const [deleting, setDeleting] = createSignal(false);127 const [contextEntry, setContextEntry] = createSignal<VaultEntry | null>(null);128 const [flashId, setFlashId] = createSignal<string | null>(null);129 let longPressTimer: number | null = null;130 let touchStart: { x: number; y: number; t: number } | null = null;131 let suppressNextClick = false;132133 function clearLongPressTimer() {134 if (longPressTimer !== null) {135 window.clearTimeout(longPressTimer);136 longPressTimer = null;137 }138 }139140 function handleRowTouchStart(entry: VaultEntry, e: TouchEvent) {141 if (selecting()) return;142 const t = e.touches[0];143 if (!t) return;144 touchStart = { x: t.clientX, y: t.clientY, t: Date.now() };145 clearLongPressTimer();146 longPressTimer = window.setTimeout(() => {147 suppressNextClick = true;148 vibrate(20);149 setContextEntry(entry);150 longPressTimer = null;151 }, 500);152 }153154 function handleRowTouchMove(e: TouchEvent) {155 if (!touchStart) return;156 const t = e.touches[0];157 if (!t) return;158 const dx = Math.abs(t.clientX - touchStart.x);159 const dy = Math.abs(t.clientY - touchStart.y);160 if (dx > 8 || dy > 8) clearLongPressTimer();161 }162163 async function handleRowTouchEnd(entry: VaultEntry, e: TouchEvent) {164 clearLongPressTimer();165 if (!touchStart) return;166 const t = e.changedTouches[0];167 if (!t) { touchStart = null; return; }168 const dx = t.clientX - touchStart.x;169 const dy = Math.abs(t.clientY - touchStart.y);170 const dt = Date.now() - touchStart.t;171 touchStart = null;172 173 const isCompact = !document.body.classList.contains("layout-medium")174 && !document.body.classList.contains("layout-expanded");175 if (176 isCompact177 && !selecting()178 && dx > 80179 && dy < 30180 && dt < 400181 && entry.type === "login"182 && entry.password183 ) {184 suppressNextClick = true;185 vibrate(15);186 setFlashId(entry.id);187 window.setTimeout(() => setFlashId((id) => id === entry.id ? null : id), 600);188 await popupCopy(entry.password, "password");189 }190 }191192 let searchRef: HTMLInputElement | undefined;193 let listRef: HTMLDivElement | undefined;194195 const focusSearch = (el: HTMLInputElement) => {196 searchRef = el;197 setTimeout(() => el.focus(), 50);198 };199200 createEffect(() => {201 void props.searchSeedNonce;202 const seed = props.searchSeed;203 if (!seed) return;204 setSearch(seed);205 requestAnimationFrame(() => {206 searchRef?.focus();207 searchRef?.setSelectionRange(seed.length, seed.length);208 });209 });210211 function enterSelectMode() {212 setSelecting(true);213 setSelectedIds(new Set<string>());214 }215216 function exitSelectMode() {217 setSelecting(false);218 setSelectedIds(new Set<string>());219 }220221 function toggleId(id: string) {222 const s = new Set<string>(selectedIds());223 if (s.has(id)) s.delete(id); else s.add(id);224 setSelectedIds(s);225 }226227 const allTags = createMemo(() => {228 const tagSet = new Set<string>();229 for (const entry of props.entries) {230 for (const tag of entry.tags ?? []) {231 tagSet.add(tag);232 }233 }234 return Array.from(tagSet).sort();235 });236237 const hasTags = createMemo(() => allTags().length > 0);238239 function toggleTag(tag: string) {240 const current = new Set(selectedTags());241 if (current.has(tag)) {242 current.delete(tag);243 } else {244 current.add(tag);245 }246 setSelectedTags(current);247 }248249 function fuzzyMatch(query: string, target: string): boolean {250 query = query.toLowerCase();251 target = target.toLowerCase();252 let qi = 0;253 for (let i = 0; i < target.length && qi < query.length; i++) {254 if (target[i] === query[qi]) qi++;255 }256 return qi === query.length;257 }258259 function isExactPrefix(query: string, target: string): boolean {260 return target.toLowerCase().startsWith(query.toLowerCase());261 }262263 function entryDomain(entry: VaultEntry): string {264 if (entry.type === "login" && entry.url) return getDisplayDomain(entry.url);265 if (entry.passkey?.rpId) return entry.passkey.rpId;266 return "";267 }268269 function entryMatchesDomain(entry: VaultEntry, domain: string): boolean {270 if (!domain) return false;271 if (entry.passkey?.rpId === domain) return true;272 if (entry.type !== "login") return false;273 274 275 if (entry.url && extractDomain(entry.url) === domain) return true;276 for (const extra of entry.additionalUrls ?? []) {277 if (extractDomain(extra) === domain) return true;278 }279 return false;280 }281282 const filteredEntries = createMemo(() => {283 let result = props.entries;284285 if (notesOnly()) {286 result = result.filter(entry => entry.type === "note");287 }288289 if (selectedTags().size > 0) {290 result = result.filter(entry =>291 Array.from(selectedTags()).every(tag => entry.tags?.includes(tag))292 );293 }294295 const q = search().trim();296 if (q) {297 const searchableFor = (e: VaultEntry): string => {298 if (e.type === "note") return `${e.title} ${e.notes ?? ""}`;299 const passkeyText = e.passkey300 ? ` ${e.passkey.rpId} ${e.passkey.userName} ${e.passkey.userDisplayName}`301 : "";302 return `${e.title} ${e.url ?? ""} ${e.username ?? ""}${passkeyText}`;303 };304 result = result305 .filter((entry) => fuzzyMatch(q, searchableFor(entry)))306 .sort((a, b) => {307 const aSearchable = searchableFor(a).toLowerCase();308 const bSearchable = searchableFor(b).toLowerCase();309 const qLower = q.toLowerCase();310 const aExact = isExactPrefix(qLower, aSearchable);311 const bExact = isExactPrefix(qLower, bSearchable);312 if (aExact && !bExact) return -1;313 if (!aExact && bExact) return 1;314 return 0;315 });316 return result;317 }318319 const tabDomain = props.currentTabDomain ?? "";320 const order = props.sortOrder;321322 if (order === "az") {323 return [...result].sort((a, b) => a.title.localeCompare(b.title));324 }325 if (order === "za") {326 return [...result].sort((a, b) => b.title.localeCompare(a.title));327 }328 if (order === "recent") {329 return [...result].sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0));330 }331 if (tabDomain) {332 return [...result].sort((a, b) => {333 const aMatch = entryMatchesDomain(a, tabDomain) ? 0 : 1;334 const bMatch = entryMatchesDomain(b, tabDomain) ? 0 : 1;335 return aMatch - bMatch;336 });337 }338 return result;339 });340341 342 343 344 345 const allVisibleIds = createMemo(() => filteredEntries().map(e => e.id));346 const allSelected = createMemo(() =>347 allVisibleIds().length > 0 && allVisibleIds().every(id => selectedIds().has(id))348 );349350 function toggleSelectAll() {351 if (allSelected()) {352 setSelectedIds(new Set<string>());353 } else {354 setSelectedIds(new Set<string>(allVisibleIds()));355 }356 }357358 async function handleDeleteSelected() {359 const ids = Array.from(selectedIds());360 if (!ids.length || !props.onDeleteMany) return;361 setDeleting(true);362 await props.onDeleteMany(ids);363 setDeleting(false);364 exitSelectMode();365 }366367 function handleSearchKeydown(e: KeyboardEvent) {368 if (e.key === "ArrowDown") {369 e.preventDefault();370 const firstBtn = listRef?.querySelector<HTMLButtonElement>(".peach-entry-row");371 firstBtn?.focus();372 } else if (e.key === "ArrowUp") {373 e.preventDefault();374 const allBtns = listRef?.querySelectorAll<HTMLButtonElement>(".peach-entry-row");375 allBtns?.[allBtns.length - 1]?.focus();376 } else if (e.key === "Enter") {377 e.preventDefault();378 const firstEntry = filteredEntries()[0];379 if (firstEntry) props.onSelectEntry(firstEntry);380 } else if (e.key === "Escape" && selecting()) {381 exitSelectMode();382 }383 }384385 function handleListKeydown(e: KeyboardEvent) {386 if (!listRef) return;387 const btns = Array.from(listRef.querySelectorAll<HTMLButtonElement>(".peach-entry-row"));388 const idx = btns.indexOf(document.activeElement as HTMLButtonElement);389 if (e.key === "ArrowDown") {390 e.preventDefault();391 if (idx < btns.length - 1) btns[idx + 1]?.focus();392 } else if (e.key === "ArrowUp") {393 e.preventDefault();394 if (idx === 0) { searchRef?.focus(); }395 else btns[idx - 1]?.focus();396 } else if (e.key === "Escape") {397 if (selecting()) exitSelectMode();398 else props.onClose();399 }400 }401402 const sortLabels: { key: SortOrder; label: string }[] = [403 { key: "url-first", label: "smart" },404 { key: "az", label: "A–Z" },405 { key: "za", label: "Z–A" },406 { key: "recent", label: "newest" },407 ];408409 return (410 <div class="peach-overlay-view ov-enter-up" style="display:flex">411 <div class="peach-list-top">412 <button413 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"414 onClick={props.onClose}415 aria-label="Back"416 >417 <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">418 <path d="M6 1L1 6L6 11" />419 </svg>420 </button>421 <input422 class="peach-search-input"423 placeholder="search vault..."424 autocomplete="off"425 style="font-size:14px"426 ref={focusSearch}427 value={search()}428 onInput={(e) => setSearch((e.currentTarget as HTMLInputElement).value)}429 onKeyDown={handleSearchKeydown}430 />431 <span class="peach-search-counter peach-search-counter-list">432 {search() || selectedTags().size > 0433 ? `${filteredEntries().length} of ${props.entries.length}`434 : `${props.entries.length} entries`}435 </span>436 </div>437438 {}439 <div class="peach-sort-bar">440 <Show441 when={selecting()}442 fallback={443 <>444 <For each={sortLabels}>445 {({ key, label }) => (446 <button447 class={`peach-sort-btn${props.sortOrder === key ? " is-active" : ""}`}448 onClick={() => props.onSortOrderChange(key)}449 >450 {label}451 </button>452 )}453 </For>454 <span style="flex:1" />455 <button456 class={`peach-sort-trash-btn${notesOnly() ? " is-active" : ""}`}457 onClick={() => setNotesOnly(v => !v)}458 aria-label="Notes only"459 title="Show notes only"460 >461 {NOTE_ICON}462 </button>463 <Show when={props.onOpenTrash}>464 <button465 class="peach-sort-trash-btn"466 onClick={props.onOpenTrash}467 aria-label="View trash"468 title="View deleted entries"469 >470 <svg width="11" height="12" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">471 <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />472 </svg>473 </button>474 </Show>475 <Show when={props.onDeleteMany}>476 <button477 class="peach-sort-trash-btn"478 onClick={enterSelectMode}479 aria-label="Select entries"480 title="Select entries"481 >482 <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">483 <circle cx="4" cy="4" r="2.5" />484 <circle cx="9" cy="4" r="2.5" />485 <circle cx="4" cy="9" r="2.5" />486 <circle cx="9" cy="9" r="2.5" />487 </svg>488 </button>489 </Show>490 </>491 }492 >493 {}494 <button495 class="peach-select-cancel-btn"496 onClick={exitSelectMode}497 >498 cancel499 </button>500 <span class="peach-select-count-label">501 {selectedIds().size > 0 ? `${selectedIds().size} selected` : "select entries"}502 </span>503 <button504 class="peach-select-all-btn"505 onClick={toggleSelectAll}506 >507 {allSelected() ? "none" : "all"}508 </button>509 </Show>510 </div>511512 <Show when={hasTags() && !selecting()}>513 <div class="peach-tag-filter-strip" style="display:flex;gap:6px;padding:4px 12px 6px;overflow-x:auto;flex-wrap:nowrap;scrollbar-width:none;">514 <For each={allTags()}>515 {(tag) => (516 <button517 class={`peach-tag-filter-chip ${selectedTags().has(tag) ? "is-selected" : ""}`}518 onClick={() => toggleTag(tag)}519 >520 {tag}521 </button>522 )}523 </For>524 </div>525 </Show>526527 <div528 class={`peach-vault-list${selecting() ? " is-selecting" : ""}`}529 ref={listRef}530 onKeyDown={handleListKeydown}531 >532 <Show533 when={filteredEntries().length > 0}534 fallback={535 <div class="peach-list-empty">536 <span>no entries yet</span>537 </div>538 }539 >540 <For each={filteredEntries()}>541 {(entry, index) => {542 const isNote = entry.type === "note";543 const isPasskey = entry.passkey !== undefined;544 const domain = entry.type === "note"545 ? (entry.notes?.slice(0, 40) ?? "note")546 : entry.url547 ? getDisplayDomain(entry.url)548 : entry.passkey?.rpId549 ? entry.passkey.rpId550 : "saved entry";551 const isTabMatch = () =>552 props.currentTabDomain !== undefined &&553 props.currentTabDomain !== "" &&554 entryMatchesDomain(entry, props.currentTabDomain);555 const isSelected = () => selectedIds().has(entry.id);556557 const ariaLabel = (() => {558 const tag = isPasskey ? "passkey" : isNote ? "note" : "login";559 const sub = entry.username || domain || "";560 return sub561 ? `${entry.title} — ${sub} (${tag})`562 : `${entry.title} (${tag})`;563 })();564 return (565 <button566 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" : ""}`}567 style={{ "animation-delay": `${Math.min(index(), 24) * 20}ms` }}568 aria-label={ariaLabel}569 aria-pressed={selecting() ? isSelected() : undefined}570 onTouchStart={(e) => handleRowTouchStart(entry, e)}571 onTouchMove={handleRowTouchMove}572 onTouchEnd={(e) => void handleRowTouchEnd(entry, e)}573 onTouchCancel={() => { clearLongPressTimer(); touchStart = null; }}574 onContextMenu={(e) => { e.preventDefault(); if (!selecting()) setContextEntry(entry); }}575 onClick={() => {576 if (suppressNextClick) { suppressNextClick = false; return; }577 if (selecting()) {578 toggleId(entry.id);579 } else {580 props.onSelectEntry(entry);581 }582 }}583 >584 <div class={`peach-entry-row-icon${!selecting() && entry.breach ? " is-breach" : ""}${selecting() ? " is-select-icon" : ""}`}>585 <Show586 when={selecting()}587 fallback={588 <FaviconIcon589 domain={entry.url ? getDisplayDomain(entry.url) : ""}590 title={entry.title}591 isNote={isNote}592 isPasskey={isPasskey}593 />594 }595 >596 {}597 <svg width="16" height="16" viewBox="0 0 16 16" fill="none">598 <circle599 cx="8" cy="8" r="7"600 stroke="currentColor"601 stroke-width="1"602 fill={isSelected() ? "var(--peach-accent)" : "transparent"}603 opacity={isSelected() ? "1" : "0.3"}604 />605 <Show when={isSelected()}>606 <path607 d="M5 8.5L7 10.5L11 6"608 stroke="white"609 stroke-width="1.2"610 stroke-linecap="round"611 stroke-linejoin="round"612 />613 </Show>614 </svg>615 </Show>616 </div>617 <div class="peach-entry-row-info">618 <span class="peach-entry-row-name">619 {entry.title}620 <Show when={isPasskey && !selecting()}>621 <span class="peach-passkey-badge">passkey</span>622 </Show>623 <Show when={isTabMatch() && !selecting()}>624 <span class="peach-tab-match-badge" title="Matches current tab">●</span>625 </Show>626 </span>627 <span class="peach-entry-row-meta">{domain}</span>628 </div>629 <Show when={entry.breach && !selecting()}>630 <span class="peach-entry-row-dot" aria-label="breach"></span>631 </Show>632 <Show when={entry.canary && !selecting()}>633 <span class="peach-entry-row-canary" title={`Canary: ${entry.canary?.label ?? "canary entry"}`}>🪤</span>634 </Show>635 </button>636 );637 }}638 </For>639 </Show>640 </div>641642 {}643 <Show644 when={selecting()}645 fallback={646 <button class="peach-new-entry-row" onClick={props.onAdd}>647 <svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round">648 <path d="M5 1v8M1 5h8" />649 </svg>650 new entry651 </button>652 }653 >654 <button655 class={`peach-select-action-bar${selectedIds().size === 0 ? " is-empty" : ""}`}656 disabled={selectedIds().size === 0 || deleting()}657 onClick={() => void handleDeleteSelected()}658 >659 <svg width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">660 <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />661 </svg>662 <Show663 when={selectedIds().size > 0}664 fallback={<span>select entries to delete</span>}665 >666 <span>667 {deleting()668 ? "moving to trash…"669 : `move ${selectedIds().size} to trash`}670 </span>671 </Show>672 </button>673 </Show>674675 <Show when={contextEntry() !== null}>676 <div677 class="peach-context-menu-backdrop"678 onClick={() => setContextEntry(null)}679 role="presentation"680 >681 <div682 class="peach-context-menu"683 role="menu"684 aria-label="Entry actions"685 onClick={(e) => e.stopPropagation()}686 >687 <Show when={contextEntry()!.username}>688 <button689 role="menuitem"690 class="peach-context-menu-item"691 onClick={async () => {692 const e = contextEntry();693 if (!e?.username) return;694 await popupCopy(e.username, "username");695 setContextEntry(null);696 }}697 >698 Copy username699 </button>700 </Show>701 <Show when={contextEntry()!.password}>702 <button703 role="menuitem"704 class="peach-context-menu-item"705 onClick={async () => {706 const e = contextEntry();707 if (!e?.password) return;708 await popupCopy(e.password, "password");709 setContextEntry(null);710 }}711 >712 Copy password713 </button>714 </Show>715 <Show when={contextEntry()!.totp}>716 <button717 role="menuitem"718 class="peach-context-menu-item"719 onClick={async () => {720 const e = contextEntry();721 if (!e?.totp) return;722 await popupCopy(e.totp, "TOTP secret");723 setContextEntry(null);724 }}725 >726 Copy TOTP secret727 </button>728 </Show>729 <button730 role="menuitem"731 class="peach-context-menu-item"732 onClick={() => {733 const e = contextEntry();734 if (e) props.onSelectEntry(e);735 setContextEntry(null);736 }}737 >738 Open739 </button>740 <Show when={props.onDeleteMany}>741 <button742 role="menuitem"743 class="peach-context-menu-item peach-context-menu-item--danger"744 onClick={async () => {745 const e = contextEntry();746 if (!e) return;747 setContextEntry(null);748 await props.onDeleteMany?.([e.id]);749 }}750 >751 Move to trash752 </button>753 </Show>754 <button755 role="menuitem"756 class="peach-context-menu-item peach-context-menu-item--cancel"757 onClick={() => setContextEntry(null)}758 >759 Cancel760 </button>761 </div>762 </div>763 </Show>764 </div>765 );766}