Vault.tsx

29.4 KB · raw

1import { createEffect, createMemo, createSignal, For, Show } from "solid-js";2import type { VaultEntry } from "../../shared/types";3import { extractDomain, getDisplayDomain } from "../../shared/domain";4import { faviconUrl } from "../../shared/favicon";5import { popupCopy } from "../copy";67function vibrate(ms: number) {8  try { navigator.vibrate?.(ms); } catch { /* unsupported */ }9}1011function FaviconIcon(props: { domain: string; title?: string; isNote: boolean; isPasskey: boolean }) {12  const src = props.domain && !props.isNote && !props.isPasskey13    ? faviconUrl(props.domain)14    : null;15  const fallback = props.isPasskey16    ? <PasskeyIcon />17    : props.isNote18      ? <NoteIcon />19      : <InitialsChip title={props.title ?? props.domain ?? ""} />;20  const [failed, setFailed] = createSignal(false);21  return (22    <Show when={src && !failed()} fallback={fallback}>23      <img24        src={src!}25        width="16"26        height="16"27        alt=""28        style="border-radius:3px;"29        onError={() => setFailed(true)}30        onLoad={(e) => {31          // Chrome's _favicon/ returns the default globe at its native 16×1632          // even when size=32 is requested. A genuinely cached favicon arrives33          // at ≥32px. Treat ≤16 as "no favicon" so InitialsChip shows instead.34          if ((e.currentTarget as HTMLImageElement).naturalWidth <= 16) setFailed(true);35        }}36      />37    </Show>38  );39}4041/**42 * Title → "PM"-style initials. First letters of the first two words,43 * falling back to the first two characters of a single-word title. Empty44 * titles get a small dot. Deterministic so the same entry always renders45 * the same chip.46 */47function titleInitials(source: string): string {48  const cleaned = source.replace(/^https?:\/\//, "").replace(/^www\./, "").trim();49  if (!cleaned) return "·";50  const words = cleaned.split(/[\s.\-_/]+/).filter(Boolean);51  if (words.length >= 2 && words[0]![0] && words[1]![0]) {52    return (words[0]![0]! + words[1]![0]!).toUpperCase();53  }54  const single = words[0] ?? cleaned;55  return single.slice(0, 2).toUpperCase();56}5758/**59 * Hash a string to an HSL background. Fixed saturation + lightness so the60 * palette stays muted and dark enough that white initials read clearly on61 * top, while still giving each entry a recognisable colour signature.62 */63function titleBackground(source: string): string {64  let hash = 0;65  for (let i = 0; i < source.length; i++) {66    hash = ((hash << 5) - hash + source.charCodeAt(i)) | 0;67  }68  const hue = ((hash % 360) + 360) % 360;69  return `hsl(${hue}, 32%, 26%)`;70}7172function InitialsChip(props: { title: string }) {73  return (74    <span75      class="peach-favicon-initials"76      style={{ background: titleBackground(props.title) }}77      aria-hidden="true"78    >79      {titleInitials(props.title)}80    </span>81  );82}838485const PasskeyIcon = () => (86  <svg width="11" height="12" viewBox="0 0 11 12" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round" aria-hidden="true">87    <circle cx="4" cy="4.5" r="3" />88    <path d="M6.5 7l3.5 3.5M8.5 8.5L10 10" />89  </svg>90);9192const NoteIcon = () => (93  <svg width="11" height="12" viewBox="0 0 11 12" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round" aria-hidden="true">94    <rect x="1.5" y="1.5" width="8" height="9" rx="1.5" />95    <path d="M3.5 4.5h4M3.5 6.5h4M3.5 8.5h2" />96  </svg>97);9899export type SortOrder = "url-first" | "az" | "za" | "recent";100101interface VaultProps {102  entries: VaultEntry[];103  selectedEntryId?: string;104  currentTabDomain?: string;105  sortOrder: SortOrder;106  searchSeed?: string;107  searchSeedNonce?: number;108  // When true, render the mobile flat top bar: no back arrow (use swipe-down109  // on the bottom sheet), search-glass icon on the left, counter on the110  // right. Matches the extension's `.peach-search-bar` look.111  isNative?: boolean;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 entryMatchesDomain(entry: VaultEntry, domain: string): boolean {266    if (!domain) return false;267    if (entry.passkey?.rpId === domain) return true;268    if (entry.type !== "login") return false;269    // Tab domain is the eTLD+1 from extractDomain — compare against eTLD+1 of270    // each entry URL too so subdomain entries also match.271    if (entry.url && extractDomain(entry.url) === domain) return true;272    for (const extra of entry.additionalUrls ?? []) {273      if (extractDomain(extra) === domain) return true;274    }275    return false;276  }277278  const filteredEntries = createMemo(() => {279    let result = props.entries;280281    if (notesOnly()) {282      result = result.filter(entry => entry.type === "note");283    }284285    if (selectedTags().size > 0) {286      result = result.filter(entry =>287        Array.from(selectedTags()).every(tag => entry.tags?.includes(tag))288      );289    }290291    const q = search().trim();292    if (q) {293      const searchableFor = (e: VaultEntry): string => {294        if (e.type === "note") return `${e.title} ${e.notes ?? ""}`;295        const passkeyText = e.passkey296          ? ` ${e.passkey.rpId} ${e.passkey.userName} ${e.passkey.userDisplayName}`297          : "";298        return `${e.title} ${e.url ?? ""} ${e.username ?? ""}${passkeyText}`;299      };300      result = result301        .filter((entry) => fuzzyMatch(q, searchableFor(entry)))302        .sort((a, b) => {303          const aSearchable = searchableFor(a).toLowerCase();304          const bSearchable = searchableFor(b).toLowerCase();305          const qLower = q.toLowerCase();306          const aExact = isExactPrefix(qLower, aSearchable);307          const bExact = isExactPrefix(qLower, bSearchable);308          if (aExact && !bExact) return -1;309          if (!aExact && bExact) return 1;310          return 0;311        });312      return result;313    }314315    const tabDomain = props.currentTabDomain ?? "";316    const order = props.sortOrder;317318    if (order === "az") {319      return [...result].sort((a, b) => a.title.localeCompare(b.title));320    }321    if (order === "za") {322      return [...result].sort((a, b) => b.title.localeCompare(a.title));323    }324    if (order === "recent") {325      return [...result].sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0));326    }327    if (tabDomain) {328      return [...result].sort((a, b) => {329        const aMatch = entryMatchesDomain(a, tabDomain) ? 0 : 1;330        const bMatch = entryMatchesDomain(b, tabDomain) ? 0 : 1;331        return aMatch - bMatch;332      });333    }334    return result;335  });336337  // Declaration order matters: `allVisibleIds` and `allSelected` read338  // `filteredEntries` eagerly on component mount via createMemo. Moving339  // `filteredEntries` below them throws ReferenceError under the production340  // minifier and freezes the vault list on mount (v1.4.34 regression).341  const allVisibleIds = createMemo(() => filteredEntries().map(e => e.id));342  const allSelected = createMemo(() =>343    allVisibleIds().length > 0 && allVisibleIds().every(id => selectedIds().has(id))344  );345346  function toggleSelectAll() {347    if (allSelected()) {348      setSelectedIds(new Set<string>());349    } else {350      setSelectedIds(new Set<string>(allVisibleIds()));351    }352  }353354  async function handleDeleteSelected() {355    const ids = Array.from(selectedIds());356    if (!ids.length || !props.onDeleteMany) return;357    setDeleting(true);358    await props.onDeleteMany(ids);359    setDeleting(false);360    exitSelectMode();361  }362363  // Virtualization: under VIRTUAL_THRESHOLD entries we render the full list.364  // Above it, we render only a window around the visible viewport plus a365  // BUFFER_ROWS-tall buffer above and below so scroll never reveals empty366  // space. Row height is approximate (variable padding for badges) but367  // bounded — the buffer absorbs over/undershoot.368  const VIRTUAL_THRESHOLD = 100;369  const ROW_HEIGHT = 56;370  const BUFFER_ROWS = 20;371372  const isVirtualized = createMemo(() => filteredEntries().length > VIRTUAL_THRESHOLD);373374  const visibleRange = createMemo(() => {375    const total = filteredEntries().length;376    if (!isVirtualized()) return { start: 0, end: total };377    const start = Math.max(0, Math.floor(scrollTop() / ROW_HEIGHT) - BUFFER_ROWS);378    const end = Math.min(total, Math.ceil((scrollTop() + viewportHeight()) / ROW_HEIGHT) + BUFFER_ROWS);379    return { start, end };380  });381382  const windowedEntries = createMemo(() => {383    const { start, end } = visibleRange();384    return filteredEntries().slice(start, end);385  });386387  function handleListScroll(e: Event) {388    if (!isVirtualized()) return;389    const target = e.currentTarget as HTMLDivElement;390    setScrollTop(target.scrollTop);391    setViewportHeight(target.clientHeight);392  }393394  function handleSearchKeydown(e: KeyboardEvent) {395    if (e.key === "ArrowDown") {396      e.preventDefault();397      const firstBtn = listRef?.querySelector<HTMLButtonElement>(".peach-entry-row");398      firstBtn?.focus();399    } else if (e.key === "ArrowUp") {400      e.preventDefault();401      const allBtns = listRef?.querySelectorAll<HTMLButtonElement>(".peach-entry-row");402      allBtns?.[allBtns.length - 1]?.focus();403    } else if (e.key === "Enter") {404      e.preventDefault();405      const firstEntry = filteredEntries()[0];406      if (firstEntry) props.onSelectEntry(firstEntry);407    } else if (e.key === "Escape" && selecting()) {408      exitSelectMode();409    }410  }411412  function handleListKeydown(e: KeyboardEvent) {413    if (!listRef) return;414    const btns = Array.from(listRef.querySelectorAll<HTMLButtonElement>(".peach-entry-row"));415    const idx = btns.indexOf(document.activeElement as HTMLButtonElement);416    if (e.key === "ArrowDown") {417      e.preventDefault();418      if (idx < btns.length - 1) btns[idx + 1]?.focus();419    } else if (e.key === "ArrowUp") {420      e.preventDefault();421      if (idx === 0) { searchRef?.focus(); }422      else btns[idx - 1]?.focus();423    } else if (e.key === "Escape") {424      if (selecting()) exitSelectMode();425      else props.onClose();426    }427  }428429  const sortLabels: { key: SortOrder; label: string }[] = [430    { key: "url-first", label: "smart" },431    { key: "az", label: "A–Z" },432    { key: "za", label: "Z–A" },433    { key: "recent", label: "newest" },434  ];435436  return (437    <div class="peach-overlay-view ov-enter-up" style="display:flex">438      <div class="peach-list-top" classList={{ "peach-list-top--native": !!props.isNative }}>439        <Show440          when={!props.isNative}441          fallback={442            <span class="peach-search-icon" aria-hidden="true">443              <svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round">444                <circle cx="5.5" cy="5.5" r="3.75" />445                <path d="M8.75 8.75L11.5 11.5" />446              </svg>447            </span>448          }449        >450          <button451            class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"452            onClick={props.onClose}453            aria-label="Back"454          >455            <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">456              <path d="M6 1L1 6L6 11" />457            </svg>458          </button>459        </Show>460        <input461          class="peach-search-input"462          placeholder="search vault..."463          autocomplete="off"464          style={props.isNative ? undefined : "font-size:14px"}465          ref={focusSearch}466          value={search()}467          onInput={(e) => setSearch((e.currentTarget as HTMLInputElement).value)}468          onKeyDown={handleSearchKeydown}469        />470        <span class="peach-search-counter peach-search-counter-list">471          {search() || selectedTags().size > 0472            ? `${filteredEntries().length} of ${props.entries.length}`473            : props.isNative474              ? `${props.entries.length}`475              : `${props.entries.length} entries`}476        </span>477      </div>478479      {/* Sort bar — morphs into select-mode controls */}480      <div class="peach-sort-bar">481        <Show482          when={selecting()}483          fallback={484            <>485              <For each={sortLabels}>486                {({ key, label }) => (487                  <button488                    class={`peach-sort-btn${props.sortOrder === key ? " is-active" : ""}`}489                    onClick={() => props.onSortOrderChange(key)}490                  >491                    {label}492                  </button>493                )}494              </For>495              <span style="flex:1" />496              <button497                class={`peach-sort-trash-btn${notesOnly() ? " is-active" : ""}`}498                onClick={() => setNotesOnly(v => !v)}499                aria-label="Notes only"500                title="Show notes only"501              >502                <NoteIcon />503              </button>504              <Show when={props.onOpenTrash}>505                <button506                  class="peach-sort-trash-btn"507                  onClick={props.onOpenTrash}508                  aria-label="View trash"509                  title="View deleted entries"510                >511                  <svg width="11" height="12" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">512                    <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />513                  </svg>514                </button>515              </Show>516              <Show when={props.onDeleteMany}>517                <button518                  class="peach-sort-trash-btn"519                  onClick={enterSelectMode}520                  aria-label="Select entries"521                  title="Select entries"522                >523                  <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">524                    <circle cx="4" cy="4" r="2.5" />525                    <circle cx="9" cy="4" r="2.5" />526                    <circle cx="4" cy="9" r="2.5" />527                    <circle cx="9" cy="9" r="2.5" />528                  </svg>529                </button>530              </Show>531            </>532          }533        >534          {/* Select mode sort bar */}535          <button536            class="peach-select-cancel-btn"537            onClick={exitSelectMode}538          >539            cancel540          </button>541          <span class="peach-select-count-label">542            {selectedIds().size > 0 ? `${selectedIds().size} selected` : "select entries"}543          </span>544          <button545            class="peach-select-all-btn"546            onClick={toggleSelectAll}547          >548            {allSelected() ? "none" : "all"}549          </button>550        </Show>551      </div>552553      <Show when={hasTags() && !selecting()}>554        <div class="peach-tag-filter-strip" style="display:flex;gap:6px;padding:4px 12px 6px;overflow-x:auto;flex-wrap:nowrap;scrollbar-width:none;">555          <For each={allTags()}>556            {(tag) => (557              <button558                class={`peach-tag-filter-chip ${selectedTags().has(tag) ? "is-selected" : ""}`}559                onClick={() => toggleTag(tag)}560              >561                {tag}562              </button>563            )}564          </For>565        </div>566      </Show>567568      <div569        class={`peach-vault-list${selecting() ? " is-selecting" : ""}`}570        ref={(el) => { listRef = el; setViewportHeight(el.clientHeight); }}571        onKeyDown={handleListKeydown}572        onScroll={handleListScroll}573      >574        <Show575          when={filteredEntries().length > 0}576          fallback={577            <div class="peach-list-empty">578              <span>no entries yet</span>579            </div>580          }581        >582          <Show when={isVirtualized()}>583            <div style={{ height: `${visibleRange().start * ROW_HEIGHT}px` }} aria-hidden="true" />584          </Show>585          <For each={windowedEntries()}>586            {(entry, localIndex) => {587              const index = () => visibleRange().start + localIndex();588              const isNote = entry.type === "note";589              const isPasskey = entry.passkey !== undefined;590              const domain = entry.type === "note"591                ? (entry.notes?.slice(0, 40) ?? "note")592                : entry.url593                  ? getDisplayDomain(entry.url)594                  : entry.passkey?.rpId595                    ? entry.passkey.rpId596                  : "saved entry";597              const isTabMatch = () =>598                props.currentTabDomain !== undefined &&599                props.currentTabDomain !== "" &&600                entryMatchesDomain(entry, props.currentTabDomain);601              const isSelected = () => selectedIds().has(entry.id);602603              const ariaLabel = (() => {604                const tag = isPasskey ? "passkey" : isNote ? "note" : "login";605                const sub = entry.username || domain || "";606                return sub607                  ? `${entry.title} — ${sub} (${tag})`608                  : `${entry.title} (${tag})`;609              })();610              return (611                <button612                  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" : ""}`}613                  style={isVirtualized()614                    ? { animation: "none" }615                    : { "animation-delay": `${Math.min(index(), 24) * 20}ms` }616                  }617                  aria-label={ariaLabel}618                  aria-pressed={selecting() ? isSelected() : undefined}619                  onTouchStart={(e) => handleRowTouchStart(entry, e)}620                  onTouchMove={handleRowTouchMove}621                  onTouchEnd={(e) => void handleRowTouchEnd(entry, e)}622                  onTouchCancel={() => { clearLongPressTimer(); touchStart = null; }}623                  onContextMenu={(e) => { e.preventDefault(); if (!selecting()) setContextEntry(entry); }}624                  onClick={() => {625                    if (suppressNextClick) { suppressNextClick = false; return; }626                    if (selecting()) {627                      toggleId(entry.id);628                    } else {629                      props.onSelectEntry(entry);630                    }631                  }}632                >633                  <div class={`peach-entry-row-icon${!selecting() && entry.breach ? " is-breach" : ""}${selecting() ? " is-select-icon" : ""}`}>634                    <Show635                      when={selecting()}636                      fallback={637                        <FaviconIcon638                          domain={entry.url ? getDisplayDomain(entry.url) : ""}639                          title={entry.title}640                          isNote={isNote}641                          isPasskey={isPasskey}642                        />643                      }644                    >645                      {/* Check indicator */}646                      <svg width="16" height="16" viewBox="0 0 16 16" fill="none">647                        <circle648                          cx="8" cy="8" r="7"649                          stroke="currentColor"650                          stroke-width="1"651                          fill={isSelected() ? "var(--peach-accent)" : "transparent"}652                          opacity={isSelected() ? "1" : "0.3"}653                        />654                        <Show when={isSelected()}>655                          <path656                            d="M5 8.5L7 10.5L11 6"657                            stroke="white"658                            stroke-width="1.2"659                            stroke-linecap="round"660                            stroke-linejoin="round"661                          />662                        </Show>663                      </svg>664                    </Show>665                  </div>666                  <div class="peach-entry-row-info">667                    <span class="peach-entry-row-name">668                      {entry.title}669                      <Show when={isPasskey && !selecting()}>670                        <span class="peach-passkey-badge">passkey</span>671                      </Show>672                      <Show when={isTabMatch() && !selecting()}>673                        <span class="peach-tab-match-badge" title="Matches current tab">●</span>674                      </Show>675                    </span>676                    <span class="peach-entry-row-meta">{domain}</span>677                  </div>678                  <Show when={entry.breach && !selecting()}>679                    <span class="peach-entry-row-dot" aria-label="breach"></span>680                  </Show>681                  <Show when={entry.canary && !selecting()}>682                    <span class="peach-entry-row-canary" title={`Canary: ${entry.canary?.label ?? "canary entry"}`}>🪤</span>683                  </Show>684                </button>685              );686            }}687          </For>688          <Show when={isVirtualized()}>689            <div690              style={{ height: `${(filteredEntries().length - visibleRange().end) * ROW_HEIGHT}px` }}691              aria-hidden="true"692            />693          </Show>694        </Show>695      </div>696697      {/* Bottom bar — new entry normally, delete action in select mode */}698      <Show699        when={selecting()}700        fallback={701          <button class="peach-new-entry-row" onClick={props.onAdd}>702            <svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round">703              <path d="M5 1v8M1 5h8" />704            </svg>705            new entry706          </button>707        }708      >709        <button710          class={`peach-select-action-bar${selectedIds().size === 0 ? " is-empty" : ""}`}711          disabled={selectedIds().size === 0 || deleting()}712          onClick={() => void handleDeleteSelected()}713        >714          <svg width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">715            <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />716          </svg>717          <Show718            when={selectedIds().size > 0}719            fallback={<span>select entries to delete</span>}720          >721            <span>722              {deleting()723                ? "moving to trash…"724                : `move ${selectedIds().size} to trash`}725            </span>726          </Show>727        </button>728      </Show>729730      <Show when={contextEntry() !== null}>731        <div732          class="peach-context-menu-backdrop"733          onClick={() => setContextEntry(null)}734          role="presentation"735        >736          <div737            class="peach-context-menu"738            role="menu"739            aria-label="Entry actions"740            onClick={(e) => e.stopPropagation()}741          >742            <Show when={contextEntry()!.username}>743              <button744                role="menuitem"745                class="peach-context-menu-item"746                onClick={async () => {747                  const e = contextEntry();748                  if (!e?.username) return;749                  await popupCopy(e.username, "username");750                  setContextEntry(null);751                }}752              >753                Copy username754              </button>755            </Show>756            <Show when={contextEntry()!.password}>757              <button758                role="menuitem"759                class="peach-context-menu-item"760                onClick={async () => {761                  const e = contextEntry();762                  if (!e?.password) return;763                  await popupCopy(e.password, "password");764                  setContextEntry(null);765                }}766              >767                Copy password768              </button>769            </Show>770            <Show when={contextEntry()!.totp}>771              <button772                role="menuitem"773                class="peach-context-menu-item"774                onClick={async () => {775                  const e = contextEntry();776                  if (!e?.totp) return;777                  await popupCopy(e.totp, "TOTP secret");778                  setContextEntry(null);779                }}780              >781                Copy TOTP secret782              </button>783            </Show>784            <button785              role="menuitem"786              class="peach-context-menu-item"787              onClick={() => {788                const e = contextEntry();789                if (e) props.onSelectEntry(e);790                setContextEntry(null);791              }}792            >793              Open794            </button>795            <Show when={props.onDeleteMany}>796              <button797                role="menuitem"798                class="peach-context-menu-item peach-context-menu-item--danger"799                onClick={async () => {800                  const e = contextEntry();801                  if (!e) return;802                  setContextEntry(null);803                  await props.onDeleteMany?.([e.id]);804                }}805              >806                Move to trash807              </button>808            </Show>809            <button810              role="menuitem"811              class="peach-context-menu-item peach-context-menu-item--cancel"812              onClick={() => setContextEntry(null)}813            >814              Cancel815            </button>816          </div>817        </div>818      </Show>819    </div>820  );821}