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 const fallback = props.isPasskey16 ? <PasskeyIcon />17 : props.isNote18 ? <NoteIcon />19 : <InitialsChip title={props.title ?? props.domain ?? ""} />;20 const [failed, setFailed] = createSignal(false);21 return (22 <Show when={src && !failed()} fallback={fallback}>23 <img24 src={src!}25 width="16"26 height="16"27 alt=""28 style="border-radius:3px;"29 onError={() => setFailed(true)}30 onLoad={(e) => {31 32 33 34 if ((e.currentTarget as HTMLImageElement).naturalWidth <= 16) setFailed(true);35 }}36 />37 </Show>38 );39}4041424344454647function titleInitials(source: string): string {48 const cleaned = source.replace(/^https?:\/\//, "").replace(/^www\./, "").trim();49 if (!cleaned) return "·";50 const words = cleaned.split(/[\s.\-_/]+/).filter(Boolean);51 if (words.length >= 2 && words[0]![0] && words[1]![0]) {52 return (words[0]![0]! + words[1]![0]!).toUpperCase();53 }54 const single = words[0] ?? cleaned;55 return single.slice(0, 2).toUpperCase();56}57585960616263function titleBackground(source: string): string {64 let hash = 0;65 for (let i = 0; i < source.length; i++) {66 hash = ((hash << 5) - hash + source.charCodeAt(i)) | 0;67 }68 const hue = ((hash % 360) + 360) % 360;69 return `hsl(${hue}, 32%, 26%)`;70}7172function InitialsChip(props: { title: string }) {73 return (74 <span75 class="peach-favicon-initials"76 style={{ background: titleBackground(props.title) }}77 aria-hidden="true"78 >79 {titleInitials(props.title)}80 </span>81 );82}838485const PasskeyIcon = () => (86 <svg width="11" height="12" viewBox="0 0 11 12" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round" aria-hidden="true">87 <circle cx="4" cy="4.5" r="3" />88 <path d="M6.5 7l3.5 3.5M8.5 8.5L10 10" />89 </svg>90);9192const NoteIcon = () => (93 <svg width="11" height="12" viewBox="0 0 11 12" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round" aria-hidden="true">94 <rect x="1.5" y="1.5" width="8" height="9" rx="1.5" />95 <path d="M3.5 4.5h4M3.5 6.5h4M3.5 8.5h2" />96 </svg>97);9899export type SortOrder = "url-first" | "az" | "za" | "recent";100101interface VaultProps {102 entries: VaultEntry[];103 selectedEntryId?: string;104 currentTabDomain?: string;105 sortOrder: SortOrder;106 searchSeed?: string;107 searchSeedNonce?: number;108 109 110 111 isNative?: boolean;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 const [scrollTop, setScrollTop] = createSignal(0);130 const [viewportHeight, setViewportHeight] = createSignal(600);131 let longPressTimer: number | null = null;132 let touchStart: { x: number; y: number; t: number } | null = null;133 let suppressNextClick = false;134135 function clearLongPressTimer() {136 if (longPressTimer !== null) {137 window.clearTimeout(longPressTimer);138 longPressTimer = null;139 }140 }141142 function handleRowTouchStart(entry: VaultEntry, e: TouchEvent) {143 if (selecting()) return;144 const t = e.touches[0];145 if (!t) return;146 touchStart = { x: t.clientX, y: t.clientY, t: Date.now() };147 clearLongPressTimer();148 longPressTimer = window.setTimeout(() => {149 suppressNextClick = true;150 vibrate(20);151 setContextEntry(entry);152 longPressTimer = null;153 }, 500);154 }155156 function handleRowTouchMove(e: TouchEvent) {157 if (!touchStart) return;158 const t = e.touches[0];159 if (!t) return;160 const dx = Math.abs(t.clientX - touchStart.x);161 const dy = Math.abs(t.clientY - touchStart.y);162 if (dx > 8 || dy > 8) clearLongPressTimer();163 }164165 async function handleRowTouchEnd(entry: VaultEntry, e: TouchEvent) {166 clearLongPressTimer();167 if (!touchStart) return;168 const t = e.changedTouches[0];169 if (!t) { touchStart = null; return; }170 const dx = t.clientX - touchStart.x;171 const dy = Math.abs(t.clientY - touchStart.y);172 const dt = Date.now() - touchStart.t;173 touchStart = null;174 175 const isCompact = !document.body.classList.contains("layout-medium")176 && !document.body.classList.contains("layout-expanded");177 if (178 isCompact179 && !selecting()180 && dx > 80181 && dy < 30182 && dt < 400183 && entry.type === "login"184 && entry.password185 ) {186 suppressNextClick = true;187 vibrate(15);188 setFlashId(entry.id);189 window.setTimeout(() => setFlashId((id) => id === entry.id ? null : id), 600);190 await popupCopy(entry.password, "password");191 }192 }193194 let searchRef: HTMLInputElement | undefined;195 let listRef: HTMLDivElement | undefined;196197 const focusSearch = (el: HTMLInputElement) => {198 searchRef = el;199 setTimeout(() => el.focus(), 50);200 };201202 createEffect(() => {203 void props.searchSeedNonce;204 const seed = props.searchSeed;205 if (!seed) return;206 setSearch(seed);207 requestAnimationFrame(() => {208 searchRef?.focus();209 searchRef?.setSelectionRange(seed.length, seed.length);210 });211 });212213 function enterSelectMode() {214 setSelecting(true);215 setSelectedIds(new Set<string>());216 }217218 function exitSelectMode() {219 setSelecting(false);220 setSelectedIds(new Set<string>());221 }222223 function toggleId(id: string) {224 const s = new Set<string>(selectedIds());225 if (s.has(id)) s.delete(id); else s.add(id);226 setSelectedIds(s);227 }228229 const allTags = createMemo(() => {230 const tagSet = new Set<string>();231 for (const entry of props.entries) {232 for (const tag of entry.tags ?? []) {233 tagSet.add(tag);234 }235 }236 return Array.from(tagSet).sort();237 });238239 const hasTags = createMemo(() => allTags().length > 0);240241 function toggleTag(tag: string) {242 const current = new Set(selectedTags());243 if (current.has(tag)) {244 current.delete(tag);245 } else {246 current.add(tag);247 }248 setSelectedTags(current);249 }250251 function fuzzyMatch(query: string, target: string): boolean {252 query = query.toLowerCase();253 target = target.toLowerCase();254 let qi = 0;255 for (let i = 0; i < target.length && qi < query.length; i++) {256 if (target[i] === query[qi]) qi++;257 }258 return qi === query.length;259 }260261 function isExactPrefix(query: string, target: string): boolean {262 return target.toLowerCase().startsWith(query.toLowerCase());263 }264265 function entryMatchesDomain(entry: VaultEntry, domain: string): boolean {266 if (!domain) return false;267 if (entry.passkey?.rpId === domain) return true;268 if (entry.type !== "login") return false;269 270 271 if (entry.url && extractDomain(entry.url) === domain) return true;272 for (const extra of entry.additionalUrls ?? []) {273 if (extractDomain(extra) === domain) return true;274 }275 return false;276 }277278 const filteredEntries = createMemo(() => {279 let result = props.entries;280281 if (notesOnly()) {282 result = result.filter(entry => entry.type === "note");283 }284285 if (selectedTags().size > 0) {286 result = result.filter(entry =>287 Array.from(selectedTags()).every(tag => entry.tags?.includes(tag))288 );289 }290291 const q = search().trim();292 if (q) {293 const searchableFor = (e: VaultEntry): string => {294 if (e.type === "note") return `${e.title} ${e.notes ?? ""}`;295 const passkeyText = e.passkey296 ? ` ${e.passkey.rpId} ${e.passkey.userName} ${e.passkey.userDisplayName}`297 : "";298 return `${e.title} ${e.url ?? ""} ${e.username ?? ""}${passkeyText}`;299 };300 result = result301 .filter((entry) => fuzzyMatch(q, searchableFor(entry)))302 .sort((a, b) => {303 const aSearchable = searchableFor(a).toLowerCase();304 const bSearchable = searchableFor(b).toLowerCase();305 const qLower = q.toLowerCase();306 const aExact = isExactPrefix(qLower, aSearchable);307 const bExact = isExactPrefix(qLower, bSearchable);308 if (aExact && !bExact) return -1;309 if (!aExact && bExact) return 1;310 return 0;311 });312 return result;313 }314315 const tabDomain = props.currentTabDomain ?? "";316 const order = props.sortOrder;317318 if (order === "az") {319 return [...result].sort((a, b) => a.title.localeCompare(b.title));320 }321 if (order === "za") {322 return [...result].sort((a, b) => b.title.localeCompare(a.title));323 }324 if (order === "recent") {325 return [...result].sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0));326 }327 if (tabDomain) {328 return [...result].sort((a, b) => {329 const aMatch = entryMatchesDomain(a, tabDomain) ? 0 : 1;330 const bMatch = entryMatchesDomain(b, tabDomain) ? 0 : 1;331 return aMatch - bMatch;332 });333 }334 return result;335 });336337 338 339 340 341 const allVisibleIds = createMemo(() => filteredEntries().map(e => e.id));342 const allSelected = createMemo(() =>343 allVisibleIds().length > 0 && allVisibleIds().every(id => selectedIds().has(id))344 );345346 function toggleSelectAll() {347 if (allSelected()) {348 setSelectedIds(new Set<string>());349 } else {350 setSelectedIds(new Set<string>(allVisibleIds()));351 }352 }353354 async function handleDeleteSelected() {355 const ids = Array.from(selectedIds());356 if (!ids.length || !props.onDeleteMany) return;357 setDeleting(true);358 await props.onDeleteMany(ids);359 setDeleting(false);360 exitSelectMode();361 }362363 364 365 366 367 368 const VIRTUAL_THRESHOLD = 100;369 const ROW_HEIGHT = 56;370 const BUFFER_ROWS = 20;371372 const isVirtualized = createMemo(() => filteredEntries().length > VIRTUAL_THRESHOLD);373374 const visibleRange = createMemo(() => {375 const total = filteredEntries().length;376 if (!isVirtualized()) return { start: 0, end: total };377 const start = Math.max(0, Math.floor(scrollTop() / ROW_HEIGHT) - BUFFER_ROWS);378 const end = Math.min(total, Math.ceil((scrollTop() + viewportHeight()) / ROW_HEIGHT) + BUFFER_ROWS);379 return { start, end };380 });381382 const windowedEntries = createMemo(() => {383 const { start, end } = visibleRange();384 return filteredEntries().slice(start, end);385 });386387 function handleListScroll(e: Event) {388 if (!isVirtualized()) return;389 const target = e.currentTarget as HTMLDivElement;390 setScrollTop(target.scrollTop);391 setViewportHeight(target.clientHeight);392 }393394 function handleSearchKeydown(e: KeyboardEvent) {395 if (e.key === "ArrowDown") {396 e.preventDefault();397 const firstBtn = listRef?.querySelector<HTMLButtonElement>(".peach-entry-row");398 firstBtn?.focus();399 } else if (e.key === "ArrowUp") {400 e.preventDefault();401 const allBtns = listRef?.querySelectorAll<HTMLButtonElement>(".peach-entry-row");402 allBtns?.[allBtns.length - 1]?.focus();403 } else if (e.key === "Enter") {404 e.preventDefault();405 const firstEntry = filteredEntries()[0];406 if (firstEntry) props.onSelectEntry(firstEntry);407 } else if (e.key === "Escape" && selecting()) {408 exitSelectMode();409 }410 }411412 function handleListKeydown(e: KeyboardEvent) {413 if (!listRef) return;414 const btns = Array.from(listRef.querySelectorAll<HTMLButtonElement>(".peach-entry-row"));415 const idx = btns.indexOf(document.activeElement as HTMLButtonElement);416 if (e.key === "ArrowDown") {417 e.preventDefault();418 if (idx < btns.length - 1) btns[idx + 1]?.focus();419 } else if (e.key === "ArrowUp") {420 e.preventDefault();421 if (idx === 0) { searchRef?.focus(); }422 else btns[idx - 1]?.focus();423 } else if (e.key === "Escape") {424 if (selecting()) exitSelectMode();425 else props.onClose();426 }427 }428429 const sortLabels: { key: SortOrder; label: string }[] = [430 { key: "url-first", label: "smart" },431 { key: "az", label: "A–Z" },432 { key: "za", label: "Z–A" },433 { key: "recent", label: "newest" },434 ];435436 return (437 <div class="peach-overlay-view ov-enter-up" style="display:flex">438 <div class="peach-list-top" classList={{ "peach-list-top--native": !!props.isNative }}>439 <Show440 when={!props.isNative}441 fallback={442 <span class="peach-search-icon" aria-hidden="true">443 <svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round">444 <circle cx="5.5" cy="5.5" r="3.75" />445 <path d="M8.75 8.75L11.5 11.5" />446 </svg>447 </span>448 }449 >450 <button451 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"452 onClick={props.onClose}453 aria-label="Back"454 >455 <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">456 <path d="M6 1L1 6L6 11" />457 </svg>458 </button>459 </Show>460 <input461 class="peach-search-input"462 placeholder="search vault..."463 autocomplete="off"464 style={props.isNative ? undefined : "font-size:14px"}465 ref={focusSearch}466 value={search()}467 onInput={(e) => setSearch((e.currentTarget as HTMLInputElement).value)}468 onKeyDown={handleSearchKeydown}469 />470 <span class="peach-search-counter peach-search-counter-list">471 {search() || selectedTags().size > 0472 ? `${filteredEntries().length} of ${props.entries.length}`473 : props.isNative474 ? `${props.entries.length}`475 : `${props.entries.length} entries`}476 </span>477 </div>478479 {}480 <div class="peach-sort-bar">481 <Show482 when={selecting()}483 fallback={484 <>485 <For each={sortLabels}>486 {({ key, label }) => (487 <button488 class={`peach-sort-btn${props.sortOrder === key ? " is-active" : ""}`}489 onClick={() => props.onSortOrderChange(key)}490 >491 {label}492 </button>493 )}494 </For>495 <span style="flex:1" />496 <button497 class={`peach-sort-trash-btn${notesOnly() ? " is-active" : ""}`}498 onClick={() => setNotesOnly(v => !v)}499 aria-label="Notes only"500 title="Show notes only"501 >502 <NoteIcon />503 </button>504 <Show when={props.onOpenTrash}>505 <button506 class="peach-sort-trash-btn"507 onClick={props.onOpenTrash}508 aria-label="View trash"509 title="View deleted entries"510 >511 <svg width="11" height="12" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">512 <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />513 </svg>514 </button>515 </Show>516 <Show when={props.onDeleteMany}>517 <button518 class="peach-sort-trash-btn"519 onClick={enterSelectMode}520 aria-label="Select entries"521 title="Select entries"522 >523 <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">524 <circle cx="4" cy="4" r="2.5" />525 <circle cx="9" cy="4" r="2.5" />526 <circle cx="4" cy="9" r="2.5" />527 <circle cx="9" cy="9" r="2.5" />528 </svg>529 </button>530 </Show>531 </>532 }533 >534 {}535 <button536 class="peach-select-cancel-btn"537 onClick={exitSelectMode}538 >539 cancel540 </button>541 <span class="peach-select-count-label">542 {selectedIds().size > 0 ? `${selectedIds().size} selected` : "select entries"}543 </span>544 <button545 class="peach-select-all-btn"546 onClick={toggleSelectAll}547 >548 {allSelected() ? "none" : "all"}549 </button>550 </Show>551 </div>552553 <Show when={hasTags() && !selecting()}>554 <div class="peach-tag-filter-strip" style="display:flex;gap:6px;padding:4px 12px 6px;overflow-x:auto;flex-wrap:nowrap;scrollbar-width:none;">555 <For each={allTags()}>556 {(tag) => (557 <button558 class={`peach-tag-filter-chip ${selectedTags().has(tag) ? "is-selected" : ""}`}559 onClick={() => toggleTag(tag)}560 >561 {tag}562 </button>563 )}564 </For>565 </div>566 </Show>567568 <div569 class={`peach-vault-list${selecting() ? " is-selecting" : ""}`}570 ref={(el) => { listRef = el; setViewportHeight(el.clientHeight); }}571 onKeyDown={handleListKeydown}572 onScroll={handleListScroll}573 >574 <Show575 when={filteredEntries().length > 0}576 fallback={577 <div class="peach-list-empty">578 <span>no entries yet</span>579 </div>580 }581 >582 <Show when={isVirtualized()}>583 <div style={{ height: `${visibleRange().start * ROW_HEIGHT}px` }} aria-hidden="true" />584 </Show>585 <For each={windowedEntries()}>586 {(entry, localIndex) => {587 const index = () => visibleRange().start + localIndex();588 const isNote = entry.type === "note";589 const isPasskey = entry.passkey !== undefined;590 const domain = entry.type === "note"591 ? (entry.notes?.slice(0, 40) ?? "note")592 : entry.url593 ? getDisplayDomain(entry.url)594 : entry.passkey?.rpId595 ? entry.passkey.rpId596 : "saved entry";597 const isTabMatch = () =>598 props.currentTabDomain !== undefined &&599 props.currentTabDomain !== "" &&600 entryMatchesDomain(entry, props.currentTabDomain);601 const isSelected = () => selectedIds().has(entry.id);602603 const ariaLabel = (() => {604 const tag = isPasskey ? "passkey" : isNote ? "note" : "login";605 const sub = entry.username || domain || "";606 return sub607 ? `${entry.title} — ${sub} (${tag})`608 : `${entry.title} (${tag})`;609 })();610 return (611 <button612 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" : ""}`}613 style={isVirtualized()614 ? { animation: "none" }615 : { "animation-delay": `${Math.min(index(), 24) * 20}ms` }616 }617 aria-label={ariaLabel}618 aria-pressed={selecting() ? isSelected() : undefined}619 onTouchStart={(e) => handleRowTouchStart(entry, e)}620 onTouchMove={handleRowTouchMove}621 onTouchEnd={(e) => void handleRowTouchEnd(entry, e)}622 onTouchCancel={() => { clearLongPressTimer(); touchStart = null; }}623 onContextMenu={(e) => { e.preventDefault(); if (!selecting()) setContextEntry(entry); }}624 onClick={() => {625 if (suppressNextClick) { suppressNextClick = false; return; }626 if (selecting()) {627 toggleId(entry.id);628 } else {629 props.onSelectEntry(entry);630 }631 }}632 >633 <div class={`peach-entry-row-icon${!selecting() && entry.breach ? " is-breach" : ""}${selecting() ? " is-select-icon" : ""}`}>634 <Show635 when={selecting()}636 fallback={637 <FaviconIcon638 domain={entry.url ? getDisplayDomain(entry.url) : ""}639 title={entry.title}640 isNote={isNote}641 isPasskey={isPasskey}642 />643 }644 >645 {}646 <svg width="16" height="16" viewBox="0 0 16 16" fill="none">647 <circle648 cx="8" cy="8" r="7"649 stroke="currentColor"650 stroke-width="1"651 fill={isSelected() ? "var(--peach-accent)" : "transparent"}652 opacity={isSelected() ? "1" : "0.3"}653 />654 <Show when={isSelected()}>655 <path656 d="M5 8.5L7 10.5L11 6"657 stroke="white"658 stroke-width="1.2"659 stroke-linecap="round"660 stroke-linejoin="round"661 />662 </Show>663 </svg>664 </Show>665 </div>666 <div class="peach-entry-row-info">667 <span class="peach-entry-row-name">668 {entry.title}669 <Show when={isPasskey && !selecting()}>670 <span class="peach-passkey-badge">passkey</span>671 </Show>672 <Show when={isTabMatch() && !selecting()}>673 <span class="peach-tab-match-badge" title="Matches current tab">●</span>674 </Show>675 </span>676 <span class="peach-entry-row-meta">{domain}</span>677 </div>678 <Show when={entry.breach && !selecting()}>679 <span class="peach-entry-row-dot" aria-label="breach"></span>680 </Show>681 <Show when={entry.canary && !selecting()}>682 <span class="peach-entry-row-canary" title={`Canary: ${entry.canary?.label ?? "canary entry"}`}>🪤</span>683 </Show>684 </button>685 );686 }}687 </For>688 <Show when={isVirtualized()}>689 <div690 style={{ height: `${(filteredEntries().length - visibleRange().end) * ROW_HEIGHT}px` }}691 aria-hidden="true"692 />693 </Show>694 </Show>695 </div>696697 {}698 <Show699 when={selecting()}700 fallback={701 <button class="peach-new-entry-row" onClick={props.onAdd}>702 <svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round">703 <path d="M5 1v8M1 5h8" />704 </svg>705 new entry706 </button>707 }708 >709 <button710 class={`peach-select-action-bar${selectedIds().size === 0 ? " is-empty" : ""}`}711 disabled={selectedIds().size === 0 || deleting()}712 onClick={() => void handleDeleteSelected()}713 >714 <svg width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">715 <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />716 </svg>717 <Show718 when={selectedIds().size > 0}719 fallback={<span>select entries to delete</span>}720 >721 <span>722 {deleting()723 ? "moving to trash…"724 : `move ${selectedIds().size} to trash`}725 </span>726 </Show>727 </button>728 </Show>729730 <Show when={contextEntry() !== null}>731 <div732 class="peach-context-menu-backdrop"733 onClick={() => setContextEntry(null)}734 role="presentation"735 >736 <div737 class="peach-context-menu"738 role="menu"739 aria-label="Entry actions"740 onClick={(e) => e.stopPropagation()}741 >742 <Show when={contextEntry()!.username}>743 <button744 role="menuitem"745 class="peach-context-menu-item"746 onClick={async () => {747 const e = contextEntry();748 if (!e?.username) return;749 await popupCopy(e.username, "username");750 setContextEntry(null);751 }}752 >753 Copy username754 </button>755 </Show>756 <Show when={contextEntry()!.password}>757 <button758 role="menuitem"759 class="peach-context-menu-item"760 onClick={async () => {761 const e = contextEntry();762 if (!e?.password) return;763 await popupCopy(e.password, "password");764 setContextEntry(null);765 }}766 >767 Copy password768 </button>769 </Show>770 <Show when={contextEntry()!.totp}>771 <button772 role="menuitem"773 class="peach-context-menu-item"774 onClick={async () => {775 const e = contextEntry();776 if (!e?.totp) return;777 await popupCopy(e.totp, "TOTP secret");778 setContextEntry(null);779 }}780 >781 Copy TOTP secret782 </button>783 </Show>784 <button785 role="menuitem"786 class="peach-context-menu-item"787 onClick={() => {788 const e = contextEntry();789 if (e) props.onSelectEntry(e);790 setContextEntry(null);791 }}792 >793 Open794 </button>795 <Show when={props.onDeleteMany}>796 <button797 role="menuitem"798 class="peach-context-menu-item peach-context-menu-item--danger"799 onClick={async () => {800 const e = contextEntry();801 if (!e) return;802 setContextEntry(null);803 await props.onDeleteMany?.([e.id]);804 }}805 >806 Move to trash807 </button>808 </Show>809 <button810 role="menuitem"811 class="peach-context-menu-item peach-context-menu-item--cancel"812 onClick={() => setContextEntry(null)}813 >814 Cancel815 </button>816 </div>817 </div>818 </Show>819 </div>820 );821}