Vault.tsx

28.8 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    ? PASSKEY_ICON21    : props.isNote22      ? NOTE_ICON23      : <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}8182const LOGIN_ICON = (83  <svg width="11" height="12" viewBox="0 0 11 12" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round" aria-hidden="true">84    <rect x="2" y="5" width="7" height="5" rx="1.2" />85    <path d="M3.5 5V3.75A2 2 0 0 1 5.5 1.75A2 2 0 0 1 7.5 3.75V5" />86  </svg>87);8889const PASSKEY_ICON = (90  <svg width="11" height="12" viewBox="0 0 11 12" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round" aria-hidden="true">91    <circle cx="4" cy="4.5" r="3" />92    <path d="M6.5 7l3.5 3.5M8.5 8.5L10 10" />93  </svg>94);9596const NOTE_ICON = (97  <svg width="11" height="12" viewBox="0 0 11 12" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round" aria-hidden="true">98    <rect x="1.5" y="1.5" width="8" height="9" rx="1.5" />99    <path d="M3.5 4.5h4M3.5 6.5h4M3.5 8.5h2" />100  </svg>101);102103export type SortOrder = "url-first" | "az" | "za" | "recent";104105interface VaultProps {106  entries: VaultEntry[];107  selectedEntryId?: string;108  currentTabDomain?: string;109  sortOrder: SortOrder;110  searchSeed?: string;111  searchSeedNonce?: number;112  onSelectEntry: (entry: VaultEntry) => void;113  onSortOrderChange: (order: SortOrder) => void;114  onAdd: () => void;115  onClose: () => void;116  onOpenTrash?: () => void;117  onDeleteMany?: (ids: string[]) => Promise<void>;118}119120export function VaultView(props: VaultProps) {121  const [search, setSearch] = createSignal("");122  const [selectedTags, setSelectedTags] = createSignal<Set<string>>(new Set());123  const [notesOnly, setNotesOnly] = createSignal(false);124  const [selecting, setSelecting] = createSignal(false);125  const [selectedIds, setSelectedIds] = createSignal<Set<string>>(new Set());126  const [deleting, setDeleting] = createSignal(false);127  const [contextEntry, setContextEntry] = createSignal<VaultEntry | null>(null);128  const [flashId, setFlashId] = createSignal<string | null>(null);129  const [scrollTop, setScrollTop] = createSignal(0);130  const [viewportHeight, setViewportHeight] = createSignal(600);131  let longPressTimer: number | null = null;132  let touchStart: { x: number; y: number; t: number } | null = null;133  let suppressNextClick = false;134135  function clearLongPressTimer() {136    if (longPressTimer !== null) {137      window.clearTimeout(longPressTimer);138      longPressTimer = null;139    }140  }141142  function handleRowTouchStart(entry: VaultEntry, e: TouchEvent) {143    if (selecting()) return;144    const t = e.touches[0];145    if (!t) return;146    touchStart = { x: t.clientX, y: t.clientY, t: Date.now() };147    clearLongPressTimer();148    longPressTimer = window.setTimeout(() => {149      suppressNextClick = true;150      vibrate(20);151      setContextEntry(entry);152      longPressTimer = null;153    }, 500);154  }155156  function handleRowTouchMove(e: TouchEvent) {157    if (!touchStart) return;158    const t = e.touches[0];159    if (!t) return;160    const dx = Math.abs(t.clientX - touchStart.x);161    const dy = Math.abs(t.clientY - touchStart.y);162    if (dx > 8 || dy > 8) clearLongPressTimer();163  }164165  async function handleRowTouchEnd(entry: VaultEntry, e: TouchEvent) {166    clearLongPressTimer();167    if (!touchStart) return;168    const t = e.changedTouches[0];169    if (!t) { touchStart = null; return; }170    const dx = t.clientX - touchStart.x;171    const dy = Math.abs(t.clientY - touchStart.y);172    const dt = Date.now() - touchStart.t;173    touchStart = null;174    // Right-swipe-to-copy: only on compact + login entries with a password175    const isCompact = !document.body.classList.contains("layout-medium")176      && !document.body.classList.contains("layout-expanded");177    if (178      isCompact179      && !selecting()180      && dx > 80181      && dy < 30182      && dt < 400183      && entry.type === "login"184      && entry.password185    ) {186      suppressNextClick = true;187      vibrate(15);188      setFlashId(entry.id);189      window.setTimeout(() => setFlashId((id) => id === entry.id ? null : id), 600);190      await popupCopy(entry.password, "password");191    }192  }193194  let searchRef: HTMLInputElement | undefined;195  let listRef: HTMLDivElement | undefined;196197  const focusSearch = (el: HTMLInputElement) => {198    searchRef = el;199    setTimeout(() => el.focus(), 50);200  };201202  createEffect(() => {203    void props.searchSeedNonce;204    const seed = props.searchSeed;205    if (!seed) return;206    setSearch(seed);207    requestAnimationFrame(() => {208      searchRef?.focus();209      searchRef?.setSelectionRange(seed.length, seed.length);210    });211  });212213  function enterSelectMode() {214    setSelecting(true);215    setSelectedIds(new Set<string>());216  }217218  function exitSelectMode() {219    setSelecting(false);220    setSelectedIds(new Set<string>());221  }222223  function toggleId(id: string) {224    const s = new Set<string>(selectedIds());225    if (s.has(id)) s.delete(id); else s.add(id);226    setSelectedIds(s);227  }228229  const allTags = createMemo(() => {230    const tagSet = new Set<string>();231    for (const entry of props.entries) {232      for (const tag of entry.tags ?? []) {233        tagSet.add(tag);234      }235    }236    return Array.from(tagSet).sort();237  });238239  const hasTags = createMemo(() => allTags().length > 0);240241  function toggleTag(tag: string) {242    const current = new Set(selectedTags());243    if (current.has(tag)) {244      current.delete(tag);245    } else {246      current.add(tag);247    }248    setSelectedTags(current);249  }250251  function fuzzyMatch(query: string, target: string): boolean {252    query = query.toLowerCase();253    target = target.toLowerCase();254    let qi = 0;255    for (let i = 0; i < target.length && qi < query.length; i++) {256      if (target[i] === query[qi]) qi++;257    }258    return qi === query.length;259  }260261  function isExactPrefix(query: string, target: string): boolean {262    return target.toLowerCase().startsWith(query.toLowerCase());263  }264265  function entryDomain(entry: VaultEntry): string {266    if (entry.type === "login" && entry.url) return getDisplayDomain(entry.url);267    if (entry.passkey?.rpId) return entry.passkey.rpId;268    return "";269  }270271  function entryMatchesDomain(entry: VaultEntry, domain: string): boolean {272    if (!domain) return false;273    if (entry.passkey?.rpId === domain) return true;274    if (entry.type !== "login") return false;275    // Tab domain is the eTLD+1 from extractDomain — compare against eTLD+1 of276    // each entry URL too so subdomain entries also match.277    if (entry.url && extractDomain(entry.url) === domain) return true;278    for (const extra of entry.additionalUrls ?? []) {279      if (extractDomain(extra) === domain) return true;280    }281    return false;282  }283284  const filteredEntries = createMemo(() => {285    let result = props.entries;286287    if (notesOnly()) {288      result = result.filter(entry => entry.type === "note");289    }290291    if (selectedTags().size > 0) {292      result = result.filter(entry =>293        Array.from(selectedTags()).every(tag => entry.tags?.includes(tag))294      );295    }296297    const q = search().trim();298    if (q) {299      const searchableFor = (e: VaultEntry): string => {300        if (e.type === "note") return `${e.title} ${e.notes ?? ""}`;301        const passkeyText = e.passkey302          ? ` ${e.passkey.rpId} ${e.passkey.userName} ${e.passkey.userDisplayName}`303          : "";304        return `${e.title} ${e.url ?? ""} ${e.username ?? ""}${passkeyText}`;305      };306      result = result307        .filter((entry) => fuzzyMatch(q, searchableFor(entry)))308        .sort((a, b) => {309          const aSearchable = searchableFor(a).toLowerCase();310          const bSearchable = searchableFor(b).toLowerCase();311          const qLower = q.toLowerCase();312          const aExact = isExactPrefix(qLower, aSearchable);313          const bExact = isExactPrefix(qLower, bSearchable);314          if (aExact && !bExact) return -1;315          if (!aExact && bExact) return 1;316          return 0;317        });318      return result;319    }320321    const tabDomain = props.currentTabDomain ?? "";322    const order = props.sortOrder;323324    if (order === "az") {325      return [...result].sort((a, b) => a.title.localeCompare(b.title));326    }327    if (order === "za") {328      return [...result].sort((a, b) => b.title.localeCompare(a.title));329    }330    if (order === "recent") {331      return [...result].sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0));332    }333    if (tabDomain) {334      return [...result].sort((a, b) => {335        const aMatch = entryMatchesDomain(a, tabDomain) ? 0 : 1;336        const bMatch = entryMatchesDomain(b, tabDomain) ? 0 : 1;337        return aMatch - bMatch;338      });339    }340    return result;341  });342343  // Declaration order matters: `allVisibleIds` and `allSelected` read344  // `filteredEntries` eagerly on component mount via createMemo. Moving345  // `filteredEntries` below them throws ReferenceError under the production346  // minifier and freezes the vault list on mount (v1.4.34 regression).347  const allVisibleIds = createMemo(() => filteredEntries().map(e => e.id));348  const allSelected = createMemo(() =>349    allVisibleIds().length > 0 && allVisibleIds().every(id => selectedIds().has(id))350  );351352  function toggleSelectAll() {353    if (allSelected()) {354      setSelectedIds(new Set<string>());355    } else {356      setSelectedIds(new Set<string>(allVisibleIds()));357    }358  }359360  async function handleDeleteSelected() {361    const ids = Array.from(selectedIds());362    if (!ids.length || !props.onDeleteMany) return;363    setDeleting(true);364    await props.onDeleteMany(ids);365    setDeleting(false);366    exitSelectMode();367  }368369  // Virtualization: under VIRTUAL_THRESHOLD entries we render the full list.370  // Above it, we render only a window around the visible viewport plus a371  // BUFFER_ROWS-tall buffer above and below so scroll never reveals empty372  // space. Row height is approximate (variable padding for badges) but373  // bounded — the buffer absorbs over/undershoot.374  const VIRTUAL_THRESHOLD = 100;375  const ROW_HEIGHT = 56;376  const BUFFER_ROWS = 8;377378  const isVirtualized = createMemo(() => filteredEntries().length > VIRTUAL_THRESHOLD);379380  const visibleRange = createMemo(() => {381    const total = filteredEntries().length;382    if (!isVirtualized()) return { start: 0, end: total };383    const start = Math.max(0, Math.floor(scrollTop() / ROW_HEIGHT) - BUFFER_ROWS);384    const end = Math.min(total, Math.ceil((scrollTop() + viewportHeight()) / ROW_HEIGHT) + BUFFER_ROWS);385    return { start, end };386  });387388  const windowedEntries = createMemo(() => {389    const { start, end } = visibleRange();390    return filteredEntries().slice(start, end);391  });392393  function handleListScroll(e: Event) {394    if (!isVirtualized()) return;395    const target = e.currentTarget as HTMLDivElement;396    setScrollTop(target.scrollTop);397    setViewportHeight(target.clientHeight);398  }399400  function handleSearchKeydown(e: KeyboardEvent) {401    if (e.key === "ArrowDown") {402      e.preventDefault();403      const firstBtn = listRef?.querySelector<HTMLButtonElement>(".peach-entry-row");404      firstBtn?.focus();405    } else if (e.key === "ArrowUp") {406      e.preventDefault();407      const allBtns = listRef?.querySelectorAll<HTMLButtonElement>(".peach-entry-row");408      allBtns?.[allBtns.length - 1]?.focus();409    } else if (e.key === "Enter") {410      e.preventDefault();411      const firstEntry = filteredEntries()[0];412      if (firstEntry) props.onSelectEntry(firstEntry);413    } else if (e.key === "Escape" && selecting()) {414      exitSelectMode();415    }416  }417418  function handleListKeydown(e: KeyboardEvent) {419    if (!listRef) return;420    const btns = Array.from(listRef.querySelectorAll<HTMLButtonElement>(".peach-entry-row"));421    const idx = btns.indexOf(document.activeElement as HTMLButtonElement);422    if (e.key === "ArrowDown") {423      e.preventDefault();424      if (idx < btns.length - 1) btns[idx + 1]?.focus();425    } else if (e.key === "ArrowUp") {426      e.preventDefault();427      if (idx === 0) { searchRef?.focus(); }428      else btns[idx - 1]?.focus();429    } else if (e.key === "Escape") {430      if (selecting()) exitSelectMode();431      else props.onClose();432    }433  }434435  const sortLabels: { key: SortOrder; label: string }[] = [436    { key: "url-first", label: "smart" },437    { key: "az", label: "A–Z" },438    { key: "za", label: "Z–A" },439    { key: "recent", label: "newest" },440  ];441442  return (443    <div class="peach-overlay-view ov-enter-up" style="display:flex">444      <div class="peach-list-top">445        <button446          class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"447          onClick={props.onClose}448          aria-label="Back"449        >450          <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">451            <path d="M6 1L1 6L6 11" />452          </svg>453        </button>454        <input455          class="peach-search-input"456          placeholder="search vault..."457          autocomplete="off"458          style="font-size:14px"459          ref={focusSearch}460          value={search()}461          onInput={(e) => setSearch((e.currentTarget as HTMLInputElement).value)}462          onKeyDown={handleSearchKeydown}463        />464        <span class="peach-search-counter peach-search-counter-list">465          {search() || selectedTags().size > 0466            ? `${filteredEntries().length} of ${props.entries.length}`467            : `${props.entries.length} entries`}468        </span>469      </div>470471      {/* Sort bar — morphs into select-mode controls */}472      <div class="peach-sort-bar">473        <Show474          when={selecting()}475          fallback={476            <>477              <For each={sortLabels}>478                {({ key, label }) => (479                  <button480                    class={`peach-sort-btn${props.sortOrder === key ? " is-active" : ""}`}481                    onClick={() => props.onSortOrderChange(key)}482                  >483                    {label}484                  </button>485                )}486              </For>487              <span style="flex:1" />488              <button489                class={`peach-sort-trash-btn${notesOnly() ? " is-active" : ""}`}490                onClick={() => setNotesOnly(v => !v)}491                aria-label="Notes only"492                title="Show notes only"493              >494                {NOTE_ICON}495              </button>496              <Show when={props.onOpenTrash}>497                <button498                  class="peach-sort-trash-btn"499                  onClick={props.onOpenTrash}500                  aria-label="View trash"501                  title="View deleted entries"502                >503                  <svg width="11" height="12" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">504                    <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />505                  </svg>506                </button>507              </Show>508              <Show when={props.onDeleteMany}>509                <button510                  class="peach-sort-trash-btn"511                  onClick={enterSelectMode}512                  aria-label="Select entries"513                  title="Select entries"514                >515                  <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">516                    <circle cx="4" cy="4" r="2.5" />517                    <circle cx="9" cy="4" r="2.5" />518                    <circle cx="4" cy="9" r="2.5" />519                    <circle cx="9" cy="9" r="2.5" />520                  </svg>521                </button>522              </Show>523            </>524          }525        >526          {/* Select mode sort bar */}527          <button528            class="peach-select-cancel-btn"529            onClick={exitSelectMode}530          >531            cancel532          </button>533          <span class="peach-select-count-label">534            {selectedIds().size > 0 ? `${selectedIds().size} selected` : "select entries"}535          </span>536          <button537            class="peach-select-all-btn"538            onClick={toggleSelectAll}539          >540            {allSelected() ? "none" : "all"}541          </button>542        </Show>543      </div>544545      <Show when={hasTags() && !selecting()}>546        <div class="peach-tag-filter-strip" style="display:flex;gap:6px;padding:4px 12px 6px;overflow-x:auto;flex-wrap:nowrap;scrollbar-width:none;">547          <For each={allTags()}>548            {(tag) => (549              <button550                class={`peach-tag-filter-chip ${selectedTags().has(tag) ? "is-selected" : ""}`}551                onClick={() => toggleTag(tag)}552              >553                {tag}554              </button>555            )}556          </For>557        </div>558      </Show>559560      <div561        class={`peach-vault-list${selecting() ? " is-selecting" : ""}`}562        ref={(el) => { listRef = el; setViewportHeight(el.clientHeight); }}563        onKeyDown={handleListKeydown}564        onScroll={handleListScroll}565      >566        <Show567          when={filteredEntries().length > 0}568          fallback={569            <div class="peach-list-empty">570              <span>no entries yet</span>571            </div>572          }573        >574          <Show when={isVirtualized()}>575            <div style={{ height: `${visibleRange().start * ROW_HEIGHT}px` }} aria-hidden="true" />576          </Show>577          <For each={windowedEntries()}>578            {(entry, localIndex) => {579              const index = () => visibleRange().start + localIndex();580              const isNote = entry.type === "note";581              const isPasskey = entry.passkey !== undefined;582              const domain = entry.type === "note"583                ? (entry.notes?.slice(0, 40) ?? "note")584                : entry.url585                  ? getDisplayDomain(entry.url)586                  : entry.passkey?.rpId587                    ? entry.passkey.rpId588                  : "saved entry";589              const isTabMatch = () =>590                props.currentTabDomain !== undefined &&591                props.currentTabDomain !== "" &&592                entryMatchesDomain(entry, props.currentTabDomain);593              const isSelected = () => selectedIds().has(entry.id);594595              const ariaLabel = (() => {596                const tag = isPasskey ? "passkey" : isNote ? "note" : "login";597                const sub = entry.username || domain || "";598                return sub599                  ? `${entry.title} — ${sub} (${tag})`600                  : `${entry.title} (${tag})`;601              })();602              return (603                <button604                  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" : ""}`}605                  style={{ "animation-delay": `${Math.min(index(), 24) * 20}ms` }}606                  aria-label={ariaLabel}607                  aria-pressed={selecting() ? isSelected() : undefined}608                  onTouchStart={(e) => handleRowTouchStart(entry, e)}609                  onTouchMove={handleRowTouchMove}610                  onTouchEnd={(e) => void handleRowTouchEnd(entry, e)}611                  onTouchCancel={() => { clearLongPressTimer(); touchStart = null; }}612                  onContextMenu={(e) => { e.preventDefault(); if (!selecting()) setContextEntry(entry); }}613                  onClick={() => {614                    if (suppressNextClick) { suppressNextClick = false; return; }615                    if (selecting()) {616                      toggleId(entry.id);617                    } else {618                      props.onSelectEntry(entry);619                    }620                  }}621                >622                  <div class={`peach-entry-row-icon${!selecting() && entry.breach ? " is-breach" : ""}${selecting() ? " is-select-icon" : ""}`}>623                    <Show624                      when={selecting()}625                      fallback={626                        <FaviconIcon627                          domain={entry.url ? getDisplayDomain(entry.url) : ""}628                          title={entry.title}629                          isNote={isNote}630                          isPasskey={isPasskey}631                        />632                      }633                    >634                      {/* Check indicator */}635                      <svg width="16" height="16" viewBox="0 0 16 16" fill="none">636                        <circle637                          cx="8" cy="8" r="7"638                          stroke="currentColor"639                          stroke-width="1"640                          fill={isSelected() ? "var(--peach-accent)" : "transparent"}641                          opacity={isSelected() ? "1" : "0.3"}642                        />643                        <Show when={isSelected()}>644                          <path645                            d="M5 8.5L7 10.5L11 6"646                            stroke="white"647                            stroke-width="1.2"648                            stroke-linecap="round"649                            stroke-linejoin="round"650                          />651                        </Show>652                      </svg>653                    </Show>654                  </div>655                  <div class="peach-entry-row-info">656                    <span class="peach-entry-row-name">657                      {entry.title}658                      <Show when={isPasskey && !selecting()}>659                        <span class="peach-passkey-badge">passkey</span>660                      </Show>661                      <Show when={isTabMatch() && !selecting()}>662                        <span class="peach-tab-match-badge" title="Matches current tab">●</span>663                      </Show>664                    </span>665                    <span class="peach-entry-row-meta">{domain}</span>666                  </div>667                  <Show when={entry.breach && !selecting()}>668                    <span class="peach-entry-row-dot" aria-label="breach"></span>669                  </Show>670                  <Show when={entry.canary && !selecting()}>671                    <span class="peach-entry-row-canary" title={`Canary: ${entry.canary?.label ?? "canary entry"}`}>🪤</span>672                  </Show>673                </button>674              );675            }}676          </For>677          <Show when={isVirtualized()}>678            <div679              style={{ height: `${(filteredEntries().length - visibleRange().end) * ROW_HEIGHT}px` }}680              aria-hidden="true"681            />682          </Show>683        </Show>684      </div>685686      {/* Bottom bar — new entry normally, delete action in select mode */}687      <Show688        when={selecting()}689        fallback={690          <button class="peach-new-entry-row" onClick={props.onAdd}>691            <svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round">692              <path d="M5 1v8M1 5h8" />693            </svg>694            new entry695          </button>696        }697      >698        <button699          class={`peach-select-action-bar${selectedIds().size === 0 ? " is-empty" : ""}`}700          disabled={selectedIds().size === 0 || deleting()}701          onClick={() => void handleDeleteSelected()}702        >703          <svg width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">704            <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />705          </svg>706          <Show707            when={selectedIds().size > 0}708            fallback={<span>select entries to delete</span>}709          >710            <span>711              {deleting()712                ? "moving to trash…"713                : `move ${selectedIds().size} to trash`}714            </span>715          </Show>716        </button>717      </Show>718719      <Show when={contextEntry() !== null}>720        <div721          class="peach-context-menu-backdrop"722          onClick={() => setContextEntry(null)}723          role="presentation"724        >725          <div726            class="peach-context-menu"727            role="menu"728            aria-label="Entry actions"729            onClick={(e) => e.stopPropagation()}730          >731            <Show when={contextEntry()!.username}>732              <button733                role="menuitem"734                class="peach-context-menu-item"735                onClick={async () => {736                  const e = contextEntry();737                  if (!e?.username) return;738                  await popupCopy(e.username, "username");739                  setContextEntry(null);740                }}741              >742                Copy username743              </button>744            </Show>745            <Show when={contextEntry()!.password}>746              <button747                role="menuitem"748                class="peach-context-menu-item"749                onClick={async () => {750                  const e = contextEntry();751                  if (!e?.password) return;752                  await popupCopy(e.password, "password");753                  setContextEntry(null);754                }}755              >756                Copy password757              </button>758            </Show>759            <Show when={contextEntry()!.totp}>760              <button761                role="menuitem"762                class="peach-context-menu-item"763                onClick={async () => {764                  const e = contextEntry();765                  if (!e?.totp) return;766                  await popupCopy(e.totp, "TOTP secret");767                  setContextEntry(null);768                }}769              >770                Copy TOTP secret771              </button>772            </Show>773            <button774              role="menuitem"775              class="peach-context-menu-item"776              onClick={() => {777                const e = contextEntry();778                if (e) props.onSelectEntry(e);779                setContextEntry(null);780              }}781            >782              Open783            </button>784            <Show when={props.onDeleteMany}>785              <button786                role="menuitem"787                class="peach-context-menu-item peach-context-menu-item--danger"788                onClick={async () => {789                  const e = contextEntry();790                  if (!e) return;791                  setContextEntry(null);792                  await props.onDeleteMany?.([e.id]);793                }}794              >795                Move to trash796              </button>797            </Show>798            <button799              role="menuitem"800              class="peach-context-menu-item peach-context-menu-item--cancel"801              onClick={() => setContextEntry(null)}802            >803              Cancel804            </button>805          </div>806        </div>807      </Show>808    </div>809  );810}