Vault.tsx

19.7 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";56function FaviconIcon(props: { domain: string; isNote: boolean; isPasskey: boolean }) {7  const src = props.domain && !props.isNote && !props.isPasskey8    ? faviconUrl(props.domain)9    : null;10  const fallback = props.isPasskey ? PASSKEY_ICON : props.isNote ? NOTE_ICON : LOGIN_ICON;11  const [failed, setFailed] = createSignal(false);12  return (13    <Show when={src && !failed()} fallback={fallback}>14      <img15        src={src!}16        width="16"17        height="16"18        alt=""19        style="border-radius:3px;"20        onError={() => setFailed(true)}21      />22    </Show>23  );24}2526const LOGIN_ICON = (27  <svg width="11" height="12" viewBox="0 0 11 12" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round" aria-hidden="true">28    <rect x="2" y="5" width="7" height="5" rx="1.2" />29    <path d="M3.5 5V3.75A2 2 0 0 1 5.5 1.75A2 2 0 0 1 7.5 3.75V5" />30  </svg>31);3233const PASSKEY_ICON = (34  <svg width="11" height="12" viewBox="0 0 11 12" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round" aria-hidden="true">35    <circle cx="4" cy="4.5" r="3" />36    <path d="M6.5 7l3.5 3.5M8.5 8.5L10 10" />37  </svg>38);3940const NOTE_ICON = (41  <svg width="11" height="12" viewBox="0 0 11 12" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round" aria-hidden="true">42    <rect x="1.5" y="1.5" width="8" height="9" rx="1.5" />43    <path d="M3.5 4.5h4M3.5 6.5h4M3.5 8.5h2" />44  </svg>45);4647export type SortOrder = "url-first" | "az" | "za" | "recent";4849interface VaultProps {50  entries: VaultEntry[];51  selectedEntryId?: string;52  currentTabDomain?: string;53  sortOrder: SortOrder;54  searchSeed?: string;55  searchSeedNonce?: number;56  onSelectEntry: (entry: VaultEntry) => void;57  onSortOrderChange: (order: SortOrder) => void;58  onAdd: () => void;59  onClose: () => void;60  onOpenTrash?: () => void;61  onDeleteMany?: (ids: string[]) => Promise<void>;62}6364export function VaultView(props: VaultProps) {65  const [search, setSearch] = createSignal("");66  const [selectedTags, setSelectedTags] = createSignal<Set<string>>(new Set());67  const [notesOnly, setNotesOnly] = createSignal(false);68  const [selecting, setSelecting] = createSignal(false);69  const [selectedIds, setSelectedIds] = createSignal<Set<string>>(new Set());70  const [deleting, setDeleting] = createSignal(false);7172  let searchRef: HTMLInputElement | undefined;73  let listRef: HTMLDivElement | undefined;7475  const focusSearch = (el: HTMLInputElement) => {76    searchRef = el;77    setTimeout(() => el.focus(), 50);78  };7980  createEffect(() => {81    void props.searchSeedNonce;82    const seed = props.searchSeed;83    if (!seed) return;84    setSearch(seed);85    requestAnimationFrame(() => {86      searchRef?.focus();87      searchRef?.setSelectionRange(seed.length, seed.length);88    });89  });9091  function enterSelectMode() {92    setSelecting(true);93    setSelectedIds(new Set<string>());94  }9596  function exitSelectMode() {97    setSelecting(false);98    setSelectedIds(new Set<string>());99  }100101  function toggleId(id: string) {102    const s = new Set<string>(selectedIds());103    if (s.has(id)) s.delete(id); else s.add(id);104    setSelectedIds(s);105  }106107  const allTags = createMemo(() => {108    const tagSet = new Set<string>();109    for (const entry of props.entries) {110      for (const tag of entry.tags ?? []) {111        tagSet.add(tag);112      }113    }114    return Array.from(tagSet).sort();115  });116117  const hasTags = createMemo(() => allTags().length > 0);118119  function toggleTag(tag: string) {120    const current = new Set(selectedTags());121    if (current.has(tag)) {122      current.delete(tag);123    } else {124      current.add(tag);125    }126    setSelectedTags(current);127  }128129  function fuzzyMatch(query: string, target: string): boolean {130    query = query.toLowerCase();131    target = target.toLowerCase();132    let qi = 0;133    for (let i = 0; i < target.length && qi < query.length; i++) {134      if (target[i] === query[qi]) qi++;135    }136    return qi === query.length;137  }138139  function isExactPrefix(query: string, target: string): boolean {140    return target.toLowerCase().startsWith(query.toLowerCase());141  }142143  function entryDomain(entry: VaultEntry): string {144    if (entry.type === "login" && entry.url) return getDisplayDomain(entry.url);145    if (entry.type === "passkey" && entry.rpId) return entry.rpId;146    return "";147  }148149  function entryMatchesDomain(entry: VaultEntry, domain: string): boolean {150    if (!domain) return false;151    if (entry.type === "passkey") return entry.rpId === domain;152    if (entry.type !== "login") return false;153    // Tab domain is the eTLD+1 from extractDomain — compare against eTLD+1 of154    // each entry URL too so subdomain entries also match.155    if (entry.url && extractDomain(entry.url) === domain) return true;156    for (const extra of entry.additionalUrls ?? []) {157      if (extractDomain(extra) === domain) return true;158    }159    return false;160  }161162  const filteredEntries = createMemo(() => {163    let result = props.entries;164165    if (notesOnly()) {166      result = result.filter(entry => entry.type === "note");167    }168169    if (selectedTags().size > 0) {170      result = result.filter(entry =>171        Array.from(selectedTags()).every(tag => entry.tags?.includes(tag))172      );173    }174175    const q = search().trim();176    if (q) {177      result = result178        .filter((entry) => {179          const searchable = entry.type === "note"180            ? `${entry.title} ${entry.notes ?? ""}`181            : entry.type === "passkey"182              ? `${entry.title} ${entry.rpId ?? ""} ${entry.userName ?? ""} ${entry.userDisplayName ?? ""}`183            : `${entry.title} ${entry.url ?? ""} ${entry.username ?? ""}`;184          return fuzzyMatch(q, searchable);185        })186        .sort((a, b) => {187          const aSearchable = a.type === "note"188            ? `${a.title} ${a.notes ?? ""}`.toLowerCase()189            : a.type === "passkey"190              ? `${a.title} ${a.rpId ?? ""} ${a.userName ?? ""}`.toLowerCase()191            : `${a.title} ${a.url ?? ""} ${a.username ?? ""}`.toLowerCase();192          const bSearchable = b.type === "note"193            ? `${b.title} ${b.notes ?? ""}`.toLowerCase()194            : b.type === "passkey"195              ? `${b.title} ${b.rpId ?? ""} ${b.userName ?? ""}`.toLowerCase()196            : `${b.title} ${b.url ?? ""} ${b.username ?? ""}`.toLowerCase();197          const qLower = q.toLowerCase();198          const aExact = isExactPrefix(qLower, aSearchable);199          const bExact = isExactPrefix(qLower, bSearchable);200          if (aExact && !bExact) return -1;201          if (!aExact && bExact) return 1;202          return 0;203        });204      return result;205    }206207    const tabDomain = props.currentTabDomain ?? "";208    const order = props.sortOrder;209210    if (order === "az") {211      return [...result].sort((a, b) => a.title.localeCompare(b.title));212    }213    if (order === "za") {214      return [...result].sort((a, b) => b.title.localeCompare(a.title));215    }216    if (order === "recent") {217      return [...result].sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0));218    }219    if (tabDomain) {220      return [...result].sort((a, b) => {221        const aMatch = entryMatchesDomain(a, tabDomain) ? 0 : 1;222        const bMatch = entryMatchesDomain(b, tabDomain) ? 0 : 1;223        return aMatch - bMatch;224      });225    }226    return result;227  });228229  // Declaration order matters: `allVisibleIds` and `allSelected` read230  // `filteredEntries` eagerly on component mount via createMemo. Moving231  // `filteredEntries` below them throws ReferenceError under the production232  // minifier and freezes the vault list on mount (v1.4.34 regression).233  const allVisibleIds = createMemo(() => filteredEntries().map(e => e.id));234  const allSelected = createMemo(() =>235    allVisibleIds().length > 0 && allVisibleIds().every(id => selectedIds().has(id))236  );237238  function toggleSelectAll() {239    if (allSelected()) {240      setSelectedIds(new Set<string>());241    } else {242      setSelectedIds(new Set<string>(allVisibleIds()));243    }244  }245246  async function handleDeleteSelected() {247    const ids = Array.from(selectedIds());248    if (!ids.length || !props.onDeleteMany) return;249    setDeleting(true);250    await props.onDeleteMany(ids);251    setDeleting(false);252    exitSelectMode();253  }254255  function handleSearchKeydown(e: KeyboardEvent) {256    if (e.key === "ArrowDown") {257      e.preventDefault();258      const firstBtn = listRef?.querySelector<HTMLButtonElement>(".peach-entry-row");259      firstBtn?.focus();260    } else if (e.key === "ArrowUp") {261      e.preventDefault();262      const allBtns = listRef?.querySelectorAll<HTMLButtonElement>(".peach-entry-row");263      allBtns?.[allBtns.length - 1]?.focus();264    } else if (e.key === "Enter") {265      e.preventDefault();266      const firstEntry = filteredEntries()[0];267      if (firstEntry) props.onSelectEntry(firstEntry);268    } else if (e.key === "Escape" && selecting()) {269      exitSelectMode();270    }271  }272273  function handleListKeydown(e: KeyboardEvent) {274    if (!listRef) return;275    const btns = Array.from(listRef.querySelectorAll<HTMLButtonElement>(".peach-entry-row"));276    const idx = btns.indexOf(document.activeElement as HTMLButtonElement);277    if (e.key === "ArrowDown") {278      e.preventDefault();279      if (idx < btns.length - 1) btns[idx + 1]?.focus();280    } else if (e.key === "ArrowUp") {281      e.preventDefault();282      if (idx === 0) { searchRef?.focus(); }283      else btns[idx - 1]?.focus();284    } else if (e.key === "Escape") {285      if (selecting()) exitSelectMode();286      else props.onClose();287    }288  }289290  const sortLabels: { key: SortOrder; label: string }[] = [291    { key: "url-first", label: "smart" },292    { key: "az", label: "A–Z" },293    { key: "za", label: "Z–A" },294    { key: "recent", label: "newest" },295  ];296297  return (298    <div class="peach-overlay-view ov-enter-up" style="display:flex">299      <div class="peach-list-top">300        <button301          class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"302          onClick={props.onClose}303          aria-label="Back"304        >305          <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">306            <path d="M6 1L1 6L6 11" />307          </svg>308        </button>309        <input310          class="peach-search-input"311          placeholder="search vault..."312          autocomplete="off"313          style="font-size:14px"314          ref={focusSearch}315          value={search()}316          onInput={(e) => setSearch((e.currentTarget as HTMLInputElement).value)}317          onKeyDown={handleSearchKeydown}318        />319        <span class="peach-search-counter peach-search-counter-list">320          {search() || selectedTags().size > 0321            ? `${filteredEntries().length} of ${props.entries.length}`322            : `${props.entries.length} entries`}323        </span>324      </div>325326      {/* Sort bar — morphs into select-mode controls */}327      <div class="peach-sort-bar">328        <Show329          when={selecting()}330          fallback={331            <>332              <For each={sortLabels}>333                {({ key, label }) => (334                  <button335                    class={`peach-sort-btn${props.sortOrder === key ? " is-active" : ""}`}336                    onClick={() => props.onSortOrderChange(key)}337                  >338                    {label}339                  </button>340                )}341              </For>342              <span style="flex:1" />343              <button344                class={`peach-sort-trash-btn${notesOnly() ? " is-active" : ""}`}345                onClick={() => setNotesOnly(v => !v)}346                aria-label="Notes only"347                title="Show notes only"348              >349                {NOTE_ICON}350              </button>351              <Show when={props.onOpenTrash}>352                <button353                  class="peach-sort-trash-btn"354                  onClick={props.onOpenTrash}355                  aria-label="View trash"356                  title="View deleted entries"357                >358                  <svg width="11" height="12" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">359                    <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />360                  </svg>361                </button>362              </Show>363              <Show when={props.onDeleteMany}>364                <button365                  class="peach-sort-trash-btn"366                  onClick={enterSelectMode}367                  aria-label="Select entries"368                  title="Select entries"369                >370                  <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">371                    <circle cx="4" cy="4" r="2.5" />372                    <circle cx="9" cy="4" r="2.5" />373                    <circle cx="4" cy="9" r="2.5" />374                    <circle cx="9" cy="9" r="2.5" />375                  </svg>376                </button>377              </Show>378            </>379          }380        >381          {/* Select mode sort bar */}382          <button383            class="peach-select-cancel-btn"384            onClick={exitSelectMode}385          >386            cancel387          </button>388          <span class="peach-select-count-label">389            {selectedIds().size > 0 ? `${selectedIds().size} selected` : "select entries"}390          </span>391          <button392            class="peach-select-all-btn"393            onClick={toggleSelectAll}394          >395            {allSelected() ? "none" : "all"}396          </button>397        </Show>398      </div>399400      <Show when={hasTags() && !selecting()}>401        <div class="peach-tag-filter-strip" style="display:flex;gap:6px;padding:4px 12px 6px;overflow-x:auto;flex-wrap:nowrap;scrollbar-width:none;">402          <For each={allTags()}>403            {(tag) => (404              <button405                class={`peach-tag-filter-chip ${selectedTags().has(tag) ? "is-selected" : ""}`}406                onClick={() => toggleTag(tag)}407              >408                {tag}409              </button>410            )}411          </For>412        </div>413      </Show>414415      <div416        class={`peach-vault-list${selecting() ? " is-selecting" : ""}`}417        ref={listRef}418        onKeyDown={handleListKeydown}419      >420        <Show421          when={filteredEntries().length > 0}422          fallback={423            <div class="peach-list-empty">424              <span>no entries yet</span>425            </div>426          }427        >428          <For each={filteredEntries()}>429            {(entry, index) => {430              const isNote = entry.type === "note";431              const isPasskey = entry.type === "passkey";432              const domain = entry.type === "note"433                ? (entry.notes?.slice(0, 40) ?? "note")434                : entry.type === "passkey"435                  ? entry.rpId ?? "passkey"436                : entry.url437                  ? getDisplayDomain(entry.url)438                  : "saved entry";439              const isTabMatch = () =>440                props.currentTabDomain !== undefined &&441                props.currentTabDomain !== "" &&442                entryMatchesDomain(entry, props.currentTabDomain);443              const isSelected = () => selectedIds().has(entry.id);444445              return (446                <button447                  class={`peach-entry-row${!selecting() && props.selectedEntryId === entry.id ? " is-active" : ""}${!selecting() && isTabMatch() ? " is-tab-match" : ""}${selecting() && isSelected() ? " is-selected" : ""}`}448                  style={{ "animation-delay": `${Math.min(index(), 24) * 20}ms` }}449                  onClick={() => {450                    if (selecting()) {451                      toggleId(entry.id);452                    } else {453                      props.onSelectEntry(entry);454                    }455                  }}456                >457                  <div class={`peach-entry-row-icon${!selecting() && entry.breach ? " is-breach" : ""}${selecting() ? " is-select-icon" : ""}`}>458                    <Show459                      when={selecting()}460                      fallback={461                        <FaviconIcon462                          domain={entry.url ? getDisplayDomain(entry.url) : ""}463                          isNote={isNote}464                          isPasskey={isPasskey}465                        />466                      }467                    >468                      {/* Check indicator */}469                      <svg width="16" height="16" viewBox="0 0 16 16" fill="none">470                        <circle471                          cx="8" cy="8" r="7"472                          stroke="currentColor"473                          stroke-width="1"474                          fill={isSelected() ? "var(--peach-accent)" : "transparent"}475                          opacity={isSelected() ? "1" : "0.3"}476                        />477                        <Show when={isSelected()}>478                          <path479                            d="M5 8.5L7 10.5L11 6"480                            stroke="white"481                            stroke-width="1.2"482                            stroke-linecap="round"483                            stroke-linejoin="round"484                          />485                        </Show>486                      </svg>487                    </Show>488                  </div>489                  <div class="peach-entry-row-info">490                    <span class="peach-entry-row-name">491                      {entry.title}492                      <Show when={isPasskey && !selecting()}>493                        <span class="peach-passkey-badge">passkey</span>494                      </Show>495                      <Show when={isTabMatch() && !selecting()}>496                        <span class="peach-tab-match-badge" title="Matches current tab">●</span>497                      </Show>498                    </span>499                    <span class="peach-entry-row-meta">{domain}</span>500                  </div>501                  <Show when={entry.breach && !selecting()}>502                    <span class="peach-entry-row-dot" aria-label="breach"></span>503                  </Show>504                  <Show when={entry.canary && !selecting()}>505                    <span class="peach-entry-row-canary" title={`Canary: ${entry.canary?.label ?? "canary entry"}`}>🪤</span>506                  </Show>507                </button>508              );509            }}510          </For>511        </Show>512      </div>513514      {/* Bottom bar — new entry normally, delete action in select mode */}515      <Show516        when={selecting()}517        fallback={518          <button class="peach-new-entry-row" onClick={props.onAdd}>519            <svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round">520              <path d="M5 1v8M1 5h8" />521            </svg>522            new entry523          </button>524        }525      >526        <button527          class={`peach-select-action-bar${selectedIds().size === 0 ? " is-empty" : ""}`}528          disabled={selectedIds().size === 0 || deleting()}529          onClick={() => void handleDeleteSelected()}530        >531          <svg width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">532            <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />533          </svg>534          <Show535            when={selectedIds().size > 0}536            fallback={<span>select entries to delete</span>}537          >538            <span>539              {deleting()540                ? "moving to trash…"541                : `move ${selectedIds().size} to trash`}542            </span>543          </Show>544        </button>545      </Show>546    </div>547  );548}