Vault.tsx

29.0 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, faviconImgSig, globeFaviconSig, titleInitials, titleBackground } 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  const [isReal, setIsReal] = createSignal(false);1617  // Show InitialsChip (or passkey/note icon) first — no globe flash.18  // Load the favicon invisibly in the background; switch to it only after19  // confirming it differs from Chrome's default "no favicon" globe image.20  return (21    <Show22      when={isReal()}23      fallback={24        <>25          {props.isPasskey26            ? <PasskeyIcon />27            : props.isNote28              ? <NoteIcon />29              : <InitialsChip title={props.title ?? props.domain ?? ""} />}30          {src && (31            <img32              src={src}33              style="display:none"34              onError={() => {}}35              onLoad={async (e) => {36                const img = e.currentTarget as HTMLImageElement;37                // Old Chrome returns the globe at native 16×16 even when38                // size=32 is requested — treat as no favicon.39                if (img.naturalWidth <= 16) return;40                // New Chrome returns the globe scaled to the requested size.41                // Compare pixel data against the known "no favicon" reference.42                const sig = faviconImgSig(img);43                if (sig !== null) {44                  const gSig = await globeFaviconSig();45                  if (gSig !== null && sig === gSig) return;46                }47                setIsReal(true);48              }}49            />50          )}51        </>52      }53    >54      <img src={src!} width="16" height="16" alt="" style="border-radius:3px;" />55    </Show>56  );57}585960function InitialsChip(props: { title: string }) {61  return (62    <span63      class="peach-favicon-initials"64      style={{ background: titleBackground(props.title) }}65      aria-hidden="true"66    >67      {titleInitials(props.title)}68    </span>69  );70}717273const PasskeyIcon = () => (74  <svg width="11" height="12" viewBox="0 0 11 12" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round" aria-hidden="true">75    <circle cx="4" cy="4.5" r="3" />76    <path d="M6.5 7l3.5 3.5M8.5 8.5L10 10" />77  </svg>78);7980const NoteIcon = () => (81  <svg width="11" height="12" viewBox="0 0 11 12" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round" aria-hidden="true">82    <rect x="1.5" y="1.5" width="8" height="9" rx="1.5" />83    <path d="M3.5 4.5h4M3.5 6.5h4M3.5 8.5h2" />84  </svg>85);8687export type SortOrder = "url-first" | "az" | "za" | "recent";8889interface VaultProps {90  entries: VaultEntry[];91  selectedEntryId?: string;92  currentTabDomain?: string;93  sortOrder: SortOrder;94  searchSeed?: string;95  searchSeedNonce?: number;96  // When true, render the mobile flat top bar: no back arrow (use swipe-down97  // on the bottom sheet), search-glass icon on the left, counter on the98  // right. Matches the extension's `.peach-search-bar` look.99  isNative?: boolean;100  onSelectEntry: (entry: VaultEntry) => void;101  onSortOrderChange: (order: SortOrder) => void;102  onAdd: () => void;103  onClose: () => void;104  onOpenTrash?: () => void;105  onDeleteMany?: (ids: string[]) => Promise<void>;106}107108export function VaultView(props: VaultProps) {109  const [search, setSearch] = createSignal("");110  const [selectedTags, setSelectedTags] = createSignal<Set<string>>(new Set());111  const [notesOnly, setNotesOnly] = createSignal(false);112  const [selecting, setSelecting] = createSignal(false);113  const [selectedIds, setSelectedIds] = createSignal<Set<string>>(new Set());114  const [deleting, setDeleting] = createSignal(false);115  const [contextEntry, setContextEntry] = createSignal<VaultEntry | null>(null);116  const [flashId, setFlashId] = createSignal<string | null>(null);117  const [scrollTop, setScrollTop] = createSignal(0);118  const [viewportHeight, setViewportHeight] = createSignal(600);119  let longPressTimer: number | null = null;120  let touchStart: { x: number; y: number; t: number } | null = null;121  let suppressNextClick = false;122123  function clearLongPressTimer() {124    if (longPressTimer !== null) {125      window.clearTimeout(longPressTimer);126      longPressTimer = null;127    }128  }129130  function handleRowTouchStart(entry: VaultEntry, e: TouchEvent) {131    if (selecting()) return;132    const t = e.touches[0];133    if (!t) return;134    touchStart = { x: t.clientX, y: t.clientY, t: Date.now() };135    clearLongPressTimer();136    longPressTimer = window.setTimeout(() => {137      suppressNextClick = true;138      vibrate(20);139      setContextEntry(entry);140      longPressTimer = null;141    }, 500);142  }143144  function handleRowTouchMove(e: TouchEvent) {145    if (!touchStart) return;146    const t = e.touches[0];147    if (!t) return;148    const dx = Math.abs(t.clientX - touchStart.x);149    const dy = Math.abs(t.clientY - touchStart.y);150    if (dx > 8 || dy > 8) clearLongPressTimer();151  }152153  async function handleRowTouchEnd(entry: VaultEntry, e: TouchEvent) {154    clearLongPressTimer();155    if (!touchStart) return;156    const t = e.changedTouches[0];157    if (!t) { touchStart = null; return; }158    const dx = t.clientX - touchStart.x;159    const dy = Math.abs(t.clientY - touchStart.y);160    const dt = Date.now() - touchStart.t;161    touchStart = null;162    // Right-swipe-to-copy: only on compact + login entries with a password163    const isCompact = !document.body.classList.contains("layout-medium")164      && !document.body.classList.contains("layout-expanded");165    if (166      isCompact167      && !selecting()168      && dx > 80169      && dy < 30170      && dt < 400171      && entry.type === "login"172      && entry.password173    ) {174      suppressNextClick = true;175      vibrate(15);176      setFlashId(entry.id);177      window.setTimeout(() => setFlashId((id) => id === entry.id ? null : id), 600);178      await popupCopy(entry.password, "password");179    }180  }181182  let searchRef: HTMLInputElement | undefined;183  let listRef: HTMLDivElement | undefined;184185  const focusSearch = (el: HTMLInputElement) => {186    searchRef = el;187    setTimeout(() => el.focus(), 50);188  };189190  createEffect(() => {191    void props.searchSeedNonce;192    const seed = props.searchSeed;193    if (!seed) return;194    setSearch(seed);195    requestAnimationFrame(() => {196      searchRef?.focus();197      searchRef?.setSelectionRange(seed.length, seed.length);198    });199  });200201  function enterSelectMode() {202    setSelecting(true);203    setSelectedIds(new Set<string>());204  }205206  function exitSelectMode() {207    setSelecting(false);208    setSelectedIds(new Set<string>());209  }210211  function toggleId(id: string) {212    const s = new Set<string>(selectedIds());213    if (s.has(id)) s.delete(id); else s.add(id);214    setSelectedIds(s);215  }216217  const allTags = createMemo(() => {218    const tagSet = new Set<string>();219    for (const entry of props.entries) {220      for (const tag of entry.tags ?? []) {221        tagSet.add(tag);222      }223    }224    return Array.from(tagSet).sort();225  });226227  const hasTags = createMemo(() => allTags().length > 0);228229  function toggleTag(tag: string) {230    const current = new Set(selectedTags());231    if (current.has(tag)) {232      current.delete(tag);233    } else {234      current.add(tag);235    }236    setSelectedTags(current);237  }238239  function fuzzyMatch(query: string, target: string): boolean {240    query = query.toLowerCase();241    target = target.toLowerCase();242    let qi = 0;243    for (let i = 0; i < target.length && qi < query.length; i++) {244      if (target[i] === query[qi]) qi++;245    }246    return qi === query.length;247  }248249  function isExactPrefix(query: string, target: string): boolean {250    return target.toLowerCase().startsWith(query.toLowerCase());251  }252253  function entryMatchesDomain(entry: VaultEntry, domain: string): boolean {254    if (!domain) return false;255    if (entry.passkey?.rpId === domain) return true;256    if (entry.type !== "login") return false;257    // Tab domain is the eTLD+1 from extractDomain — compare against eTLD+1 of258    // each entry URL too so subdomain entries also match.259    if (entry.url && extractDomain(entry.url) === domain) return true;260    for (const extra of entry.additionalUrls ?? []) {261      if (extractDomain(extra) === domain) return true;262    }263    return false;264  }265266  const filteredEntries = createMemo(() => {267    let result = props.entries;268269    if (notesOnly()) {270      result = result.filter(entry => entry.type === "note");271    }272273    if (selectedTags().size > 0) {274      result = result.filter(entry =>275        Array.from(selectedTags()).every(tag => entry.tags?.includes(tag))276      );277    }278279    const q = search().trim();280    if (q) {281      const searchableFor = (e: VaultEntry): string => {282        if (e.type === "note") return `${e.title} ${e.notes ?? ""}`;283        const passkeyText = e.passkey284          ? ` ${e.passkey.rpId} ${e.passkey.userName} ${e.passkey.userDisplayName}`285          : "";286        return `${e.title} ${e.url ?? ""} ${e.username ?? ""}${passkeyText}`;287      };288      result = result289        .filter((entry) => fuzzyMatch(q, searchableFor(entry)))290        .sort((a, b) => {291          const aSearchable = searchableFor(a).toLowerCase();292          const bSearchable = searchableFor(b).toLowerCase();293          const qLower = q.toLowerCase();294          const aExact = isExactPrefix(qLower, aSearchable);295          const bExact = isExactPrefix(qLower, bSearchable);296          if (aExact && !bExact) return -1;297          if (!aExact && bExact) return 1;298          return 0;299        });300      return result;301    }302303    const tabDomain = props.currentTabDomain ?? "";304    const order = props.sortOrder;305306    if (order === "az") {307      return [...result].sort((a, b) => a.title.localeCompare(b.title));308    }309    if (order === "za") {310      return [...result].sort((a, b) => b.title.localeCompare(a.title));311    }312    if (order === "recent") {313      return [...result].sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0));314    }315    if (tabDomain) {316      return [...result].sort((a, b) => {317        const aMatch = entryMatchesDomain(a, tabDomain) ? 0 : 1;318        const bMatch = entryMatchesDomain(b, tabDomain) ? 0 : 1;319        return aMatch - bMatch;320      });321    }322    return result;323  });324325  // Declaration order matters: `allVisibleIds` and `allSelected` read326  // `filteredEntries` eagerly on component mount via createMemo. Moving327  // `filteredEntries` below them throws ReferenceError under the production328  // minifier and freezes the vault list on mount (v1.4.34 regression).329  const allVisibleIds = createMemo(() => filteredEntries().map(e => e.id));330  const allSelected = createMemo(() =>331    allVisibleIds().length > 0 && allVisibleIds().every(id => selectedIds().has(id))332  );333334  function toggleSelectAll() {335    if (allSelected()) {336      setSelectedIds(new Set<string>());337    } else {338      setSelectedIds(new Set<string>(allVisibleIds()));339    }340  }341342  async function handleDeleteSelected() {343    const ids = Array.from(selectedIds());344    if (!ids.length || !props.onDeleteMany) return;345    setDeleting(true);346    await props.onDeleteMany(ids);347    setDeleting(false);348    exitSelectMode();349  }350351  // Virtualization: under VIRTUAL_THRESHOLD entries we render the full list.352  // Above it, we render only a window around the visible viewport plus a353  // BUFFER_ROWS-tall buffer above and below so scroll never reveals empty354  // space. Row height is approximate (variable padding for badges) but355  // bounded — the buffer absorbs over/undershoot.356  const VIRTUAL_THRESHOLD = 100;357  const ROW_HEIGHT = 56;358  const BUFFER_ROWS = 20;359360  const isVirtualized = createMemo(() => filteredEntries().length > VIRTUAL_THRESHOLD);361362  const visibleRange = createMemo(() => {363    const total = filteredEntries().length;364    if (!isVirtualized()) return { start: 0, end: total };365    const start = Math.max(0, Math.floor(scrollTop() / ROW_HEIGHT) - BUFFER_ROWS);366    const end = Math.min(total, Math.ceil((scrollTop() + viewportHeight()) / ROW_HEIGHT) + BUFFER_ROWS);367    return { start, end };368  });369370  const windowedEntries = createMemo(() => {371    const { start, end } = visibleRange();372    return filteredEntries().slice(start, end);373  });374375  function handleListScroll(e: Event) {376    if (!isVirtualized()) return;377    const target = e.currentTarget as HTMLDivElement;378    setScrollTop(target.scrollTop);379    setViewportHeight(target.clientHeight);380  }381382  function handleSearchKeydown(e: KeyboardEvent) {383    if (e.key === "ArrowDown") {384      e.preventDefault();385      const firstBtn = listRef?.querySelector<HTMLButtonElement>(".peach-entry-row");386      firstBtn?.focus();387    } else if (e.key === "ArrowUp") {388      e.preventDefault();389      const allBtns = listRef?.querySelectorAll<HTMLButtonElement>(".peach-entry-row");390      allBtns?.[allBtns.length - 1]?.focus();391    } else if (e.key === "Enter") {392      e.preventDefault();393      const firstEntry = filteredEntries()[0];394      if (firstEntry) props.onSelectEntry(firstEntry);395    } else if (e.key === "Escape" && selecting()) {396      exitSelectMode();397    }398  }399400  function handleListKeydown(e: KeyboardEvent) {401    if (!listRef) return;402    const btns = Array.from(listRef.querySelectorAll<HTMLButtonElement>(".peach-entry-row"));403    const idx = btns.indexOf(document.activeElement as HTMLButtonElement);404    if (e.key === "ArrowDown") {405      e.preventDefault();406      if (idx < btns.length - 1) btns[idx + 1]?.focus();407    } else if (e.key === "ArrowUp") {408      e.preventDefault();409      if (idx === 0) { searchRef?.focus(); }410      else btns[idx - 1]?.focus();411    } else if (e.key === "Escape") {412      if (selecting()) exitSelectMode();413      else props.onClose();414    }415  }416417  const sortLabels: { key: SortOrder; label: string }[] = [418    { key: "url-first", label: "smart" },419    { key: "az", label: "A–Z" },420    { key: "za", label: "Z–A" },421    { key: "recent", label: "newest" },422  ];423424  return (425    <div class="peach-overlay-view ov-enter-up" style="display:flex">426      <div class="peach-list-top" classList={{ "peach-list-top--native": !!props.isNative }}>427        <Show428          when={!props.isNative}429          fallback={430            <span class="peach-search-icon" aria-hidden="true">431              <svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round">432                <circle cx="5.5" cy="5.5" r="3.75" />433                <path d="M8.75 8.75L11.5 11.5" />434              </svg>435            </span>436          }437        >438          <button439            class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"440            onClick={props.onClose}441            aria-label="Back"442          >443            <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">444              <path d="M6 1L1 6L6 11" />445            </svg>446          </button>447        </Show>448        <input449          class="peach-search-input"450          placeholder="search vault..."451          autocomplete="off"452          style={props.isNative ? undefined : "font-size:14px"}453          ref={focusSearch}454          value={search()}455          onInput={(e) => setSearch((e.currentTarget as HTMLInputElement).value)}456          onKeyDown={handleSearchKeydown}457        />458        <span class="peach-search-counter peach-search-counter-list">459          {search() || selectedTags().size > 0460            ? `${filteredEntries().length} of ${props.entries.length}`461            : props.isNative462              ? `${props.entries.length}`463              : `${props.entries.length} entries`}464        </span>465      </div>466467      {/* Sort bar — morphs into select-mode controls */}468      <div class="peach-sort-bar">469        <Show470          when={selecting()}471          fallback={472            <>473              <For each={sortLabels}>474                {({ key, label }) => (475                  <button476                    class={`peach-sort-btn${props.sortOrder === key ? " is-active" : ""}`}477                    onClick={() => props.onSortOrderChange(key)}478                  >479                    {label}480                  </button>481                )}482              </For>483              <span style="flex:1" />484              <button485                class={`peach-sort-trash-btn${notesOnly() ? " is-active" : ""}`}486                onClick={() => setNotesOnly(v => !v)}487                aria-label="Notes only"488                title="Show notes only"489              >490                <NoteIcon />491              </button>492              <Show when={props.onOpenTrash}>493                <button494                  class="peach-sort-trash-btn"495                  onClick={props.onOpenTrash}496                  aria-label="View trash"497                  title="View deleted entries"498                >499                  <svg width="11" height="12" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">500                    <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />501                  </svg>502                </button>503              </Show>504              <Show when={props.onDeleteMany}>505                <button506                  class="peach-sort-trash-btn"507                  onClick={enterSelectMode}508                  aria-label="Select entries"509                  title="Select entries"510                >511                  <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">512                    <circle cx="4" cy="4" r="2.5" />513                    <circle cx="9" cy="4" r="2.5" />514                    <circle cx="4" cy="9" r="2.5" />515                    <circle cx="9" cy="9" r="2.5" />516                  </svg>517                </button>518              </Show>519            </>520          }521        >522          {/* Select mode sort bar */}523          <button524            class="peach-select-cancel-btn"525            onClick={exitSelectMode}526          >527            cancel528          </button>529          <span class="peach-select-count-label">530            {selectedIds().size > 0 ? `${selectedIds().size} selected` : "select entries"}531          </span>532          <button533            class="peach-select-all-btn"534            onClick={toggleSelectAll}535          >536            {allSelected() ? "none" : "all"}537          </button>538        </Show>539      </div>540541      <Show when={hasTags() && !selecting()}>542        <div class="peach-tag-filter-strip" style="display:flex;gap:6px;padding:4px 12px 6px;overflow-x:auto;flex-wrap:nowrap;scrollbar-width:none;">543          <For each={allTags()}>544            {(tag) => (545              <button546                class={`peach-tag-filter-chip ${selectedTags().has(tag) ? "is-selected" : ""}`}547                onClick={() => toggleTag(tag)}548              >549                {tag}550              </button>551            )}552          </For>553        </div>554      </Show>555556      <div557        class={`peach-vault-list${selecting() ? " is-selecting" : ""}`}558        ref={(el) => { listRef = el; setViewportHeight(el.clientHeight); }}559        onKeyDown={handleListKeydown}560        onScroll={handleListScroll}561      >562        <Show563          when={filteredEntries().length > 0}564          fallback={565            <div class="peach-list-empty">566              <span>no entries yet</span>567            </div>568          }569        >570          <Show when={isVirtualized()}>571            <div style={{ height: `${visibleRange().start * ROW_HEIGHT}px` }} aria-hidden="true" />572          </Show>573          <For each={windowedEntries()}>574            {(entry, localIndex) => {575              const index = () => visibleRange().start + localIndex();576              const isNote = entry.type === "note";577              const isPasskey = entry.passkey !== undefined;578              const domain = entry.type === "note"579                ? (entry.notes?.slice(0, 40) ?? "note")580                : entry.url581                  ? getDisplayDomain(entry.url)582                  : entry.passkey?.rpId583                    ? entry.passkey.rpId584                  : "saved entry";585              const isTabMatch = () =>586                props.currentTabDomain !== undefined &&587                props.currentTabDomain !== "" &&588                entryMatchesDomain(entry, props.currentTabDomain);589              const isSelected = () => selectedIds().has(entry.id);590591              const ariaLabel = (() => {592                const tag = isPasskey ? "passkey" : isNote ? "note" : "login";593                const sub = entry.username || domain || "";594                return sub595                  ? `${entry.title} — ${sub} (${tag})`596                  : `${entry.title} (${tag})`;597              })();598              return (599                <button600                  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" : ""}`}601                  style={isVirtualized()602                    ? { animation: "none" }603                    : { "animation-delay": `${Math.min(index(), 24) * 20}ms` }604                  }605                  aria-label={ariaLabel}606                  aria-pressed={selecting() ? isSelected() : undefined}607                  onTouchStart={(e) => handleRowTouchStart(entry, e)}608                  onTouchMove={handleRowTouchMove}609                  onTouchEnd={(e) => void handleRowTouchEnd(entry, e)}610                  onTouchCancel={() => { clearLongPressTimer(); touchStart = null; }}611                  onContextMenu={(e) => { e.preventDefault(); if (!selecting()) setContextEntry(entry); }}612                  onClick={() => {613                    if (suppressNextClick) { suppressNextClick = false; return; }614                    if (selecting()) {615                      toggleId(entry.id);616                    } else {617                      props.onSelectEntry(entry);618                    }619                  }}620                >621                  <div class={`peach-entry-row-icon${!selecting() && entry.breach ? " is-breach" : ""}${selecting() ? " is-select-icon" : ""}`}>622                    <Show623                      when={selecting()}624                      fallback={625                        <FaviconIcon626                          domain={entry.url ? getDisplayDomain(entry.url) : ""}627                          title={entry.title}628                          isNote={isNote}629                          isPasskey={isPasskey}630                        />631                      }632                    >633                      {/* Check indicator */}634                      <svg width="16" height="16" viewBox="0 0 16 16" fill="none">635                        <circle636                          cx="8" cy="8" r="7"637                          stroke="currentColor"638                          stroke-width="1"639                          fill={isSelected() ? "var(--peach-accent)" : "transparent"}640                          opacity={isSelected() ? "1" : "0.3"}641                        />642                        <Show when={isSelected()}>643                          <path644                            d="M5 8.5L7 10.5L11 6"645                            stroke="white"646                            stroke-width="1.2"647                            stroke-linecap="round"648                            stroke-linejoin="round"649                          />650                        </Show>651                      </svg>652                    </Show>653                  </div>654                  <div class="peach-entry-row-info">655                    <span class="peach-entry-row-name">656                      {entry.title}657                      <Show when={isPasskey && !selecting()}>658                        <span class="peach-passkey-badge">passkey</span>659                      </Show>660                      <Show when={isTabMatch() && !selecting()}>661                        <span class="peach-tab-match-badge" title="Matches current tab">●</span>662                      </Show>663                    </span>664                    <span class="peach-entry-row-meta">{domain}</span>665                  </div>666                  <Show when={entry.breach && !selecting()}>667                    <span class="peach-entry-row-dot" aria-label="breach"></span>668                  </Show>669                  <Show when={entry.canary && !selecting()}>670                    <span class="peach-entry-row-canary" title={`Canary: ${entry.canary?.label ?? "canary entry"}`}>🪤</span>671                  </Show>672                </button>673              );674            }}675          </For>676          <Show when={isVirtualized()}>677            <div678              style={{ height: `${(filteredEntries().length - visibleRange().end) * ROW_HEIGHT}px` }}679              aria-hidden="true"680            />681          </Show>682        </Show>683      </div>684685      {/* Bottom bar — new entry normally, delete action in select mode */}686      <Show687        when={selecting()}688        fallback={689          <button class="peach-new-entry-row" onClick={props.onAdd}>690            <svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round">691              <path d="M5 1v8M1 5h8" />692            </svg>693            new entry694          </button>695        }696      >697        <button698          class={`peach-select-action-bar${selectedIds().size === 0 ? " is-empty" : ""}`}699          disabled={selectedIds().size === 0 || deleting()}700          onClick={() => void handleDeleteSelected()}701        >702          <svg width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">703            <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />704          </svg>705          <Show706            when={selectedIds().size > 0}707            fallback={<span>select entries to delete</span>}708          >709            <span>710              {deleting()711                ? "moving to trash…"712                : `move ${selectedIds().size} to trash`}713            </span>714          </Show>715        </button>716      </Show>717718      <Show when={contextEntry() !== null}>719        <div720          class="peach-context-menu-backdrop"721          onClick={() => setContextEntry(null)}722          role="presentation"723        >724          <div725            class="peach-context-menu"726            role="menu"727            aria-label="Entry actions"728            onClick={(e) => e.stopPropagation()}729          >730            <Show when={contextEntry()!.username}>731              <button732                role="menuitem"733                class="peach-context-menu-item"734                onClick={async () => {735                  const e = contextEntry();736                  if (!e?.username) return;737                  await popupCopy(e.username, "username");738                  setContextEntry(null);739                }}740              >741                Copy username742              </button>743            </Show>744            <Show when={contextEntry()!.password}>745              <button746                role="menuitem"747                class="peach-context-menu-item"748                onClick={async () => {749                  const e = contextEntry();750                  if (!e?.password) return;751                  await popupCopy(e.password, "password");752                  setContextEntry(null);753                }}754              >755                Copy password756              </button>757            </Show>758            <Show when={contextEntry()!.totp}>759              <button760                role="menuitem"761                class="peach-context-menu-item"762                onClick={async () => {763                  const e = contextEntry();764                  if (!e?.totp) return;765                  await popupCopy(e.totp, "TOTP secret");766                  setContextEntry(null);767                }}768              >769                Copy TOTP secret770              </button>771            </Show>772            <button773              role="menuitem"774              class="peach-context-menu-item"775              onClick={() => {776                const e = contextEntry();777                if (e) props.onSelectEntry(e);778                setContextEntry(null);779              }}780            >781              Open782            </button>783            <Show when={props.onDeleteMany}>784              <button785                role="menuitem"786                class="peach-context-menu-item peach-context-menu-item--danger"787                onClick={async () => {788                  const e = contextEntry();789                  if (!e) return;790                  setContextEntry(null);791                  await props.onDeleteMany?.([e.id]);792                }}793              >794                Move to trash795              </button>796            </Show>797            <button798              role="menuitem"799              class="peach-context-menu-item peach-context-menu-item--cancel"800              onClick={() => setContextEntry(null)}801            >802              Cancel803            </button>804          </div>805        </div>806      </Show>807    </div>808  );809}