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 ? <PasskeyIcon />21 : props.isNote22 ? <NoteIcon />23 : <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 onLoad={(e) => {35 36 37 38 39 if ((e.currentTarget as HTMLImageElement).naturalWidth <= 16) setFailed(true);40 }}41 />42 </Show>43 );44}4546474849505152function titleInitials(source: string): string {53 const cleaned = source.replace(/^https?:\/\//, "").replace(/^www\./, "").trim();54 if (!cleaned) return "·";55 const words = cleaned.split(/[\s.\-_/]+/).filter(Boolean);56 if (words.length >= 2 && words[0]![0] && words[1]![0]) {57 return (words[0]![0]! + words[1]![0]!).toUpperCase();58 }59 const single = words[0] ?? cleaned;60 return single.slice(0, 2).toUpperCase();61}62636465666768function titleBackground(source: string): string {69 let hash = 0;70 for (let i = 0; i < source.length; i++) {71 hash = ((hash << 5) - hash + source.charCodeAt(i)) | 0;72 }73 const hue = ((hash % 360) + 360) % 360;74 return `hsl(${hue}, 32%, 26%)`;75}7677function InitialsChip(props: { title: string }) {78 return (79 <span80 class="peach-favicon-initials"81 style={{ background: titleBackground(props.title) }}82 aria-hidden="true"83 >84 {titleInitials(props.title)}85 </span>86 );87}888990const PasskeyIcon = () => (91 <svg width="11" height="12" viewBox="0 0 11 12" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round" aria-hidden="true">92 <circle cx="4" cy="4.5" r="3" />93 <path d="M6.5 7l3.5 3.5M8.5 8.5L10 10" />94 </svg>95);9697const NoteIcon = () => (98 <svg width="11" height="12" viewBox="0 0 11 12" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round" aria-hidden="true">99 <rect x="1.5" y="1.5" width="8" height="9" rx="1.5" />100 <path d="M3.5 4.5h4M3.5 6.5h4M3.5 8.5h2" />101 </svg>102);103104export type SortOrder = "url-first" | "az" | "za" | "recent";105106interface VaultProps {107 entries: VaultEntry[];108 selectedEntryId?: string;109 currentTabDomain?: string;110 sortOrder: SortOrder;111 searchSeed?: string;112 searchSeedNonce?: number;113 114 115 116 isNative?: boolean;117 onSelectEntry: (entry: VaultEntry) => void;118 onSortOrderChange: (order: SortOrder) => void;119 onAdd: () => void;120 onClose: () => void;121 onOpenTrash?: () => void;122 onDeleteMany?: (ids: string[]) => Promise<void>;123}124125export function VaultView(props: VaultProps) {126 const [search, setSearch] = createSignal("");127 const [selectedTags, setSelectedTags] = createSignal<Set<string>>(new Set());128 const [notesOnly, setNotesOnly] = createSignal(false);129 const [selecting, setSelecting] = createSignal(false);130 const [selectedIds, setSelectedIds] = createSignal<Set<string>>(new Set());131 const [deleting, setDeleting] = createSignal(false);132 const [contextEntry, setContextEntry] = createSignal<VaultEntry | null>(null);133 const [flashId, setFlashId] = createSignal<string | null>(null);134 const [scrollTop, setScrollTop] = createSignal(0);135 const [viewportHeight, setViewportHeight] = createSignal(600);136 let longPressTimer: number | null = null;137 let touchStart: { x: number; y: number; t: number } | null = null;138 let suppressNextClick = false;139140 function clearLongPressTimer() {141 if (longPressTimer !== null) {142 window.clearTimeout(longPressTimer);143 longPressTimer = null;144 }145 }146147 function handleRowTouchStart(entry: VaultEntry, e: TouchEvent) {148 if (selecting()) return;149 const t = e.touches[0];150 if (!t) return;151 touchStart = { x: t.clientX, y: t.clientY, t: Date.now() };152 clearLongPressTimer();153 longPressTimer = window.setTimeout(() => {154 suppressNextClick = true;155 vibrate(20);156 setContextEntry(entry);157 longPressTimer = null;158 }, 500);159 }160161 function handleRowTouchMove(e: TouchEvent) {162 if (!touchStart) return;163 const t = e.touches[0];164 if (!t) return;165 const dx = Math.abs(t.clientX - touchStart.x);166 const dy = Math.abs(t.clientY - touchStart.y);167 if (dx > 8 || dy > 8) clearLongPressTimer();168 }169170 async function handleRowTouchEnd(entry: VaultEntry, e: TouchEvent) {171 clearLongPressTimer();172 if (!touchStart) return;173 const t = e.changedTouches[0];174 if (!t) { touchStart = null; return; }175 const dx = t.clientX - touchStart.x;176 const dy = Math.abs(t.clientY - touchStart.y);177 const dt = Date.now() - touchStart.t;178 touchStart = null;179 180 const isCompact = !document.body.classList.contains("layout-medium")181 && !document.body.classList.contains("layout-expanded");182 if (183 isCompact184 && !selecting()185 && dx > 80186 && dy < 30187 && dt < 400188 && entry.type === "login"189 && entry.password190 ) {191 suppressNextClick = true;192 vibrate(15);193 setFlashId(entry.id);194 window.setTimeout(() => setFlashId((id) => id === entry.id ? null : id), 600);195 await popupCopy(entry.password, "password");196 }197 }198199 let searchRef: HTMLInputElement | undefined;200 let listRef: HTMLDivElement | undefined;201202 const focusSearch = (el: HTMLInputElement) => {203 searchRef = el;204 setTimeout(() => el.focus(), 50);205 };206207 createEffect(() => {208 void props.searchSeedNonce;209 const seed = props.searchSeed;210 if (!seed) return;211 setSearch(seed);212 requestAnimationFrame(() => {213 searchRef?.focus();214 searchRef?.setSelectionRange(seed.length, seed.length);215 });216 });217218 function enterSelectMode() {219 setSelecting(true);220 setSelectedIds(new Set<string>());221 }222223 function exitSelectMode() {224 setSelecting(false);225 setSelectedIds(new Set<string>());226 }227228 function toggleId(id: string) {229 const s = new Set<string>(selectedIds());230 if (s.has(id)) s.delete(id); else s.add(id);231 setSelectedIds(s);232 }233234 const allTags = createMemo(() => {235 const tagSet = new Set<string>();236 for (const entry of props.entries) {237 for (const tag of entry.tags ?? []) {238 tagSet.add(tag);239 }240 }241 return Array.from(tagSet).sort();242 });243244 const hasTags = createMemo(() => allTags().length > 0);245246 function toggleTag(tag: string) {247 const current = new Set(selectedTags());248 if (current.has(tag)) {249 current.delete(tag);250 } else {251 current.add(tag);252 }253 setSelectedTags(current);254 }255256 function fuzzyMatch(query: string, target: string): boolean {257 query = query.toLowerCase();258 target = target.toLowerCase();259 let qi = 0;260 for (let i = 0; i < target.length && qi < query.length; i++) {261 if (target[i] === query[qi]) qi++;262 }263 return qi === query.length;264 }265266 function isExactPrefix(query: string, target: string): boolean {267 return target.toLowerCase().startsWith(query.toLowerCase());268 }269270 function entryMatchesDomain(entry: VaultEntry, domain: string): boolean {271 if (!domain) return false;272 if (entry.passkey?.rpId === domain) return true;273 if (entry.type !== "login") return false;274 275 276 if (entry.url && extractDomain(entry.url) === domain) return true;277 for (const extra of entry.additionalUrls ?? []) {278 if (extractDomain(extra) === domain) return true;279 }280 return false;281 }282283 const filteredEntries = createMemo(() => {284 let result = props.entries;285286 if (notesOnly()) {287 result = result.filter(entry => entry.type === "note");288 }289290 if (selectedTags().size > 0) {291 result = result.filter(entry =>292 Array.from(selectedTags()).every(tag => entry.tags?.includes(tag))293 );294 }295296 const q = search().trim();297 if (q) {298 const searchableFor = (e: VaultEntry): string => {299 if (e.type === "note") return `${e.title} ${e.notes ?? ""}`;300 const passkeyText = e.passkey301 ? ` ${e.passkey.rpId} ${e.passkey.userName} ${e.passkey.userDisplayName}`302 : "";303 return `${e.title} ${e.url ?? ""} ${e.username ?? ""}${passkeyText}`;304 };305 result = result306 .filter((entry) => fuzzyMatch(q, searchableFor(entry)))307 .sort((a, b) => {308 const aSearchable = searchableFor(a).toLowerCase();309 const bSearchable = searchableFor(b).toLowerCase();310 const qLower = q.toLowerCase();311 const aExact = isExactPrefix(qLower, aSearchable);312 const bExact = isExactPrefix(qLower, bSearchable);313 if (aExact && !bExact) return -1;314 if (!aExact && bExact) return 1;315 return 0;316 });317 return result;318 }319320 const tabDomain = props.currentTabDomain ?? "";321 const order = props.sortOrder;322323 if (order === "az") {324 return [...result].sort((a, b) => a.title.localeCompare(b.title));325 }326 if (order === "za") {327 return [...result].sort((a, b) => b.title.localeCompare(a.title));328 }329 if (order === "recent") {330 return [...result].sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0));331 }332 if (tabDomain) {333 return [...result].sort((a, b) => {334 const aMatch = entryMatchesDomain(a, tabDomain) ? 0 : 1;335 const bMatch = entryMatchesDomain(b, tabDomain) ? 0 : 1;336 return aMatch - bMatch;337 });338 }339 return result;340 });341342 343 344 345 346 const allVisibleIds = createMemo(() => filteredEntries().map(e => e.id));347 const allSelected = createMemo(() =>348 allVisibleIds().length > 0 && allVisibleIds().every(id => selectedIds().has(id))349 );350351 function toggleSelectAll() {352 if (allSelected()) {353 setSelectedIds(new Set<string>());354 } else {355 setSelectedIds(new Set<string>(allVisibleIds()));356 }357 }358359 async function handleDeleteSelected() {360 const ids = Array.from(selectedIds());361 if (!ids.length || !props.onDeleteMany) return;362 setDeleting(true);363 await props.onDeleteMany(ids);364 setDeleting(false);365 exitSelectMode();366 }367368 369 370 371 372 373 const VIRTUAL_THRESHOLD = 100;374 const ROW_HEIGHT = 56;375 const BUFFER_ROWS = 20;376377 const isVirtualized = createMemo(() => filteredEntries().length > VIRTUAL_THRESHOLD);378379 const visibleRange = createMemo(() => {380 const total = filteredEntries().length;381 if (!isVirtualized()) return { start: 0, end: total };382 const start = Math.max(0, Math.floor(scrollTop() / ROW_HEIGHT) - BUFFER_ROWS);383 const end = Math.min(total, Math.ceil((scrollTop() + viewportHeight()) / ROW_HEIGHT) + BUFFER_ROWS);384 return { start, end };385 });386387 const windowedEntries = createMemo(() => {388 const { start, end } = visibleRange();389 return filteredEntries().slice(start, end);390 });391392 function handleListScroll(e: Event) {393 if (!isVirtualized()) return;394 const target = e.currentTarget as HTMLDivElement;395 setScrollTop(target.scrollTop);396 setViewportHeight(target.clientHeight);397 }398399 function handleSearchKeydown(e: KeyboardEvent) {400 if (e.key === "ArrowDown") {401 e.preventDefault();402 const firstBtn = listRef?.querySelector<HTMLButtonElement>(".peach-entry-row");403 firstBtn?.focus();404 } else if (e.key === "ArrowUp") {405 e.preventDefault();406 const allBtns = listRef?.querySelectorAll<HTMLButtonElement>(".peach-entry-row");407 allBtns?.[allBtns.length - 1]?.focus();408 } else if (e.key === "Enter") {409 e.preventDefault();410 const firstEntry = filteredEntries()[0];411 if (firstEntry) props.onSelectEntry(firstEntry);412 } else if (e.key === "Escape" && selecting()) {413 exitSelectMode();414 }415 }416417 function handleListKeydown(e: KeyboardEvent) {418 if (!listRef) return;419 const btns = Array.from(listRef.querySelectorAll<HTMLButtonElement>(".peach-entry-row"));420 const idx = btns.indexOf(document.activeElement as HTMLButtonElement);421 if (e.key === "ArrowDown") {422 e.preventDefault();423 if (idx < btns.length - 1) btns[idx + 1]?.focus();424 } else if (e.key === "ArrowUp") {425 e.preventDefault();426 if (idx === 0) { searchRef?.focus(); }427 else btns[idx - 1]?.focus();428 } else if (e.key === "Escape") {429 if (selecting()) exitSelectMode();430 else props.onClose();431 }432 }433434 const sortLabels: { key: SortOrder; label: string }[] = [435 { key: "url-first", label: "smart" },436 { key: "az", label: "A–Z" },437 { key: "za", label: "Z–A" },438 { key: "recent", label: "newest" },439 ];440441 return (442 <div class="peach-overlay-view ov-enter-up" style="display:flex">443 <div class="peach-list-top" classList={{ "peach-list-top--native": !!props.isNative }}>444 <Show445 when={!props.isNative}446 fallback={447 <span class="peach-search-icon" aria-hidden="true">448 <svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round">449 <circle cx="5.5" cy="5.5" r="3.75" />450 <path d="M8.75 8.75L11.5 11.5" />451 </svg>452 </span>453 }454 >455 <button456 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"457 onClick={props.onClose}458 aria-label="Back"459 >460 <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">461 <path d="M6 1L1 6L6 11" />462 </svg>463 </button>464 </Show>465 <input466 class="peach-search-input"467 placeholder="search vault..."468 autocomplete="off"469 style={props.isNative ? undefined : "font-size:14px"}470 ref={focusSearch}471 value={search()}472 onInput={(e) => setSearch((e.currentTarget as HTMLInputElement).value)}473 onKeyDown={handleSearchKeydown}474 />475 <span class="peach-search-counter peach-search-counter-list">476 {search() || selectedTags().size > 0477 ? `${filteredEntries().length} of ${props.entries.length}`478 : props.isNative479 ? `${props.entries.length}`480 : `${props.entries.length} entries`}481 </span>482 </div>483484 {}485 <div class="peach-sort-bar">486 <Show487 when={selecting()}488 fallback={489 <>490 <For each={sortLabels}>491 {({ key, label }) => (492 <button493 class={`peach-sort-btn${props.sortOrder === key ? " is-active" : ""}`}494 onClick={() => props.onSortOrderChange(key)}495 >496 {label}497 </button>498 )}499 </For>500 <span style="flex:1" />501 <button502 class={`peach-sort-trash-btn${notesOnly() ? " is-active" : ""}`}503 onClick={() => setNotesOnly(v => !v)}504 aria-label="Notes only"505 title="Show notes only"506 >507 <NoteIcon />508 </button>509 <Show when={props.onOpenTrash}>510 <button511 class="peach-sort-trash-btn"512 onClick={props.onOpenTrash}513 aria-label="View trash"514 title="View deleted entries"515 >516 <svg width="11" height="12" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">517 <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />518 </svg>519 </button>520 </Show>521 <Show when={props.onDeleteMany}>522 <button523 class="peach-sort-trash-btn"524 onClick={enterSelectMode}525 aria-label="Select entries"526 title="Select entries"527 >528 <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">529 <circle cx="4" cy="4" r="2.5" />530 <circle cx="9" cy="4" r="2.5" />531 <circle cx="4" cy="9" r="2.5" />532 <circle cx="9" cy="9" r="2.5" />533 </svg>534 </button>535 </Show>536 </>537 }538 >539 {}540 <button541 class="peach-select-cancel-btn"542 onClick={exitSelectMode}543 >544 cancel545 </button>546 <span class="peach-select-count-label">547 {selectedIds().size > 0 ? `${selectedIds().size} selected` : "select entries"}548 </span>549 <button550 class="peach-select-all-btn"551 onClick={toggleSelectAll}552 >553 {allSelected() ? "none" : "all"}554 </button>555 </Show>556 </div>557558 <Show when={hasTags() && !selecting()}>559 <div class="peach-tag-filter-strip" style="display:flex;gap:6px;padding:4px 12px 6px;overflow-x:auto;flex-wrap:nowrap;scrollbar-width:none;">560 <For each={allTags()}>561 {(tag) => (562 <button563 class={`peach-tag-filter-chip ${selectedTags().has(tag) ? "is-selected" : ""}`}564 onClick={() => toggleTag(tag)}565 >566 {tag}567 </button>568 )}569 </For>570 </div>571 </Show>572573 <div574 class={`peach-vault-list${selecting() ? " is-selecting" : ""}`}575 ref={(el) => { listRef = el; setViewportHeight(el.clientHeight); }}576 onKeyDown={handleListKeydown}577 onScroll={handleListScroll}578 >579 <Show580 when={filteredEntries().length > 0}581 fallback={582 <div class="peach-list-empty">583 <span>no entries yet</span>584 </div>585 }586 >587 <Show when={isVirtualized()}>588 <div style={{ height: `${visibleRange().start * ROW_HEIGHT}px` }} aria-hidden="true" />589 </Show>590 <For each={windowedEntries()}>591 {(entry, localIndex) => {592 const index = () => visibleRange().start + localIndex();593 const isNote = entry.type === "note";594 const isPasskey = entry.passkey !== undefined;595 const domain = entry.type === "note"596 ? (entry.notes?.slice(0, 40) ?? "note")597 : entry.url598 ? getDisplayDomain(entry.url)599 : entry.passkey?.rpId600 ? entry.passkey.rpId601 : "saved entry";602 const isTabMatch = () =>603 props.currentTabDomain !== undefined &&604 props.currentTabDomain !== "" &&605 entryMatchesDomain(entry, props.currentTabDomain);606 const isSelected = () => selectedIds().has(entry.id);607608 const ariaLabel = (() => {609 const tag = isPasskey ? "passkey" : isNote ? "note" : "login";610 const sub = entry.username || domain || "";611 return sub612 ? `${entry.title} — ${sub} (${tag})`613 : `${entry.title} (${tag})`;614 })();615 return (616 <button617 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" : ""}`}618 style={isVirtualized()619 ? { animation: "none" }620 : { "animation-delay": `${Math.min(index(), 24) * 20}ms` }621 }622 aria-label={ariaLabel}623 aria-pressed={selecting() ? isSelected() : undefined}624 onTouchStart={(e) => handleRowTouchStart(entry, e)}625 onTouchMove={handleRowTouchMove}626 onTouchEnd={(e) => void handleRowTouchEnd(entry, e)}627 onTouchCancel={() => { clearLongPressTimer(); touchStart = null; }}628 onContextMenu={(e) => { e.preventDefault(); if (!selecting()) setContextEntry(entry); }}629 onClick={() => {630 if (suppressNextClick) { suppressNextClick = false; return; }631 if (selecting()) {632 toggleId(entry.id);633 } else {634 props.onSelectEntry(entry);635 }636 }}637 >638 <div class={`peach-entry-row-icon${!selecting() && entry.breach ? " is-breach" : ""}${selecting() ? " is-select-icon" : ""}`}>639 <Show640 when={selecting()}641 fallback={642 <FaviconIcon643 domain={entry.url ? getDisplayDomain(entry.url) : ""}644 title={entry.title}645 isNote={isNote}646 isPasskey={isPasskey}647 />648 }649 >650 {}651 <svg width="16" height="16" viewBox="0 0 16 16" fill="none">652 <circle653 cx="8" cy="8" r="7"654 stroke="currentColor"655 stroke-width="1"656 fill={isSelected() ? "var(--peach-accent)" : "transparent"}657 opacity={isSelected() ? "1" : "0.3"}658 />659 <Show when={isSelected()}>660 <path661 d="M5 8.5L7 10.5L11 6"662 stroke="white"663 stroke-width="1.2"664 stroke-linecap="round"665 stroke-linejoin="round"666 />667 </Show>668 </svg>669 </Show>670 </div>671 <div class="peach-entry-row-info">672 <span class="peach-entry-row-name">673 {entry.title}674 <Show when={isPasskey && !selecting()}>675 <span class="peach-passkey-badge">passkey</span>676 </Show>677 <Show when={isTabMatch() && !selecting()}>678 <span class="peach-tab-match-badge" title="Matches current tab">●</span>679 </Show>680 </span>681 <span class="peach-entry-row-meta">{domain}</span>682 </div>683 <Show when={entry.breach && !selecting()}>684 <span class="peach-entry-row-dot" aria-label="breach"></span>685 </Show>686 <Show when={entry.canary && !selecting()}>687 <span class="peach-entry-row-canary" title={`Canary: ${entry.canary?.label ?? "canary entry"}`}>🪤</span>688 </Show>689 </button>690 );691 }}692 </For>693 <Show when={isVirtualized()}>694 <div695 style={{ height: `${(filteredEntries().length - visibleRange().end) * ROW_HEIGHT}px` }}696 aria-hidden="true"697 />698 </Show>699 </Show>700 </div>701702 {}703 <Show704 when={selecting()}705 fallback={706 <button class="peach-new-entry-row" onClick={props.onAdd}>707 <svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round">708 <path d="M5 1v8M1 5h8" />709 </svg>710 new entry711 </button>712 }713 >714 <button715 class={`peach-select-action-bar${selectedIds().size === 0 ? " is-empty" : ""}`}716 disabled={selectedIds().size === 0 || deleting()}717 onClick={() => void handleDeleteSelected()}718 >719 <svg width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">720 <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />721 </svg>722 <Show723 when={selectedIds().size > 0}724 fallback={<span>select entries to delete</span>}725 >726 <span>727 {deleting()728 ? "moving to trash…"729 : `move ${selectedIds().size} to trash`}730 </span>731 </Show>732 </button>733 </Show>734735 <Show when={contextEntry() !== null}>736 <div737 class="peach-context-menu-backdrop"738 onClick={() => setContextEntry(null)}739 role="presentation"740 >741 <div742 class="peach-context-menu"743 role="menu"744 aria-label="Entry actions"745 onClick={(e) => e.stopPropagation()}746 >747 <Show when={contextEntry()!.username}>748 <button749 role="menuitem"750 class="peach-context-menu-item"751 onClick={async () => {752 const e = contextEntry();753 if (!e?.username) return;754 await popupCopy(e.username, "username");755 setContextEntry(null);756 }}757 >758 Copy username759 </button>760 </Show>761 <Show when={contextEntry()!.password}>762 <button763 role="menuitem"764 class="peach-context-menu-item"765 onClick={async () => {766 const e = contextEntry();767 if (!e?.password) return;768 await popupCopy(e.password, "password");769 setContextEntry(null);770 }}771 >772 Copy password773 </button>774 </Show>775 <Show when={contextEntry()!.totp}>776 <button777 role="menuitem"778 class="peach-context-menu-item"779 onClick={async () => {780 const e = contextEntry();781 if (!e?.totp) return;782 await popupCopy(e.totp, "TOTP secret");783 setContextEntry(null);784 }}785 >786 Copy TOTP secret787 </button>788 </Show>789 <button790 role="menuitem"791 class="peach-context-menu-item"792 onClick={() => {793 const e = contextEntry();794 if (e) props.onSelectEntry(e);795 setContextEntry(null);796 }}797 >798 Open799 </button>800 <Show when={props.onDeleteMany}>801 <button802 role="menuitem"803 class="peach-context-menu-item peach-context-menu-item--danger"804 onClick={async () => {805 const e = contextEntry();806 if (!e) return;807 setContextEntry(null);808 await props.onDeleteMany?.([e.id]);809 }}810 >811 Move to trash812 </button>813 </Show>814 <button815 role="menuitem"816 class="peach-context-menu-item peach-context-menu-item--cancel"817 onClick={() => setContextEntry(null)}818 >819 Cancel820 </button>821 </div>822 </div>823 </Show>824 </div>825 );826}