Vault.tsx

29.3 KB · raw

1import { createEffect, createMemo, createSignal, For, Show } from "solid-js";2import type { VaultEntry } from "../../shared/types";3import { extractDomain, getDisplayDomain } from "../../shared/domain";4import { faviconUrl } from "../../shared/favicon";5import { popupCopy } from "../copy";67function vibrate(ms: number) {8  try { navigator.vibrate?.(ms); } catch { /* unsupported */ }9}1011function FaviconIcon(props: { domain: string; title?: string; isNote: boolean; isPasskey: boolean }) {12  const src = props.domain && !props.isNote && !props.isPasskey13    ? faviconUrl(props.domain)14    : null;15  // Note/passkey entries keep their semantic glyphs. Login entries get a16  // colored-initials chip when the real favicon hasn't loaded — better than17  // a black square on freshly-imported entries the browser hasn't visited18  // yet (Chrome's `_favicon/` cache is empty for unvisited domains).19  const fallback = props.isPasskey20    ? <PasskeyIcon />21    : props.isNote22      ? <NoteIcon />23      : <InitialsChip title={props.title ?? props.domain ?? ""} />;24  const [failed, setFailed] = createSignal(false);25  return (26    <Show when={src && !failed()} fallback={fallback}>27      <img28        src={src!}29        width="16"30        height="16"31        alt=""32        style="border-radius:3px;"33        onError={() => setFailed(true)}34      />35    </Show>36  );37}3839/**40 * Title → "PM"-style initials. First letters of the first two words,41 * falling back to the first two characters of a single-word title. Empty42 * titles get a small dot. Deterministic so the same entry always renders43 * the same chip.44 */45function titleInitials(source: string): string {46  const cleaned = source.replace(/^https?:\/\//, "").replace(/^www\./, "").trim();47  if (!cleaned) return "·";48  const words = cleaned.split(/[\s.\-_/]+/).filter(Boolean);49  if (words.length >= 2 && words[0]![0] && words[1]![0]) {50    return (words[0]![0]! + words[1]![0]!).toUpperCase();51  }52  const single = words[0] ?? cleaned;53  return single.slice(0, 2).toUpperCase();54}5556/**57 * Hash a string to an HSL background. Fixed saturation + lightness so the58 * palette stays muted and dark enough that white initials read clearly on59 * top, while still giving each entry a recognisable colour signature.60 */61function titleBackground(source: string): string {62  let hash = 0;63  for (let i = 0; i < source.length; i++) {64    hash = ((hash << 5) - hash + source.charCodeAt(i)) | 0;65  }66  const hue = ((hash % 360) + 360) % 360;67  return `hsl(${hue}, 32%, 26%)`;68}6970function InitialsChip(props: { title: string }) {71  return (72    <span73      class="peach-favicon-initials"74      style={{ background: titleBackground(props.title) }}75      aria-hidden="true"76    >77      {titleInitials(props.title)}78    </span>79  );80}818283const PasskeyIcon = () => (84  <svg width="11" height="12" viewBox="0 0 11 12" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round" aria-hidden="true">85    <circle cx="4" cy="4.5" r="3" />86    <path d="M6.5 7l3.5 3.5M8.5 8.5L10 10" />87  </svg>88);8990const NoteIcon = () => (91  <svg width="11" height="12" viewBox="0 0 11 12" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round" aria-hidden="true">92    <rect x="1.5" y="1.5" width="8" height="9" rx="1.5" />93    <path d="M3.5 4.5h4M3.5 6.5h4M3.5 8.5h2" />94  </svg>95);9697export type SortOrder = "url-first" | "az" | "za" | "recent";9899interface VaultProps {100  entries: VaultEntry[];101  selectedEntryId?: string;102  currentTabDomain?: string;103  sortOrder: SortOrder;104  searchSeed?: string;105  searchSeedNonce?: number;106  // When true, render the mobile flat top bar: no back arrow (use swipe-down107  // on the bottom sheet), search-glass icon on the left, counter on the108  // right. Matches the extension's `.peach-search-bar` look.109  isNative?: boolean;110  onSelectEntry: (entry: VaultEntry) => void;111  onSortOrderChange: (order: SortOrder) => void;112  onAdd: () => void;113  onClose: () => void;114  onOpenTrash?: () => void;115  onDeleteMany?: (ids: string[]) => Promise<void>;116}117118export function VaultView(props: VaultProps) {119  const [search, setSearch] = createSignal("");120  const [selectedTags, setSelectedTags] = createSignal<Set<string>>(new Set());121  const [notesOnly, setNotesOnly] = createSignal(false);122  const [selecting, setSelecting] = createSignal(false);123  const [selectedIds, setSelectedIds] = createSignal<Set<string>>(new Set());124  const [deleting, setDeleting] = createSignal(false);125  const [contextEntry, setContextEntry] = createSignal<VaultEntry | null>(null);126  const [flashId, setFlashId] = createSignal<string | null>(null);127  const [scrollTop, setScrollTop] = createSignal(0);128  const [viewportHeight, setViewportHeight] = createSignal(600);129  let longPressTimer: number | null = null;130  let touchStart: { x: number; y: number; t: number } | null = null;131  let suppressNextClick = false;132133  function clearLongPressTimer() {134    if (longPressTimer !== null) {135      window.clearTimeout(longPressTimer);136      longPressTimer = null;137    }138  }139140  function handleRowTouchStart(entry: VaultEntry, e: TouchEvent) {141    if (selecting()) return;142    const t = e.touches[0];143    if (!t) return;144    touchStart = { x: t.clientX, y: t.clientY, t: Date.now() };145    clearLongPressTimer();146    longPressTimer = window.setTimeout(() => {147      suppressNextClick = true;148      vibrate(20);149      setContextEntry(entry);150      longPressTimer = null;151    }, 500);152  }153154  function handleRowTouchMove(e: TouchEvent) {155    if (!touchStart) return;156    const t = e.touches[0];157    if (!t) return;158    const dx = Math.abs(t.clientX - touchStart.x);159    const dy = Math.abs(t.clientY - touchStart.y);160    if (dx > 8 || dy > 8) clearLongPressTimer();161  }162163  async function handleRowTouchEnd(entry: VaultEntry, e: TouchEvent) {164    clearLongPressTimer();165    if (!touchStart) return;166    const t = e.changedTouches[0];167    if (!t) { touchStart = null; return; }168    const dx = t.clientX - touchStart.x;169    const dy = Math.abs(t.clientY - touchStart.y);170    const dt = Date.now() - touchStart.t;171    touchStart = null;172    // Right-swipe-to-copy: only on compact + login entries with a password173    const isCompact = !document.body.classList.contains("layout-medium")174      && !document.body.classList.contains("layout-expanded");175    if (176      isCompact177      && !selecting()178      && dx > 80179      && dy < 30180      && dt < 400181      && entry.type === "login"182      && entry.password183    ) {184      suppressNextClick = true;185      vibrate(15);186      setFlashId(entry.id);187      window.setTimeout(() => setFlashId((id) => id === entry.id ? null : id), 600);188      await popupCopy(entry.password, "password");189    }190  }191192  let searchRef: HTMLInputElement | undefined;193  let listRef: HTMLDivElement | undefined;194195  const focusSearch = (el: HTMLInputElement) => {196    searchRef = el;197    setTimeout(() => el.focus(), 50);198  };199200  createEffect(() => {201    void props.searchSeedNonce;202    const seed = props.searchSeed;203    if (!seed) return;204    setSearch(seed);205    requestAnimationFrame(() => {206      searchRef?.focus();207      searchRef?.setSelectionRange(seed.length, seed.length);208    });209  });210211  function enterSelectMode() {212    setSelecting(true);213    setSelectedIds(new Set<string>());214  }215216  function exitSelectMode() {217    setSelecting(false);218    setSelectedIds(new Set<string>());219  }220221  function toggleId(id: string) {222    const s = new Set<string>(selectedIds());223    if (s.has(id)) s.delete(id); else s.add(id);224    setSelectedIds(s);225  }226227  const allTags = createMemo(() => {228    const tagSet = new Set<string>();229    for (const entry of props.entries) {230      for (const tag of entry.tags ?? []) {231        tagSet.add(tag);232      }233    }234    return Array.from(tagSet).sort();235  });236237  const hasTags = createMemo(() => allTags().length > 0);238239  function toggleTag(tag: string) {240    const current = new Set(selectedTags());241    if (current.has(tag)) {242      current.delete(tag);243    } else {244      current.add(tag);245    }246    setSelectedTags(current);247  }248249  function fuzzyMatch(query: string, target: string): boolean {250    query = query.toLowerCase();251    target = target.toLowerCase();252    let qi = 0;253    for (let i = 0; i < target.length && qi < query.length; i++) {254      if (target[i] === query[qi]) qi++;255    }256    return qi === query.length;257  }258259  function isExactPrefix(query: string, target: string): boolean {260    return target.toLowerCase().startsWith(query.toLowerCase());261  }262263  function entryMatchesDomain(entry: VaultEntry, domain: string): boolean {264    if (!domain) return false;265    if (entry.passkey?.rpId === domain) return true;266    if (entry.type !== "login") return false;267    // Tab domain is the eTLD+1 from extractDomain — compare against eTLD+1 of268    // each entry URL too so subdomain entries also match.269    if (entry.url && extractDomain(entry.url) === domain) return true;270    for (const extra of entry.additionalUrls ?? []) {271      if (extractDomain(extra) === domain) return true;272    }273    return false;274  }275276  const filteredEntries = createMemo(() => {277    let result = props.entries;278279    if (notesOnly()) {280      result = result.filter(entry => entry.type === "note");281    }282283    if (selectedTags().size > 0) {284      result = result.filter(entry =>285        Array.from(selectedTags()).every(tag => entry.tags?.includes(tag))286      );287    }288289    const q = search().trim();290    if (q) {291      const searchableFor = (e: VaultEntry): string => {292        if (e.type === "note") return `${e.title} ${e.notes ?? ""}`;293        const passkeyText = e.passkey294          ? ` ${e.passkey.rpId} ${e.passkey.userName} ${e.passkey.userDisplayName}`295          : "";296        return `${e.title} ${e.url ?? ""} ${e.username ?? ""}${passkeyText}`;297      };298      result = result299        .filter((entry) => fuzzyMatch(q, searchableFor(entry)))300        .sort((a, b) => {301          const aSearchable = searchableFor(a).toLowerCase();302          const bSearchable = searchableFor(b).toLowerCase();303          const qLower = q.toLowerCase();304          const aExact = isExactPrefix(qLower, aSearchable);305          const bExact = isExactPrefix(qLower, bSearchable);306          if (aExact && !bExact) return -1;307          if (!aExact && bExact) return 1;308          return 0;309        });310      return result;311    }312313    const tabDomain = props.currentTabDomain ?? "";314    const order = props.sortOrder;315316    if (order === "az") {317      return [...result].sort((a, b) => a.title.localeCompare(b.title));318    }319    if (order === "za") {320      return [...result].sort((a, b) => b.title.localeCompare(a.title));321    }322    if (order === "recent") {323      return [...result].sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0));324    }325    if (tabDomain) {326      return [...result].sort((a, b) => {327        const aMatch = entryMatchesDomain(a, tabDomain) ? 0 : 1;328        const bMatch = entryMatchesDomain(b, tabDomain) ? 0 : 1;329        return aMatch - bMatch;330      });331    }332    return result;333  });334335  // Declaration order matters: `allVisibleIds` and `allSelected` read336  // `filteredEntries` eagerly on component mount via createMemo. Moving337  // `filteredEntries` below them throws ReferenceError under the production338  // minifier and freezes the vault list on mount (v1.4.34 regression).339  const allVisibleIds = createMemo(() => filteredEntries().map(e => e.id));340  const allSelected = createMemo(() =>341    allVisibleIds().length > 0 && allVisibleIds().every(id => selectedIds().has(id))342  );343344  function toggleSelectAll() {345    if (allSelected()) {346      setSelectedIds(new Set<string>());347    } else {348      setSelectedIds(new Set<string>(allVisibleIds()));349    }350  }351352  async function handleDeleteSelected() {353    const ids = Array.from(selectedIds());354    if (!ids.length || !props.onDeleteMany) return;355    setDeleting(true);356    await props.onDeleteMany(ids);357    setDeleting(false);358    exitSelectMode();359  }360361  // Virtualization: under VIRTUAL_THRESHOLD entries we render the full list.362  // Above it, we render only a window around the visible viewport plus a363  // BUFFER_ROWS-tall buffer above and below so scroll never reveals empty364  // space. Row height is approximate (variable padding for badges) but365  // bounded — the buffer absorbs over/undershoot.366  const VIRTUAL_THRESHOLD = 100;367  const ROW_HEIGHT = 56;368  const BUFFER_ROWS = 20;369370  const isVirtualized = createMemo(() => filteredEntries().length > VIRTUAL_THRESHOLD);371372  const visibleRange = createMemo(() => {373    const total = filteredEntries().length;374    if (!isVirtualized()) return { start: 0, end: total };375    const start = Math.max(0, Math.floor(scrollTop() / ROW_HEIGHT) - BUFFER_ROWS);376    const end = Math.min(total, Math.ceil((scrollTop() + viewportHeight()) / ROW_HEIGHT) + BUFFER_ROWS);377    return { start, end };378  });379380  const windowedEntries = createMemo(() => {381    const { start, end } = visibleRange();382    return filteredEntries().slice(start, end);383  });384385  function handleListScroll(e: Event) {386    if (!isVirtualized()) return;387    const target = e.currentTarget as HTMLDivElement;388    setScrollTop(target.scrollTop);389    setViewportHeight(target.clientHeight);390  }391392  function handleSearchKeydown(e: KeyboardEvent) {393    if (e.key === "ArrowDown") {394      e.preventDefault();395      const firstBtn = listRef?.querySelector<HTMLButtonElement>(".peach-entry-row");396      firstBtn?.focus();397    } else if (e.key === "ArrowUp") {398      e.preventDefault();399      const allBtns = listRef?.querySelectorAll<HTMLButtonElement>(".peach-entry-row");400      allBtns?.[allBtns.length - 1]?.focus();401    } else if (e.key === "Enter") {402      e.preventDefault();403      const firstEntry = filteredEntries()[0];404      if (firstEntry) props.onSelectEntry(firstEntry);405    } else if (e.key === "Escape" && selecting()) {406      exitSelectMode();407    }408  }409410  function handleListKeydown(e: KeyboardEvent) {411    if (!listRef) return;412    const btns = Array.from(listRef.querySelectorAll<HTMLButtonElement>(".peach-entry-row"));413    const idx = btns.indexOf(document.activeElement as HTMLButtonElement);414    if (e.key === "ArrowDown") {415      e.preventDefault();416      if (idx < btns.length - 1) btns[idx + 1]?.focus();417    } else if (e.key === "ArrowUp") {418      e.preventDefault();419      if (idx === 0) { searchRef?.focus(); }420      else btns[idx - 1]?.focus();421    } else if (e.key === "Escape") {422      if (selecting()) exitSelectMode();423      else props.onClose();424    }425  }426427  const sortLabels: { key: SortOrder; label: string }[] = [428    { key: "url-first", label: "smart" },429    { key: "az", label: "A–Z" },430    { key: "za", label: "Z–A" },431    { key: "recent", label: "newest" },432  ];433434  return (435    <div class="peach-overlay-view ov-enter-up" style="display:flex">436      <div class="peach-list-top" classList={{ "peach-list-top--native": !!props.isNative }}>437        <Show438          when={!props.isNative}439          fallback={440            <span class="peach-search-icon" aria-hidden="true">441              <svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round">442                <circle cx="5.5" cy="5.5" r="3.75" />443                <path d="M8.75 8.75L11.5 11.5" />444              </svg>445            </span>446          }447        >448          <button449            class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"450            onClick={props.onClose}451            aria-label="Back"452          >453            <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">454              <path d="M6 1L1 6L6 11" />455            </svg>456          </button>457        </Show>458        <input459          class="peach-search-input"460          placeholder="search vault..."461          autocomplete="off"462          style={props.isNative ? undefined : "font-size:14px"}463          ref={focusSearch}464          value={search()}465          onInput={(e) => setSearch((e.currentTarget as HTMLInputElement).value)}466          onKeyDown={handleSearchKeydown}467        />468        <span class="peach-search-counter peach-search-counter-list">469          {search() || selectedTags().size > 0470            ? `${filteredEntries().length} of ${props.entries.length}`471            : props.isNative472              ? `${props.entries.length}`473              : `${props.entries.length} entries`}474        </span>475      </div>476477      {/* Sort bar — morphs into select-mode controls */}478      <div class="peach-sort-bar">479        <Show480          when={selecting()}481          fallback={482            <>483              <For each={sortLabels}>484                {({ key, label }) => (485                  <button486                    class={`peach-sort-btn${props.sortOrder === key ? " is-active" : ""}`}487                    onClick={() => props.onSortOrderChange(key)}488                  >489                    {label}490                  </button>491                )}492              </For>493              <span style="flex:1" />494              <button495                class={`peach-sort-trash-btn${notesOnly() ? " is-active" : ""}`}496                onClick={() => setNotesOnly(v => !v)}497                aria-label="Notes only"498                title="Show notes only"499              >500                <NoteIcon />501              </button>502              <Show when={props.onOpenTrash}>503                <button504                  class="peach-sort-trash-btn"505                  onClick={props.onOpenTrash}506                  aria-label="View trash"507                  title="View deleted entries"508                >509                  <svg width="11" height="12" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">510                    <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />511                  </svg>512                </button>513              </Show>514              <Show when={props.onDeleteMany}>515                <button516                  class="peach-sort-trash-btn"517                  onClick={enterSelectMode}518                  aria-label="Select entries"519                  title="Select entries"520                >521                  <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">522                    <circle cx="4" cy="4" r="2.5" />523                    <circle cx="9" cy="4" r="2.5" />524                    <circle cx="4" cy="9" r="2.5" />525                    <circle cx="9" cy="9" r="2.5" />526                  </svg>527                </button>528              </Show>529            </>530          }531        >532          {/* Select mode sort bar */}533          <button534            class="peach-select-cancel-btn"535            onClick={exitSelectMode}536          >537            cancel538          </button>539          <span class="peach-select-count-label">540            {selectedIds().size > 0 ? `${selectedIds().size} selected` : "select entries"}541          </span>542          <button543            class="peach-select-all-btn"544            onClick={toggleSelectAll}545          >546            {allSelected() ? "none" : "all"}547          </button>548        </Show>549      </div>550551      <Show when={hasTags() && !selecting()}>552        <div class="peach-tag-filter-strip" style="display:flex;gap:6px;padding:4px 12px 6px;overflow-x:auto;flex-wrap:nowrap;scrollbar-width:none;">553          <For each={allTags()}>554            {(tag) => (555              <button556                class={`peach-tag-filter-chip ${selectedTags().has(tag) ? "is-selected" : ""}`}557                onClick={() => toggleTag(tag)}558              >559                {tag}560              </button>561            )}562          </For>563        </div>564      </Show>565566      <div567        class={`peach-vault-list${selecting() ? " is-selecting" : ""}`}568        ref={(el) => { listRef = el; setViewportHeight(el.clientHeight); }}569        onKeyDown={handleListKeydown}570        onScroll={handleListScroll}571      >572        <Show573          when={filteredEntries().length > 0}574          fallback={575            <div class="peach-list-empty">576              <span>no entries yet</span>577            </div>578          }579        >580          <Show when={isVirtualized()}>581            <div style={{ height: `${visibleRange().start * ROW_HEIGHT}px` }} aria-hidden="true" />582          </Show>583          <For each={windowedEntries()}>584            {(entry, localIndex) => {585              const index = () => visibleRange().start + localIndex();586              const isNote = entry.type === "note";587              const isPasskey = entry.passkey !== undefined;588              const domain = entry.type === "note"589                ? (entry.notes?.slice(0, 40) ?? "note")590                : entry.url591                  ? getDisplayDomain(entry.url)592                  : entry.passkey?.rpId593                    ? entry.passkey.rpId594                  : "saved entry";595              const isTabMatch = () =>596                props.currentTabDomain !== undefined &&597                props.currentTabDomain !== "" &&598                entryMatchesDomain(entry, props.currentTabDomain);599              const isSelected = () => selectedIds().has(entry.id);600601              const ariaLabel = (() => {602                const tag = isPasskey ? "passkey" : isNote ? "note" : "login";603                const sub = entry.username || domain || "";604                return sub605                  ? `${entry.title} — ${sub} (${tag})`606                  : `${entry.title} (${tag})`;607              })();608              return (609                <button610                  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" : ""}`}611                  style={isVirtualized()612                    ? { animation: "none" }613                    : { "animation-delay": `${Math.min(index(), 24) * 20}ms` }614                  }615                  aria-label={ariaLabel}616                  aria-pressed={selecting() ? isSelected() : undefined}617                  onTouchStart={(e) => handleRowTouchStart(entry, e)}618                  onTouchMove={handleRowTouchMove}619                  onTouchEnd={(e) => void handleRowTouchEnd(entry, e)}620                  onTouchCancel={() => { clearLongPressTimer(); touchStart = null; }}621                  onContextMenu={(e) => { e.preventDefault(); if (!selecting()) setContextEntry(entry); }}622                  onClick={() => {623                    if (suppressNextClick) { suppressNextClick = false; return; }624                    if (selecting()) {625                      toggleId(entry.id);626                    } else {627                      props.onSelectEntry(entry);628                    }629                  }}630                >631                  <div class={`peach-entry-row-icon${!selecting() && entry.breach ? " is-breach" : ""}${selecting() ? " is-select-icon" : ""}`}>632                    <Show633                      when={selecting()}634                      fallback={635                        <FaviconIcon636                          domain={entry.url ? getDisplayDomain(entry.url) : ""}637                          title={entry.title}638                          isNote={isNote}639                          isPasskey={isPasskey}640                        />641                      }642                    >643                      {/* Check indicator */}644                      <svg width="16" height="16" viewBox="0 0 16 16" fill="none">645                        <circle646                          cx="8" cy="8" r="7"647                          stroke="currentColor"648                          stroke-width="1"649                          fill={isSelected() ? "var(--peach-accent)" : "transparent"}650                          opacity={isSelected() ? "1" : "0.3"}651                        />652                        <Show when={isSelected()}>653                          <path654                            d="M5 8.5L7 10.5L11 6"655                            stroke="white"656                            stroke-width="1.2"657                            stroke-linecap="round"658                            stroke-linejoin="round"659                          />660                        </Show>661                      </svg>662                    </Show>663                  </div>664                  <div class="peach-entry-row-info">665                    <span class="peach-entry-row-name">666                      {entry.title}667                      <Show when={isPasskey && !selecting()}>668                        <span class="peach-passkey-badge">passkey</span>669                      </Show>670                      <Show when={isTabMatch() && !selecting()}>671                        <span class="peach-tab-match-badge" title="Matches current tab">●</span>672                      </Show>673                    </span>674                    <span class="peach-entry-row-meta">{domain}</span>675                  </div>676                  <Show when={entry.breach && !selecting()}>677                    <span class="peach-entry-row-dot" aria-label="breach"></span>678                  </Show>679                  <Show when={entry.canary && !selecting()}>680                    <span class="peach-entry-row-canary" title={`Canary: ${entry.canary?.label ?? "canary entry"}`}>🪤</span>681                  </Show>682                </button>683              );684            }}685          </For>686          <Show when={isVirtualized()}>687            <div688              style={{ height: `${(filteredEntries().length - visibleRange().end) * ROW_HEIGHT}px` }}689              aria-hidden="true"690            />691          </Show>692        </Show>693      </div>694695      {/* Bottom bar — new entry normally, delete action in select mode */}696      <Show697        when={selecting()}698        fallback={699          <button class="peach-new-entry-row" onClick={props.onAdd}>700            <svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round">701              <path d="M5 1v8M1 5h8" />702            </svg>703            new entry704          </button>705        }706      >707        <button708          class={`peach-select-action-bar${selectedIds().size === 0 ? " is-empty" : ""}`}709          disabled={selectedIds().size === 0 || deleting()}710          onClick={() => void handleDeleteSelected()}711        >712          <svg width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">713            <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />714          </svg>715          <Show716            when={selectedIds().size > 0}717            fallback={<span>select entries to delete</span>}718          >719            <span>720              {deleting()721                ? "moving to trash…"722                : `move ${selectedIds().size} to trash`}723            </span>724          </Show>725        </button>726      </Show>727728      <Show when={contextEntry() !== null}>729        <div730          class="peach-context-menu-backdrop"731          onClick={() => setContextEntry(null)}732          role="presentation"733        >734          <div735            class="peach-context-menu"736            role="menu"737            aria-label="Entry actions"738            onClick={(e) => e.stopPropagation()}739          >740            <Show when={contextEntry()!.username}>741              <button742                role="menuitem"743                class="peach-context-menu-item"744                onClick={async () => {745                  const e = contextEntry();746                  if (!e?.username) return;747                  await popupCopy(e.username, "username");748                  setContextEntry(null);749                }}750              >751                Copy username752              </button>753            </Show>754            <Show when={contextEntry()!.password}>755              <button756                role="menuitem"757                class="peach-context-menu-item"758                onClick={async () => {759                  const e = contextEntry();760                  if (!e?.password) return;761                  await popupCopy(e.password, "password");762                  setContextEntry(null);763                }}764              >765                Copy password766              </button>767            </Show>768            <Show when={contextEntry()!.totp}>769              <button770                role="menuitem"771                class="peach-context-menu-item"772                onClick={async () => {773                  const e = contextEntry();774                  if (!e?.totp) return;775                  await popupCopy(e.totp, "TOTP secret");776                  setContextEntry(null);777                }}778              >779                Copy TOTP secret780              </button>781            </Show>782            <button783              role="menuitem"784              class="peach-context-menu-item"785              onClick={() => {786                const e = contextEntry();787                if (e) props.onSelectEntry(e);788                setContextEntry(null);789              }}790            >791              Open792            </button>793            <Show when={props.onDeleteMany}>794              <button795                role="menuitem"796                class="peach-context-menu-item peach-context-menu-item--danger"797                onClick={async () => {798                  const e = contextEntry();799                  if (!e) return;800                  setContextEntry(null);801                  await props.onDeleteMany?.([e.id]);802                }}803              >804                Move to trash805              </button>806            </Show>807            <button808              role="menuitem"809              class="peach-context-menu-item peach-context-menu-item--cancel"810              onClick={() => setContextEntry(null)}811            >812              Cancel813            </button>814          </div>815        </div>816      </Show>817    </div>818  );819}