Vault.tsx

28.4 KB · raw

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 { /* unsupported */ }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  // Note/passkey entries keep their semantic glyphs. Login entries get a16  // colored-initials chip when the real favicon hasn't loaded — better than17  // a black square on freshly-imported entries the browser hasn't visited18  // yet (Chrome's `_favicon/` cache is empty for unvisited domains).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      />35    </Show>36  );37}3839/**40 * Title → "PM"-style initials. First letters of the first two words,41 * falling back to the first two characters of a single-word title. Empty42 * titles get a small dot. Deterministic so the same entry always renders43 * the same chip.44 */45function 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}5556/**57 * Hash a string to an HSL background. Fixed saturation + lightness so the58 * palette stays muted and dark enough that white initials read clearly on59 * top, while still giving each entry a recognisable colour signature.60 */61function 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}818283const PasskeyIcon = () => (84  <svg width="11" height="12" viewBox="0 0 11 12" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round" aria-hidden="true">85    <circle cx="4" cy="4.5" r="3" />86    <path d="M6.5 7l3.5 3.5M8.5 8.5L10 10" />87  </svg>88);8990const NoteIcon = () => (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    <rect x="1.5" y="1.5" width="8" height="9" rx="1.5" />93    <path d="M3.5 4.5h4M3.5 6.5h4M3.5 8.5h2" />94  </svg>95);9697export type SortOrder = "url-first" | "az" | "za" | "recent";9899interface VaultProps {100  entries: VaultEntry[];101  selectedEntryId?: string;102  currentTabDomain?: string;103  sortOrder: SortOrder;104  searchSeed?: string;105  searchSeedNonce?: number;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    // Right-swipe-to-copy: only on compact + login entries with a password169    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  function fuzzyMatch(query: string, target: string): boolean {246    query = query.toLowerCase();247    target = target.toLowerCase();248    let qi = 0;249    for (let i = 0; i < target.length && qi < query.length; i++) {250      if (target[i] === query[qi]) qi++;251    }252    return qi === query.length;253  }254255  function isExactPrefix(query: string, target: string): boolean {256    return target.toLowerCase().startsWith(query.toLowerCase());257  }258259  function entryMatchesDomain(entry: VaultEntry, domain: string): boolean {260    if (!domain) return false;261    if (entry.passkey?.rpId === domain) return true;262    if (entry.type !== "login") return false;263    // Tab domain is the eTLD+1 from extractDomain — compare against eTLD+1 of264    // each entry URL too so subdomain entries also match.265    if (entry.url && extractDomain(entry.url) === domain) return true;266    for (const extra of entry.additionalUrls ?? []) {267      if (extractDomain(extra) === domain) return true;268    }269    return false;270  }271272  const filteredEntries = createMemo(() => {273    let result = props.entries;274275    if (notesOnly()) {276      result = result.filter(entry => entry.type === "note");277    }278279    if (selectedTags().size > 0) {280      result = result.filter(entry =>281        Array.from(selectedTags()).every(tag => entry.tags?.includes(tag))282      );283    }284285    const q = search().trim();286    if (q) {287      const searchableFor = (e: VaultEntry): string => {288        if (e.type === "note") return `${e.title} ${e.notes ?? ""}`;289        const passkeyText = e.passkey290          ? ` ${e.passkey.rpId} ${e.passkey.userName} ${e.passkey.userDisplayName}`291          : "";292        return `${e.title} ${e.url ?? ""} ${e.username ?? ""}${passkeyText}`;293      };294      result = result295        .filter((entry) => fuzzyMatch(q, searchableFor(entry)))296        .sort((a, b) => {297          const aSearchable = searchableFor(a).toLowerCase();298          const bSearchable = searchableFor(b).toLowerCase();299          const qLower = q.toLowerCase();300          const aExact = isExactPrefix(qLower, aSearchable);301          const bExact = isExactPrefix(qLower, bSearchable);302          if (aExact && !bExact) return -1;303          if (!aExact && bExact) return 1;304          return 0;305        });306      return result;307    }308309    const tabDomain = props.currentTabDomain ?? "";310    const order = props.sortOrder;311312    if (order === "az") {313      return [...result].sort((a, b) => a.title.localeCompare(b.title));314    }315    if (order === "za") {316      return [...result].sort((a, b) => b.title.localeCompare(a.title));317    }318    if (order === "recent") {319      return [...result].sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0));320    }321    if (tabDomain) {322      return [...result].sort((a, b) => {323        const aMatch = entryMatchesDomain(a, tabDomain) ? 0 : 1;324        const bMatch = entryMatchesDomain(b, tabDomain) ? 0 : 1;325        return aMatch - bMatch;326      });327    }328    return result;329  });330331  // Declaration order matters: `allVisibleIds` and `allSelected` read332  // `filteredEntries` eagerly on component mount via createMemo. Moving333  // `filteredEntries` below them throws ReferenceError under the production334  // minifier and freezes the vault list on mount (v1.4.34 regression).335  const allVisibleIds = createMemo(() => filteredEntries().map(e => e.id));336  const allSelected = createMemo(() =>337    allVisibleIds().length > 0 && allVisibleIds().every(id => selectedIds().has(id))338  );339340  function toggleSelectAll() {341    if (allSelected()) {342      setSelectedIds(new Set<string>());343    } else {344      setSelectedIds(new Set<string>(allVisibleIds()));345    }346  }347348  async function handleDeleteSelected() {349    const ids = Array.from(selectedIds());350    if (!ids.length || !props.onDeleteMany) return;351    setDeleting(true);352    await props.onDeleteMany(ids);353    setDeleting(false);354    exitSelectMode();355  }356357  // Virtualization: under VIRTUAL_THRESHOLD entries we render the full list.358  // Above it, we render only a window around the visible viewport plus a359  // BUFFER_ROWS-tall buffer above and below so scroll never reveals empty360  // space. Row height is approximate (variable padding for badges) but361  // bounded — the buffer absorbs over/undershoot.362  const VIRTUAL_THRESHOLD = 100;363  const ROW_HEIGHT = 56;364  const BUFFER_ROWS = 20;365366  const isVirtualized = createMemo(() => filteredEntries().length > VIRTUAL_THRESHOLD);367368  const visibleRange = createMemo(() => {369    const total = filteredEntries().length;370    if (!isVirtualized()) return { start: 0, end: total };371    const start = Math.max(0, Math.floor(scrollTop() / ROW_HEIGHT) - BUFFER_ROWS);372    const end = Math.min(total, Math.ceil((scrollTop() + viewportHeight()) / ROW_HEIGHT) + BUFFER_ROWS);373    return { start, end };374  });375376  const windowedEntries = createMemo(() => {377    const { start, end } = visibleRange();378    return filteredEntries().slice(start, end);379  });380381  function handleListScroll(e: Event) {382    if (!isVirtualized()) return;383    const target = e.currentTarget as HTMLDivElement;384    setScrollTop(target.scrollTop);385    setViewportHeight(target.clientHeight);386  }387388  function handleSearchKeydown(e: KeyboardEvent) {389    if (e.key === "ArrowDown") {390      e.preventDefault();391      const firstBtn = listRef?.querySelector<HTMLButtonElement>(".peach-entry-row");392      firstBtn?.focus();393    } else if (e.key === "ArrowUp") {394      e.preventDefault();395      const allBtns = listRef?.querySelectorAll<HTMLButtonElement>(".peach-entry-row");396      allBtns?.[allBtns.length - 1]?.focus();397    } else if (e.key === "Enter") {398      e.preventDefault();399      const firstEntry = filteredEntries()[0];400      if (firstEntry) props.onSelectEntry(firstEntry);401    } else if (e.key === "Escape" && selecting()) {402      exitSelectMode();403    }404  }405406  function handleListKeydown(e: KeyboardEvent) {407    if (!listRef) return;408    const btns = Array.from(listRef.querySelectorAll<HTMLButtonElement>(".peach-entry-row"));409    const idx = btns.indexOf(document.activeElement as HTMLButtonElement);410    if (e.key === "ArrowDown") {411      e.preventDefault();412      if (idx < btns.length - 1) btns[idx + 1]?.focus();413    } else if (e.key === "ArrowUp") {414      e.preventDefault();415      if (idx === 0) { searchRef?.focus(); }416      else btns[idx - 1]?.focus();417    } else if (e.key === "Escape") {418      if (selecting()) exitSelectMode();419      else props.onClose();420    }421  }422423  const sortLabels: { key: SortOrder; label: string }[] = [424    { key: "url-first", label: "smart" },425    { key: "az", label: "A–Z" },426    { key: "za", label: "Z–A" },427    { key: "recent", label: "newest" },428  ];429430  return (431    <div class="peach-overlay-view ov-enter-up" style="display:flex">432      <div class="peach-list-top">433        <button434          class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"435          onClick={props.onClose}436          aria-label="Back"437        >438          <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">439            <path d="M6 1L1 6L6 11" />440          </svg>441        </button>442        <input443          class="peach-search-input"444          placeholder="search vault..."445          autocomplete="off"446          style="font-size:14px"447          ref={focusSearch}448          value={search()}449          onInput={(e) => setSearch((e.currentTarget as HTMLInputElement).value)}450          onKeyDown={handleSearchKeydown}451        />452        <span class="peach-search-counter peach-search-counter-list">453          {search() || selectedTags().size > 0454            ? `${filteredEntries().length} of ${props.entries.length}`455            : `${props.entries.length} entries`}456        </span>457      </div>458459      {/* Sort bar — morphs into select-mode controls */}460      <div class="peach-sort-bar">461        <Show462          when={selecting()}463          fallback={464            <>465              <For each={sortLabels}>466                {({ key, label }) => (467                  <button468                    class={`peach-sort-btn${props.sortOrder === key ? " is-active" : ""}`}469                    onClick={() => props.onSortOrderChange(key)}470                  >471                    {label}472                  </button>473                )}474              </For>475              <span style="flex:1" />476              <button477                class={`peach-sort-trash-btn${notesOnly() ? " is-active" : ""}`}478                onClick={() => setNotesOnly(v => !v)}479                aria-label="Notes only"480                title="Show notes only"481              >482                <NoteIcon />483              </button>484              <Show when={props.onOpenTrash}>485                <button486                  class="peach-sort-trash-btn"487                  onClick={props.onOpenTrash}488                  aria-label="View trash"489                  title="View deleted entries"490                >491                  <svg width="11" height="12" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">492                    <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />493                  </svg>494                </button>495              </Show>496              <Show when={props.onDeleteMany}>497                <button498                  class="peach-sort-trash-btn"499                  onClick={enterSelectMode}500                  aria-label="Select entries"501                  title="Select entries"502                >503                  <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">504                    <circle cx="4" cy="4" r="2.5" />505                    <circle cx="9" cy="4" r="2.5" />506                    <circle cx="4" cy="9" r="2.5" />507                    <circle cx="9" cy="9" r="2.5" />508                  </svg>509                </button>510              </Show>511            </>512          }513        >514          {/* Select mode sort bar */}515          <button516            class="peach-select-cancel-btn"517            onClick={exitSelectMode}518          >519            cancel520          </button>521          <span class="peach-select-count-label">522            {selectedIds().size > 0 ? `${selectedIds().size} selected` : "select entries"}523          </span>524          <button525            class="peach-select-all-btn"526            onClick={toggleSelectAll}527          >528            {allSelected() ? "none" : "all"}529          </button>530        </Show>531      </div>532533      <Show when={hasTags() && !selecting()}>534        <div class="peach-tag-filter-strip" style="display:flex;gap:6px;padding:4px 12px 6px;overflow-x:auto;flex-wrap:nowrap;scrollbar-width:none;">535          <For each={allTags()}>536            {(tag) => (537              <button538                class={`peach-tag-filter-chip ${selectedTags().has(tag) ? "is-selected" : ""}`}539                onClick={() => toggleTag(tag)}540              >541                {tag}542              </button>543            )}544          </For>545        </div>546      </Show>547548      <div549        class={`peach-vault-list${selecting() ? " is-selecting" : ""}`}550        ref={(el) => { listRef = el; setViewportHeight(el.clientHeight); }}551        onKeyDown={handleListKeydown}552        onScroll={handleListScroll}553      >554        <Show555          when={filteredEntries().length > 0}556          fallback={557            <div class="peach-list-empty">558              <span>no entries yet</span>559            </div>560          }561        >562          <Show when={isVirtualized()}>563            <div style={{ height: `${visibleRange().start * ROW_HEIGHT}px` }} aria-hidden="true" />564          </Show>565          <For each={windowedEntries()}>566            {(entry, localIndex) => {567              const index = () => visibleRange().start + localIndex();568              const isNote = entry.type === "note";569              const isPasskey = entry.passkey !== undefined;570              const domain = entry.type === "note"571                ? (entry.notes?.slice(0, 40) ?? "note")572                : entry.url573                  ? getDisplayDomain(entry.url)574                  : entry.passkey?.rpId575                    ? entry.passkey.rpId576                  : "saved entry";577              const isTabMatch = () =>578                props.currentTabDomain !== undefined &&579                props.currentTabDomain !== "" &&580                entryMatchesDomain(entry, props.currentTabDomain);581              const isSelected = () => selectedIds().has(entry.id);582583              const ariaLabel = (() => {584                const tag = isPasskey ? "passkey" : isNote ? "note" : "login";585                const sub = entry.username || domain || "";586                return sub587                  ? `${entry.title} — ${sub} (${tag})`588                  : `${entry.title} (${tag})`;589              })();590              return (591                <button592                  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" : ""}`}593                  style={isVirtualized()594                    ? { animation: "none" }595                    : { "animation-delay": `${Math.min(index(), 24) * 20}ms` }596                  }597                  aria-label={ariaLabel}598                  aria-pressed={selecting() ? isSelected() : undefined}599                  onTouchStart={(e) => handleRowTouchStart(entry, e)}600                  onTouchMove={handleRowTouchMove}601                  onTouchEnd={(e) => void handleRowTouchEnd(entry, e)}602                  onTouchCancel={() => { clearLongPressTimer(); touchStart = null; }}603                  onContextMenu={(e) => { e.preventDefault(); if (!selecting()) setContextEntry(entry); }}604                  onClick={() => {605                    if (suppressNextClick) { suppressNextClick = false; return; }606                    if (selecting()) {607                      toggleId(entry.id);608                    } else {609                      props.onSelectEntry(entry);610                    }611                  }}612                >613                  <div class={`peach-entry-row-icon${!selecting() && entry.breach ? " is-breach" : ""}${selecting() ? " is-select-icon" : ""}`}>614                    <Show615                      when={selecting()}616                      fallback={617                        <FaviconIcon618                          domain={entry.url ? getDisplayDomain(entry.url) : ""}619                          title={entry.title}620                          isNote={isNote}621                          isPasskey={isPasskey}622                        />623                      }624                    >625                      {/* Check indicator */}626                      <svg width="16" height="16" viewBox="0 0 16 16" fill="none">627                        <circle628                          cx="8" cy="8" r="7"629                          stroke="currentColor"630                          stroke-width="1"631                          fill={isSelected() ? "var(--peach-accent)" : "transparent"}632                          opacity={isSelected() ? "1" : "0.3"}633                        />634                        <Show when={isSelected()}>635                          <path636                            d="M5 8.5L7 10.5L11 6"637                            stroke="white"638                            stroke-width="1.2"639                            stroke-linecap="round"640                            stroke-linejoin="round"641                          />642                        </Show>643                      </svg>644                    </Show>645                  </div>646                  <div class="peach-entry-row-info">647                    <span class="peach-entry-row-name">648                      {entry.title}649                      <Show when={isPasskey && !selecting()}>650                        <span class="peach-passkey-badge">passkey</span>651                      </Show>652                      <Show when={isTabMatch() && !selecting()}>653                        <span class="peach-tab-match-badge" title="Matches current tab">●</span>654                      </Show>655                    </span>656                    <span class="peach-entry-row-meta">{domain}</span>657                  </div>658                  <Show when={entry.breach && !selecting()}>659                    <span class="peach-entry-row-dot" aria-label="breach"></span>660                  </Show>661                  <Show when={entry.canary && !selecting()}>662                    <span class="peach-entry-row-canary" title={`Canary: ${entry.canary?.label ?? "canary entry"}`}>🪤</span>663                  </Show>664                </button>665              );666            }}667          </For>668          <Show when={isVirtualized()}>669            <div670              style={{ height: `${(filteredEntries().length - visibleRange().end) * ROW_HEIGHT}px` }}671              aria-hidden="true"672            />673          </Show>674        </Show>675      </div>676677      {/* Bottom bar — new entry normally, delete action in select mode */}678      <Show679        when={selecting()}680        fallback={681          <button class="peach-new-entry-row" onClick={props.onAdd}>682            <svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round">683              <path d="M5 1v8M1 5h8" />684            </svg>685            new entry686          </button>687        }688      >689        <button690          class={`peach-select-action-bar${selectedIds().size === 0 ? " is-empty" : ""}`}691          disabled={selectedIds().size === 0 || deleting()}692          onClick={() => void handleDeleteSelected()}693        >694          <svg width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">695            <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />696          </svg>697          <Show698            when={selectedIds().size > 0}699            fallback={<span>select entries to delete</span>}700          >701            <span>702              {deleting()703                ? "moving to trash…"704                : `move ${selectedIds().size} to trash`}705            </span>706          </Show>707        </button>708      </Show>709710      <Show when={contextEntry() !== null}>711        <div712          class="peach-context-menu-backdrop"713          onClick={() => setContextEntry(null)}714          role="presentation"715        >716          <div717            class="peach-context-menu"718            role="menu"719            aria-label="Entry actions"720            onClick={(e) => e.stopPropagation()}721          >722            <Show when={contextEntry()!.username}>723              <button724                role="menuitem"725                class="peach-context-menu-item"726                onClick={async () => {727                  const e = contextEntry();728                  if (!e?.username) return;729                  await popupCopy(e.username, "username");730                  setContextEntry(null);731                }}732              >733                Copy username734              </button>735            </Show>736            <Show when={contextEntry()!.password}>737              <button738                role="menuitem"739                class="peach-context-menu-item"740                onClick={async () => {741                  const e = contextEntry();742                  if (!e?.password) return;743                  await popupCopy(e.password, "password");744                  setContextEntry(null);745                }}746              >747                Copy password748              </button>749            </Show>750            <Show when={contextEntry()!.totp}>751              <button752                role="menuitem"753                class="peach-context-menu-item"754                onClick={async () => {755                  const e = contextEntry();756                  if (!e?.totp) return;757                  await popupCopy(e.totp, "TOTP secret");758                  setContextEntry(null);759                }}760              >761                Copy TOTP secret762              </button>763            </Show>764            <button765              role="menuitem"766              class="peach-context-menu-item"767              onClick={() => {768                const e = contextEntry();769                if (e) props.onSelectEntry(e);770                setContextEntry(null);771              }}772            >773              Open774            </button>775            <Show when={props.onDeleteMany}>776              <button777                role="menuitem"778                class="peach-context-menu-item peach-context-menu-item--danger"779                onClick={async () => {780                  const e = contextEntry();781                  if (!e) return;782                  setContextEntry(null);783                  await props.onDeleteMany?.([e.id]);784                }}785              >786                Move to trash787              </button>788            </Show>789            <button790              role="menuitem"791              class="peach-context-menu-item peach-context-menu-item--cancel"792              onClick={() => setContextEntry(null)}793            >794              Cancel795            </button>796          </div>797        </div>798      </Show>799    </div>800  );801}