Vault.tsx

18.9 KB · raw

1import { createEffect, createMemo, createSignal, For, Show } from "solid-js";2import type { VaultEntry } from "../../shared/types";3import { getDisplayDomain } from "../../shared/domain";4import { faviconUrl } from "../../shared/favicon";56function FaviconIcon(props: { domain: string; isNote: boolean; isPasskey: boolean }) {7  const src = props.domain && !props.isNote && !props.isPasskey8    ? faviconUrl(props.domain)9    : null;10  return (11    <Show when={src} fallback={props.isPasskey ? PASSKEY_ICON : props.isNote ? NOTE_ICON : LOGIN_ICON}>12      <img src={src!} width="16" height="16" alt="" style="border-radius:3px;" />13    </Show>14  );15}1617const LOGIN_ICON = (18  <svg width="11" height="12" viewBox="0 0 11 12" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round" aria-hidden="true">19    <rect x="2" y="5" width="7" height="5" rx="1.2" />20    <path d="M3.5 5V3.75A2 2 0 0 1 5.5 1.75A2 2 0 0 1 7.5 3.75V5" />21  </svg>22);2324const PASSKEY_ICON = (25  <svg width="11" height="12" viewBox="0 0 11 12" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round" aria-hidden="true">26    <circle cx="4" cy="4.5" r="3" />27    <path d="M6.5 7l3.5 3.5M8.5 8.5L10 10" />28  </svg>29);3031const NOTE_ICON = (32  <svg width="11" height="12" viewBox="0 0 11 12" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round" aria-hidden="true">33    <rect x="1.5" y="1.5" width="8" height="9" rx="1.5" />34    <path d="M3.5 4.5h4M3.5 6.5h4M3.5 8.5h2" />35  </svg>36);3738export type SortOrder = "url-first" | "az" | "za" | "recent";3940interface VaultProps {41  entries: VaultEntry[];42  selectedEntryId?: string;43  currentTabDomain?: string;44  sortOrder: SortOrder;45  searchSeed?: string;46  searchSeedNonce?: number;47  onSelectEntry: (entry: VaultEntry) => void;48  onSortOrderChange: (order: SortOrder) => void;49  onAdd: () => void;50  onClose: () => void;51  onOpenTrash?: () => void;52  onDeleteMany?: (ids: string[]) => Promise<void>;53}5455export function VaultView(props: VaultProps) {56  const [search, setSearch] = createSignal("");57  const [selectedTags, setSelectedTags] = createSignal<Set<string>>(new Set());58  const [notesOnly, setNotesOnly] = createSignal(false);59  const [selecting, setSelecting] = createSignal(false);60  const [selectedIds, setSelectedIds] = createSignal<Set<string>>(new Set());61  const [deleting, setDeleting] = createSignal(false);6263  let searchRef: HTMLInputElement | undefined;64  let listRef: HTMLDivElement | undefined;6566  const focusSearch = (el: HTMLInputElement) => {67    searchRef = el;68    setTimeout(() => el.focus(), 50);69  };7071  createEffect(() => {72    void props.searchSeedNonce;73    const seed = props.searchSeed;74    if (!seed) return;75    setSearch(seed);76    requestAnimationFrame(() => {77      searchRef?.focus();78      searchRef?.setSelectionRange(seed.length, seed.length);79    });80  });8182  function enterSelectMode() {83    setSelecting(true);84    setSelectedIds(new Set<string>());85  }8687  function exitSelectMode() {88    setSelecting(false);89    setSelectedIds(new Set<string>());90  }9192  function toggleId(id: string) {93    const s = new Set<string>(selectedIds());94    if (s.has(id)) s.delete(id); else s.add(id);95    setSelectedIds(s);96  }9798  const allTags = createMemo(() => {99    const tagSet = new Set<string>();100    for (const entry of props.entries) {101      for (const tag of entry.tags ?? []) {102        tagSet.add(tag);103      }104    }105    return Array.from(tagSet).sort();106  });107108  const hasTags = createMemo(() => allTags().length > 0);109110  function toggleTag(tag: string) {111    const current = new Set(selectedTags());112    if (current.has(tag)) {113      current.delete(tag);114    } else {115      current.add(tag);116    }117    setSelectedTags(current);118  }119120  function fuzzyMatch(query: string, target: string): boolean {121    query = query.toLowerCase();122    target = target.toLowerCase();123    let qi = 0;124    for (let i = 0; i < target.length && qi < query.length; i++) {125      if (target[i] === query[qi]) qi++;126    }127    return qi === query.length;128  }129130  function isExactPrefix(query: string, target: string): boolean {131    return target.toLowerCase().startsWith(query.toLowerCase());132  }133134  function entryDomain(entry: VaultEntry): string {135    if (entry.type === "login" && entry.url) return getDisplayDomain(entry.url);136    if (entry.type === "passkey" && entry.rpId) return entry.rpId;137    return "";138  }139140  const filteredEntries = createMemo(() => {141    let result = props.entries;142143    if (notesOnly()) {144      result = result.filter(entry => entry.type === "note");145    }146147    if (selectedTags().size > 0) {148      result = result.filter(entry =>149        Array.from(selectedTags()).every(tag => entry.tags?.includes(tag))150      );151    }152153    const q = search().trim();154    if (q) {155      result = result156        .filter((entry) => {157          const searchable = entry.type === "note"158            ? `${entry.title} ${entry.notes ?? ""}`159            : entry.type === "passkey"160              ? `${entry.title} ${entry.rpId ?? ""} ${entry.userName ?? ""} ${entry.userDisplayName ?? ""}`161            : `${entry.title} ${entry.url ?? ""} ${entry.username ?? ""}`;162          return fuzzyMatch(q, searchable);163        })164        .sort((a, b) => {165          const aSearchable = a.type === "note"166            ? `${a.title} ${a.notes ?? ""}`.toLowerCase()167            : a.type === "passkey"168              ? `${a.title} ${a.rpId ?? ""} ${a.userName ?? ""}`.toLowerCase()169            : `${a.title} ${a.url ?? ""} ${a.username ?? ""}`.toLowerCase();170          const bSearchable = b.type === "note"171            ? `${b.title} ${b.notes ?? ""}`.toLowerCase()172            : b.type === "passkey"173              ? `${b.title} ${b.rpId ?? ""} ${b.userName ?? ""}`.toLowerCase()174            : `${b.title} ${b.url ?? ""} ${b.username ?? ""}`.toLowerCase();175          const qLower = q.toLowerCase();176          const aExact = isExactPrefix(qLower, aSearchable);177          const bExact = isExactPrefix(qLower, bSearchable);178          if (aExact && !bExact) return -1;179          if (!aExact && bExact) return 1;180          return 0;181        });182      return result;183    }184185    const tabDomain = props.currentTabDomain ?? "";186    const order = props.sortOrder;187188    if (order === "az") {189      return [...result].sort((a, b) => a.title.localeCompare(b.title));190    }191    if (order === "za") {192      return [...result].sort((a, b) => b.title.localeCompare(a.title));193    }194    if (order === "recent") {195      return [...result].sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0));196    }197    if (tabDomain) {198      return [...result].sort((a, b) => {199        const aMatch = entryDomain(a) === tabDomain ? 0 : 1;200        const bMatch = entryDomain(b) === tabDomain ? 0 : 1;201        return aMatch - bMatch;202      });203    }204    return result;205  });206207  // Declaration order matters: `allVisibleIds` and `allSelected` read208  // `filteredEntries` eagerly on component mount via createMemo. Moving209  // `filteredEntries` below them throws ReferenceError under the production210  // minifier and freezes the vault list on mount (v1.4.34 regression).211  const allVisibleIds = createMemo(() => filteredEntries().map(e => e.id));212  const allSelected = createMemo(() =>213    allVisibleIds().length > 0 && allVisibleIds().every(id => selectedIds().has(id))214  );215216  function toggleSelectAll() {217    if (allSelected()) {218      setSelectedIds(new Set<string>());219    } else {220      setSelectedIds(new Set<string>(allVisibleIds()));221    }222  }223224  async function handleDeleteSelected() {225    const ids = Array.from(selectedIds());226    if (!ids.length || !props.onDeleteMany) return;227    setDeleting(true);228    await props.onDeleteMany(ids);229    setDeleting(false);230    exitSelectMode();231  }232233  function handleSearchKeydown(e: KeyboardEvent) {234    if (e.key === "ArrowDown") {235      e.preventDefault();236      const firstBtn = listRef?.querySelector<HTMLButtonElement>(".peach-entry-row");237      firstBtn?.focus();238    } else if (e.key === "ArrowUp") {239      e.preventDefault();240      const allBtns = listRef?.querySelectorAll<HTMLButtonElement>(".peach-entry-row");241      allBtns?.[allBtns.length - 1]?.focus();242    } else if (e.key === "Enter") {243      e.preventDefault();244      const firstEntry = filteredEntries()[0];245      if (firstEntry) props.onSelectEntry(firstEntry);246    } else if (e.key === "Escape" && selecting()) {247      exitSelectMode();248    }249  }250251  function handleListKeydown(e: KeyboardEvent) {252    if (!listRef) return;253    const btns = Array.from(listRef.querySelectorAll<HTMLButtonElement>(".peach-entry-row"));254    const idx = btns.indexOf(document.activeElement as HTMLButtonElement);255    if (e.key === "ArrowDown") {256      e.preventDefault();257      if (idx < btns.length - 1) btns[idx + 1]?.focus();258    } else if (e.key === "ArrowUp") {259      e.preventDefault();260      if (idx === 0) { searchRef?.focus(); }261      else btns[idx - 1]?.focus();262    } else if (e.key === "Escape") {263      if (selecting()) exitSelectMode();264      else props.onClose();265    }266  }267268  const sortLabels: { key: SortOrder; label: string }[] = [269    { key: "url-first", label: "smart" },270    { key: "az", label: "A–Z" },271    { key: "za", label: "Z–A" },272    { key: "recent", label: "newest" },273  ];274275  return (276    <div class="peach-overlay-view ov-enter-up" style="display:flex">277      <div class="peach-list-top">278        <button279          class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"280          onClick={props.onClose}281          aria-label="Back"282        >283          <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">284            <path d="M6 1L1 6L6 11" />285          </svg>286        </button>287        <input288          class="peach-search-input"289          placeholder="search vault..."290          autocomplete="off"291          style="font-size:14px"292          ref={focusSearch}293          value={search()}294          onInput={(e) => setSearch((e.currentTarget as HTMLInputElement).value)}295          onKeyDown={handleSearchKeydown}296        />297        <span class="peach-search-counter peach-search-counter-list">298          {search() || selectedTags().size > 0299            ? `${filteredEntries().length} of ${props.entries.length}`300            : `${props.entries.length} entries`}301        </span>302      </div>303304      {/* Sort bar — morphs into select-mode controls */}305      <div class="peach-sort-bar">306        <Show307          when={selecting()}308          fallback={309            <>310              <For each={sortLabels}>311                {({ key, label }) => (312                  <button313                    class={`peach-sort-btn${props.sortOrder === key ? " is-active" : ""}`}314                    onClick={() => props.onSortOrderChange(key)}315                  >316                    {label}317                  </button>318                )}319              </For>320              <span style="flex:1" />321              <button322                class={`peach-sort-trash-btn${notesOnly() ? " is-active" : ""}`}323                onClick={() => setNotesOnly(v => !v)}324                aria-label="Notes only"325                title="Show notes only"326              >327                {NOTE_ICON}328              </button>329              <Show when={props.onOpenTrash}>330                <button331                  class="peach-sort-trash-btn"332                  onClick={props.onOpenTrash}333                  aria-label="View trash"334                  title="View deleted entries"335                >336                  <svg width="11" height="12" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">337                    <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />338                  </svg>339                </button>340              </Show>341              <Show when={props.onDeleteMany}>342                <button343                  class="peach-sort-trash-btn"344                  onClick={enterSelectMode}345                  aria-label="Select entries"346                  title="Select entries"347                >348                  <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">349                    <circle cx="4" cy="4" r="2.5" />350                    <circle cx="9" cy="4" r="2.5" />351                    <circle cx="4" cy="9" r="2.5" />352                    <circle cx="9" cy="9" r="2.5" />353                  </svg>354                </button>355              </Show>356            </>357          }358        >359          {/* Select mode sort bar */}360          <button361            class="peach-select-cancel-btn"362            onClick={exitSelectMode}363          >364            cancel365          </button>366          <span class="peach-select-count-label">367            {selectedIds().size > 0 ? `${selectedIds().size} selected` : "select entries"}368          </span>369          <button370            class="peach-select-all-btn"371            onClick={toggleSelectAll}372          >373            {allSelected() ? "none" : "all"}374          </button>375        </Show>376      </div>377378      <Show when={hasTags() && !selecting()}>379        <div class="peach-tag-filter-strip" style="display:flex;gap:6px;padding:4px 12px 6px;overflow-x:auto;flex-wrap:nowrap;scrollbar-width:none;">380          <For each={allTags()}>381            {(tag) => (382              <button383                class={`peach-tag-filter-chip ${selectedTags().has(tag) ? "is-selected" : ""}`}384                onClick={() => toggleTag(tag)}385              >386                {tag}387              </button>388            )}389          </For>390        </div>391      </Show>392393      <div394        class={`peach-vault-list${selecting() ? " is-selecting" : ""}`}395        ref={listRef}396        onKeyDown={handleListKeydown}397      >398        <Show399          when={filteredEntries().length > 0}400          fallback={401            <div class="peach-list-empty">402              <span>no entries yet</span>403            </div>404          }405        >406          <For each={filteredEntries()}>407            {(entry, index) => {408              const isNote = entry.type === "note";409              const isPasskey = entry.type === "passkey";410              const domain = entry.type === "note"411                ? (entry.notes?.slice(0, 40) ?? "note")412                : entry.type === "passkey"413                  ? entry.rpId ?? "passkey"414                : entry.url415                  ? getDisplayDomain(entry.url)416                  : "saved entry";417              const isTabMatch = () =>418                props.currentTabDomain !== undefined &&419                props.currentTabDomain !== "" &&420                entryDomain(entry) === props.currentTabDomain;421              const isSelected = () => selectedIds().has(entry.id);422423              return (424                <button425                  class={`peach-entry-row${!selecting() && props.selectedEntryId === entry.id ? " is-active" : ""}${!selecting() && isTabMatch() ? " is-tab-match" : ""}${selecting() && isSelected() ? " is-selected" : ""}`}426                  style={{ "animation-delay": `${Math.min(index(), 24) * 20}ms` }}427                  onClick={() => {428                    if (selecting()) {429                      toggleId(entry.id);430                    } else {431                      props.onSelectEntry(entry);432                    }433                  }}434                >435                  <div class={`peach-entry-row-icon${!selecting() && entry.breach ? " is-breach" : ""}${selecting() ? " is-select-icon" : ""}`}>436                    <Show437                      when={selecting()}438                      fallback={439                        <FaviconIcon440                          domain={entry.url ? getDisplayDomain(entry.url) : ""}441                          isNote={isNote}442                          isPasskey={isPasskey}443                        />444                      }445                    >446                      {/* Check indicator */}447                      <svg width="16" height="16" viewBox="0 0 16 16" fill="none">448                        <circle449                          cx="8" cy="8" r="7"450                          stroke="currentColor"451                          stroke-width="1"452                          fill={isSelected() ? "var(--peach-accent)" : "transparent"}453                          opacity={isSelected() ? "1" : "0.3"}454                        />455                        <Show when={isSelected()}>456                          <path457                            d="M5 8.5L7 10.5L11 6"458                            stroke="white"459                            stroke-width="1.2"460                            stroke-linecap="round"461                            stroke-linejoin="round"462                          />463                        </Show>464                      </svg>465                    </Show>466                  </div>467                  <div class="peach-entry-row-info">468                    <span class="peach-entry-row-name">469                      {entry.title}470                      <Show when={isPasskey && !selecting()}>471                        <span class="peach-passkey-badge">passkey</span>472                      </Show>473                      <Show when={isTabMatch() && !selecting()}>474                        <span class="peach-tab-match-badge" title="Matches current tab">●</span>475                      </Show>476                    </span>477                    <span class="peach-entry-row-meta">{domain}</span>478                  </div>479                  <Show when={entry.breach && !selecting()}>480                    <span class="peach-entry-row-dot" aria-label="breach"></span>481                  </Show>482                  <Show when={entry.canary && !selecting()}>483                    <span class="peach-entry-row-canary" title={`Canary: ${entry.canary?.label ?? "canary entry"}`}>🪤</span>484                  </Show>485                </button>486              );487            }}488          </For>489        </Show>490      </div>491492      {/* Bottom bar — new entry normally, delete action in select mode */}493      <Show494        when={selecting()}495        fallback={496          <button class="peach-new-entry-row" onClick={props.onAdd}>497            <svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round">498              <path d="M5 1v8M1 5h8" />499            </svg>500            new entry501          </button>502        }503      >504        <button505          class={`peach-select-action-bar${selectedIds().size === 0 ? " is-empty" : ""}`}506          disabled={selectedIds().size === 0 || deleting()}507          onClick={() => void handleDeleteSelected()}508        >509          <svg width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1.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          <Show513            when={selectedIds().size > 0}514            fallback={<span>select entries to delete</span>}515          >516            <span>517              {deleting()518                ? "moving to trash…"519                : `move ${selectedIds().size} to trash`}520            </span>521          </Show>522        </button>523      </Show>524    </div>525  );526}