Vault.tsx

28.5 KB · raw

1import { createEffect, createMemo, createSignal, For, Show } from "solid-js";2import type { VaultEntry } from "../../shared/types";3import { extractDomain, getDisplayDomain } from "../../shared/domain";4import { popupCopy } from "../copy";56function vibrate(ms: number) {7  try { navigator.vibrate?.(ms); } catch { /* unsupported */ }8}910function FaviconIcon(props: { domain: string; title?: string; isNote: boolean; isPasskey: boolean }) {11  if (props.isPasskey) return <PasskeyIcon />;12  if (props.isNote) return <NoteIcon />;13  return <InitialsChip title={props.title ?? props.domain ?? ""} />;14}1516/**17 * Title → "PM"-style initials. First letters of the first two words,18 * falling back to the first two characters of a single-word title. Empty19 * titles get a small dot. Deterministic so the same entry always renders20 * the same chip.21 */22function titleInitials(source: string): string {23  const cleaned = source.replace(/^https?:\/\//, "").replace(/^www\./, "").trim();24  if (!cleaned) return "·";25  const words = cleaned.split(/[\s.\-_/]+/).filter(Boolean);26  if (words.length >= 2 && words[0]![0] && words[1]![0]) {27    return (words[0]![0]! + words[1]![0]!).toUpperCase();28  }29  const single = words[0] ?? cleaned;30  return single.slice(0, 2).toUpperCase();31}3233/**34 * Hash a string to an HSL background. Fixed saturation + lightness so the35 * palette stays muted and dark enough that white initials read clearly on36 * top, while still giving each entry a recognisable colour signature.37 */38function titleBackground(source: string): string {39  let hash = 0;40  for (let i = 0; i < source.length; i++) {41    hash = ((hash << 5) - hash + source.charCodeAt(i)) | 0;42  }43  const hue = ((hash % 360) + 360) % 360;44  return `hsl(${hue}, 32%, 26%)`;45}4647function InitialsChip(props: { title: string }) {48  return (49    <span50      class="peach-favicon-initials"51      style={{ background: titleBackground(props.title) }}52      aria-hidden="true"53    >54      {titleInitials(props.title)}55    </span>56  );57}585960const PasskeyIcon = () => (61  <svg width="11" height="12" viewBox="0 0 11 12" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round" aria-hidden="true">62    <circle cx="4" cy="4.5" r="3" />63    <path d="M6.5 7l3.5 3.5M8.5 8.5L10 10" />64  </svg>65);6667const NoteIcon = () => (68  <svg width="11" height="12" viewBox="0 0 11 12" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round" aria-hidden="true">69    <rect x="1.5" y="1.5" width="8" height="9" rx="1.5" />70    <path d="M3.5 4.5h4M3.5 6.5h4M3.5 8.5h2" />71  </svg>72);7374export type SortOrder = "url-first" | "az" | "za" | "recent";7576interface VaultProps {77  entries: VaultEntry[];78  selectedEntryId?: string;79  currentTabDomain?: string;80  sortOrder: SortOrder;81  searchSeed?: string;82  searchSeedNonce?: number;83  // When true, render the mobile flat top bar: no back arrow (use swipe-down84  // on the bottom sheet), search-glass icon on the left, counter on the85  // right. Matches the extension's `.peach-search-bar` look.86  isNative?: boolean;87  onSelectEntry: (entry: VaultEntry) => void;88  onSortOrderChange: (order: SortOrder) => void;89  onAdd: () => void;90  onClose: () => void;91  onOpenTrash?: () => void;92  onDeleteMany?: (ids: string[]) => Promise<void>;93}9495export function VaultView(props: VaultProps) {96  const [search, setSearch] = createSignal("");97  const [selectedTags, setSelectedTags] = createSignal<Set<string>>(new Set());98  const [notesOnly, setNotesOnly] = createSignal(false);99  const [selecting, setSelecting] = createSignal(false);100  const [selectedIds, setSelectedIds] = createSignal<Set<string>>(new Set());101  const [deleting, setDeleting] = createSignal(false);102  const [contextEntry, setContextEntry] = createSignal<VaultEntry | null>(null);103  const [flashId, setFlashId] = createSignal<string | null>(null);104  const [scrollTop, setScrollTop] = createSignal(0);105  const [viewportHeight, setViewportHeight] = createSignal(600);106  let longPressTimer: number | null = null;107  let touchStart: { x: number; y: number; t: number } | null = null;108  let suppressNextClick = false;109110  function clearLongPressTimer() {111    if (longPressTimer !== null) {112      window.clearTimeout(longPressTimer);113      longPressTimer = null;114    }115  }116117  function handleRowTouchStart(entry: VaultEntry, e: TouchEvent) {118    if (selecting()) return;119    const t = e.touches[0];120    if (!t) return;121    touchStart = { x: t.clientX, y: t.clientY, t: Date.now() };122    clearLongPressTimer();123    longPressTimer = window.setTimeout(() => {124      suppressNextClick = true;125      vibrate(20);126      setContextEntry(entry);127      longPressTimer = null;128    }, 500);129  }130131  function handleRowTouchMove(e: TouchEvent) {132    if (!touchStart) return;133    const t = e.touches[0];134    if (!t) return;135    const dx = Math.abs(t.clientX - touchStart.x);136    const dy = Math.abs(t.clientY - touchStart.y);137    if (dx > 8 || dy > 8) clearLongPressTimer();138  }139140  async function handleRowTouchEnd(entry: VaultEntry, e: TouchEvent) {141    clearLongPressTimer();142    if (!touchStart) return;143    const t = e.changedTouches[0];144    if (!t) { touchStart = null; return; }145    const dx = t.clientX - touchStart.x;146    const dy = Math.abs(t.clientY - touchStart.y);147    const dt = Date.now() - touchStart.t;148    touchStart = null;149    // Right-swipe-to-copy: only on compact + login entries with a password150    const isCompact = !document.body.classList.contains("layout-medium")151      && !document.body.classList.contains("layout-expanded");152    if (153      isCompact154      && !selecting()155      && dx > 80156      && dy < 30157      && dt < 400158      && entry.type === "login"159      && entry.password160    ) {161      suppressNextClick = true;162      vibrate(15);163      setFlashId(entry.id);164      window.setTimeout(() => setFlashId((id) => id === entry.id ? null : id), 600);165      await popupCopy(entry.password, "password");166    }167  }168169  let searchRef: HTMLInputElement | undefined;170  let listRef: HTMLDivElement | undefined;171172  const focusSearch = (el: HTMLInputElement) => {173    searchRef = el;174    setTimeout(() => el.focus(), 50);175  };176177  createEffect(() => {178    void props.searchSeedNonce;179    const seed = props.searchSeed;180    if (!seed) return;181    setSearch(seed);182    requestAnimationFrame(() => {183      searchRef?.focus();184      searchRef?.setSelectionRange(seed.length, seed.length);185    });186  });187188  function enterSelectMode() {189    setSelecting(true);190    setSelectedIds(new Set<string>());191  }192193  function exitSelectMode() {194    setSelecting(false);195    setSelectedIds(new Set<string>());196  }197198  function toggleId(id: string) {199    const s = new Set<string>(selectedIds());200    if (s.has(id)) s.delete(id); else s.add(id);201    setSelectedIds(s);202  }203204  const allTags = createMemo(() => {205    const tagSet = new Set<string>();206    for (const entry of props.entries) {207      for (const tag of entry.tags ?? []) {208        tagSet.add(tag);209      }210    }211    return Array.from(tagSet).sort();212  });213214  const hasTags = createMemo(() => allTags().length > 0);215216  function toggleTag(tag: string) {217    const current = new Set(selectedTags());218    if (current.has(tag)) {219      current.delete(tag);220    } else {221      current.add(tag);222    }223    setSelectedTags(current);224  }225226  function fuzzyMatch(query: string, target: string): boolean {227    query = query.toLowerCase();228    target = target.toLowerCase();229    let qi = 0;230    for (let i = 0; i < target.length && qi < query.length; i++) {231      if (target[i] === query[qi]) qi++;232    }233    return qi === query.length;234  }235236  function isExactPrefix(query: string, target: string): boolean {237    return target.toLowerCase().startsWith(query.toLowerCase());238  }239240  function entryMatchesDomain(entry: VaultEntry, domain: string): boolean {241    if (!domain) return false;242    if (entry.passkey?.rpId === domain) return true;243    if (entry.type !== "login") return false;244    // Tab domain is the eTLD+1 from extractDomain — compare against eTLD+1 of245    // each entry URL too so subdomain entries also match.246    if (entry.url && extractDomain(entry.url) === domain) return true;247    for (const extra of entry.additionalUrls ?? []) {248      if (extractDomain(extra) === domain) return true;249    }250    return false;251  }252253  const filteredEntries = createMemo(() => {254    let result = props.entries;255256    if (notesOnly()) {257      result = result.filter(entry => entry.type === "note");258    }259260    if (selectedTags().size > 0) {261      result = result.filter(entry =>262        Array.from(selectedTags()).every(tag => entry.tags?.includes(tag))263      );264    }265266    const q = search().trim();267    if (q) {268      const searchableFor = (e: VaultEntry): string => {269        if (e.type === "note") return `${e.title} ${e.notes ?? ""}`;270        const passkeyText = e.passkey271          ? ` ${e.passkey.rpId} ${e.passkey.userName} ${e.passkey.userDisplayName}`272          : "";273        return `${e.title} ${e.url ?? ""} ${e.username ?? ""}${passkeyText}`;274      };275      result = result276        .filter((entry) => fuzzyMatch(q, searchableFor(entry)))277        .sort((a, b) => {278          const aSearchable = searchableFor(a).toLowerCase();279          const bSearchable = searchableFor(b).toLowerCase();280          const qLower = q.toLowerCase();281          const aExact = isExactPrefix(qLower, aSearchable);282          const bExact = isExactPrefix(qLower, bSearchable);283          if (aExact && !bExact) return -1;284          if (!aExact && bExact) return 1;285          return 0;286        });287      return result;288    }289290    const tabDomain = props.currentTabDomain ?? "";291    const order = props.sortOrder;292293    if (order === "az") {294      return [...result].sort((a, b) => a.title.localeCompare(b.title));295    }296    if (order === "za") {297      return [...result].sort((a, b) => b.title.localeCompare(a.title));298    }299    if (order === "recent") {300      return [...result].sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0));301    }302    if (tabDomain) {303      return [...result].sort((a, b) => {304        const aMatch = entryMatchesDomain(a, tabDomain) ? 0 : 1;305        const bMatch = entryMatchesDomain(b, tabDomain) ? 0 : 1;306        return aMatch - bMatch;307      });308    }309    return result;310  });311312  // Declaration order matters: `allVisibleIds` and `allSelected` read313  // `filteredEntries` eagerly on component mount via createMemo. Moving314  // `filteredEntries` below them throws ReferenceError under the production315  // minifier and freezes the vault list on mount (v1.4.34 regression).316  const allVisibleIds = createMemo(() => filteredEntries().map(e => e.id));317  const allSelected = createMemo(() =>318    allVisibleIds().length > 0 && allVisibleIds().every(id => selectedIds().has(id))319  );320321  function toggleSelectAll() {322    if (allSelected()) {323      setSelectedIds(new Set<string>());324    } else {325      setSelectedIds(new Set<string>(allVisibleIds()));326    }327  }328329  async function handleDeleteSelected() {330    const ids = Array.from(selectedIds());331    if (!ids.length || !props.onDeleteMany) return;332    setDeleting(true);333    await props.onDeleteMany(ids);334    setDeleting(false);335    exitSelectMode();336  }337338  // Virtualization: under VIRTUAL_THRESHOLD entries we render the full list.339  // Above it, we render only a window around the visible viewport plus a340  // BUFFER_ROWS-tall buffer above and below so scroll never reveals empty341  // space. Row height is approximate (variable padding for badges) but342  // bounded — the buffer absorbs over/undershoot.343  const VIRTUAL_THRESHOLD = 100;344  const ROW_HEIGHT = 56;345  const BUFFER_ROWS = 20;346347  const isVirtualized = createMemo(() => filteredEntries().length > VIRTUAL_THRESHOLD);348349  const visibleRange = createMemo(() => {350    const total = filteredEntries().length;351    if (!isVirtualized()) return { start: 0, end: total };352    const start = Math.max(0, Math.floor(scrollTop() / ROW_HEIGHT) - BUFFER_ROWS);353    const end = Math.min(total, Math.ceil((scrollTop() + viewportHeight()) / ROW_HEIGHT) + BUFFER_ROWS);354    return { start, end };355  });356357  const windowedEntries = createMemo(() => {358    const { start, end } = visibleRange();359    return filteredEntries().slice(start, end);360  });361362  function handleListScroll(e: Event) {363    if (!isVirtualized()) return;364    const target = e.currentTarget as HTMLDivElement;365    setScrollTop(target.scrollTop);366    setViewportHeight(target.clientHeight);367  }368369  function handleSearchKeydown(e: KeyboardEvent) {370    if (e.key === "ArrowDown") {371      e.preventDefault();372      const firstBtn = listRef?.querySelector<HTMLButtonElement>(".peach-entry-row");373      firstBtn?.focus();374    } else if (e.key === "ArrowUp") {375      e.preventDefault();376      const allBtns = listRef?.querySelectorAll<HTMLButtonElement>(".peach-entry-row");377      allBtns?.[allBtns.length - 1]?.focus();378    } else if (e.key === "Enter") {379      e.preventDefault();380      const firstEntry = filteredEntries()[0];381      if (firstEntry) props.onSelectEntry(firstEntry);382    } else if (e.key === "Escape" && selecting()) {383      exitSelectMode();384    }385  }386387  function handleListKeydown(e: KeyboardEvent) {388    if (!listRef) return;389    const btns = Array.from(listRef.querySelectorAll<HTMLButtonElement>(".peach-entry-row"));390    const idx = btns.indexOf(document.activeElement as HTMLButtonElement);391    if (e.key === "ArrowDown") {392      e.preventDefault();393      if (idx < btns.length - 1) btns[idx + 1]?.focus();394    } else if (e.key === "ArrowUp") {395      e.preventDefault();396      if (idx === 0) { searchRef?.focus(); }397      else btns[idx - 1]?.focus();398    } else if (e.key === "Escape") {399      if (selecting()) exitSelectMode();400      else props.onClose();401    }402  }403404  const sortLabels: { key: SortOrder; label: string }[] = [405    { key: "url-first", label: "smart" },406    { key: "az", label: "A–Z" },407    { key: "za", label: "Z–A" },408    { key: "recent", label: "newest" },409  ];410411  return (412    <div class="peach-overlay-view ov-enter-up" style="display:flex">413      <div class="peach-list-top" classList={{ "peach-list-top--native": !!props.isNative }}>414        <Show415          when={!props.isNative}416          fallback={417            <span class="peach-search-icon" aria-hidden="true">418              <svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round">419                <circle cx="5.5" cy="5.5" r="3.75" />420                <path d="M8.75 8.75L11.5 11.5" />421              </svg>422            </span>423          }424        >425          <button426            class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"427            onClick={props.onClose}428            aria-label="Back"429          >430            <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">431              <path d="M6 1L1 6L6 11" />432            </svg>433          </button>434        </Show>435        <input436          class="peach-search-input"437          placeholder="search vault..."438          autocomplete="off"439          style={props.isNative ? undefined : "font-size:14px"}440          ref={focusSearch}441          value={search()}442          onInput={(e) => setSearch((e.currentTarget as HTMLInputElement).value)}443          onKeyDown={handleSearchKeydown}444        />445        <span class="peach-search-counter peach-search-counter-list">446          {search() || selectedTags().size > 0447            ? `${filteredEntries().length} of ${props.entries.length}`448            : props.isNative449              ? `${props.entries.length}`450              : `${props.entries.length} entries`}451        </span>452      </div>453454      {/* Sort bar — morphs into select-mode controls */}455      <div class="peach-sort-bar">456        <Show457          when={selecting()}458          fallback={459            <>460              <For each={sortLabels}>461                {({ key, label }) => (462                  <button463                    class={`peach-sort-btn${props.sortOrder === key ? " is-active" : ""}`}464                    onClick={() => props.onSortOrderChange(key)}465                  >466                    {label}467                  </button>468                )}469              </For>470              <span style="flex:1" />471              <button472                class={`peach-sort-trash-btn${notesOnly() ? " is-active" : ""}`}473                onClick={() => setNotesOnly(v => !v)}474                aria-label="Notes only"475                title="Show notes only"476              >477                <NoteIcon />478              </button>479              <Show when={props.onOpenTrash}>480                <button481                  class="peach-sort-trash-btn"482                  onClick={props.onOpenTrash}483                  aria-label="View trash"484                  title="View deleted entries"485                >486                  <svg width="11" height="12" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">487                    <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />488                  </svg>489                </button>490              </Show>491              <Show when={props.onDeleteMany}>492                <button493                  class="peach-sort-trash-btn"494                  onClick={enterSelectMode}495                  aria-label="Select entries"496                  title="Select entries"497                >498                  <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">499                    <circle cx="4" cy="4" r="2.5" />500                    <circle cx="9" cy="4" r="2.5" />501                    <circle cx="4" cy="9" r="2.5" />502                    <circle cx="9" cy="9" r="2.5" />503                  </svg>504                </button>505              </Show>506            </>507          }508        >509          {/* Select mode sort bar */}510          <button511            class="peach-select-cancel-btn"512            onClick={exitSelectMode}513          >514            cancel515          </button>516          <span class="peach-select-count-label">517            {selectedIds().size > 0 ? `${selectedIds().size} selected` : "select entries"}518          </span>519          <button520            class="peach-select-all-btn"521            onClick={toggleSelectAll}522          >523            {allSelected() ? "none" : "all"}524          </button>525        </Show>526      </div>527528      <Show when={hasTags() && !selecting()}>529        <div class="peach-tag-filter-strip" style="display:flex;gap:6px;padding:4px 12px 6px;overflow-x:auto;flex-wrap:nowrap;scrollbar-width:none;">530          <For each={allTags()}>531            {(tag) => (532              <button533                class={`peach-tag-filter-chip ${selectedTags().has(tag) ? "is-selected" : ""}`}534                onClick={() => toggleTag(tag)}535              >536                {tag}537              </button>538            )}539          </For>540        </div>541      </Show>542543      <div544        class={`peach-vault-list${selecting() ? " is-selecting" : ""}`}545        ref={(el) => { listRef = el; setViewportHeight(el.clientHeight); }}546        onKeyDown={handleListKeydown}547        onScroll={handleListScroll}548      >549        <Show550          when={filteredEntries().length > 0}551          fallback={552            <div class="peach-list-empty">553              <span>no entries yet</span>554            </div>555          }556        >557          <Show when={isVirtualized()}>558            <div style={{ height: `${visibleRange().start * ROW_HEIGHT}px` }} aria-hidden="true" />559          </Show>560          <For each={windowedEntries()}>561            {(entry, localIndex) => {562              const index = () => visibleRange().start + localIndex();563              const isNote = entry.type === "note";564              const isPasskey = entry.passkey !== undefined;565              const domain = entry.type === "note"566                ? (entry.notes?.slice(0, 40) ?? "note")567                : entry.url568                  ? getDisplayDomain(entry.url)569                  : entry.passkey?.rpId570                    ? entry.passkey.rpId571                  : "saved entry";572              const isTabMatch = () =>573                props.currentTabDomain !== undefined &&574                props.currentTabDomain !== "" &&575                entryMatchesDomain(entry, props.currentTabDomain);576              const isSelected = () => selectedIds().has(entry.id);577578              const ariaLabel = (() => {579                const tag = isPasskey ? "passkey" : isNote ? "note" : "login";580                const sub = entry.username || domain || "";581                return sub582                  ? `${entry.title} — ${sub} (${tag})`583                  : `${entry.title} (${tag})`;584              })();585              return (586                <button587                  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" : ""}`}588                  style={isVirtualized()589                    ? { animation: "none" }590                    : { "animation-delay": `${Math.min(index(), 24) * 20}ms` }591                  }592                  aria-label={ariaLabel}593                  aria-pressed={selecting() ? isSelected() : undefined}594                  onTouchStart={(e) => handleRowTouchStart(entry, e)}595                  onTouchMove={handleRowTouchMove}596                  onTouchEnd={(e) => void handleRowTouchEnd(entry, e)}597                  onTouchCancel={() => { clearLongPressTimer(); touchStart = null; }}598                  onContextMenu={(e) => { e.preventDefault(); if (!selecting()) setContextEntry(entry); }}599                  onClick={() => {600                    if (suppressNextClick) { suppressNextClick = false; return; }601                    if (selecting()) {602                      toggleId(entry.id);603                    } else {604                      props.onSelectEntry(entry);605                    }606                  }}607                >608                  <div class={`peach-entry-row-icon${!selecting() && entry.breach ? " is-breach" : ""}${selecting() ? " is-select-icon" : ""}`}>609                    <Show610                      when={selecting()}611                      fallback={612                        <FaviconIcon613                          domain={entry.url ? getDisplayDomain(entry.url) : ""}614                          title={entry.title}615                          isNote={isNote}616                          isPasskey={isPasskey}617                        />618                      }619                    >620                      {/* Check indicator */}621                      <svg width="16" height="16" viewBox="0 0 16 16" fill="none">622                        <circle623                          cx="8" cy="8" r="7"624                          stroke="currentColor"625                          stroke-width="1"626                          fill={isSelected() ? "var(--peach-accent)" : "transparent"}627                          opacity={isSelected() ? "1" : "0.3"}628                        />629                        <Show when={isSelected()}>630                          <path631                            d="M5 8.5L7 10.5L11 6"632                            stroke="white"633                            stroke-width="1.2"634                            stroke-linecap="round"635                            stroke-linejoin="round"636                          />637                        </Show>638                      </svg>639                    </Show>640                  </div>641                  <div class="peach-entry-row-info">642                    <span class="peach-entry-row-name">643                      {entry.title}644                      <Show when={isPasskey && !selecting()}>645                        <span class="peach-passkey-badge">passkey</span>646                      </Show>647                      <Show when={isTabMatch() && !selecting()}>648                        <span class="peach-tab-match-badge" title="Matches current tab">●</span>649                      </Show>650                    </span>651                    <span class="peach-entry-row-meta">{domain}</span>652                  </div>653                  <Show when={entry.breach && !selecting()}>654                    <span class="peach-entry-row-dot" aria-label="breach"></span>655                  </Show>656                  <Show when={entry.canary && !selecting()}>657                    <span class="peach-entry-row-canary" title={`Canary: ${entry.canary?.label ?? "canary entry"}`}>🪤</span>658                  </Show>659                </button>660              );661            }}662          </For>663          <Show when={isVirtualized()}>664            <div665              style={{ height: `${(filteredEntries().length - visibleRange().end) * ROW_HEIGHT}px` }}666              aria-hidden="true"667            />668          </Show>669        </Show>670      </div>671672      {/* Bottom bar — new entry normally, delete action in select mode */}673      <Show674        when={selecting()}675        fallback={676          <button class="peach-new-entry-row" onClick={props.onAdd}>677            <svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round">678              <path d="M5 1v8M1 5h8" />679            </svg>680            new entry681          </button>682        }683      >684        <button685          class={`peach-select-action-bar${selectedIds().size === 0 ? " is-empty" : ""}`}686          disabled={selectedIds().size === 0 || deleting()}687          onClick={() => void handleDeleteSelected()}688        >689          <svg width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">690            <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />691          </svg>692          <Show693            when={selectedIds().size > 0}694            fallback={<span>select entries to delete</span>}695          >696            <span>697              {deleting()698                ? "moving to trash…"699                : `move ${selectedIds().size} to trash`}700            </span>701          </Show>702        </button>703      </Show>704705      <Show when={contextEntry() !== null}>706        <div707          class="peach-context-menu-backdrop"708          onClick={() => setContextEntry(null)}709          role="presentation"710        >711          <div712            class="peach-context-menu"713            role="menu"714            aria-label="Entry actions"715            onClick={(e) => e.stopPropagation()}716          >717            <Show when={contextEntry()!.username}>718              <button719                role="menuitem"720                class="peach-context-menu-item"721                onClick={async () => {722                  const e = contextEntry();723                  if (!e?.username) return;724                  await popupCopy(e.username, "username");725                  setContextEntry(null);726                }}727              >728                Copy username729              </button>730            </Show>731            <Show when={contextEntry()!.password}>732              <button733                role="menuitem"734                class="peach-context-menu-item"735                onClick={async () => {736                  const e = contextEntry();737                  if (!e?.password) return;738                  await popupCopy(e.password, "password");739                  setContextEntry(null);740                }}741              >742                Copy password743              </button>744            </Show>745            <Show when={contextEntry()!.totp}>746              <button747                role="menuitem"748                class="peach-context-menu-item"749                onClick={async () => {750                  const e = contextEntry();751                  if (!e?.totp) return;752                  await popupCopy(e.totp, "TOTP secret");753                  setContextEntry(null);754                }}755              >756                Copy TOTP secret757              </button>758            </Show>759            <button760              role="menuitem"761              class="peach-context-menu-item"762              onClick={() => {763                const e = contextEntry();764                if (e) props.onSelectEntry(e);765                setContextEntry(null);766              }}767            >768              Open769            </button>770            <Show when={props.onDeleteMany}>771              <button772                role="menuitem"773                class="peach-context-menu-item peach-context-menu-item--danger"774                onClick={async () => {775                  const e = contextEntry();776                  if (!e) return;777                  setContextEntry(null);778                  await props.onDeleteMany?.([e.id]);779                }}780              >781                Move to trash782              </button>783            </Show>784            <button785              role="menuitem"786              class="peach-context-menu-item peach-context-menu-item--cancel"787              onClick={() => setContextEntry(null)}788            >789              Cancel790            </button>791          </div>792        </div>793      </Show>794    </div>795  );796}