Vault.tsx

19.5 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  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  function entryMatchesDomain(entry: VaultEntry, domain: string): boolean {141    if (!domain) return false;142    if (entry.type === "passkey") return entry.rpId === domain;143    if (entry.type !== "login") return false;144    // Tab domain is the eTLD+1 from extractDomain — compare against eTLD+1 of145    // each entry URL too so subdomain entries also match.146    if (entry.url && extractDomain(entry.url) === domain) return true;147    for (const extra of entry.additionalUrls ?? []) {148      if (extractDomain(extra) === domain) return true;149    }150    return false;151  }152153  const filteredEntries = createMemo(() => {154    let result = props.entries;155156    if (notesOnly()) {157      result = result.filter(entry => entry.type === "note");158    }159160    if (selectedTags().size > 0) {161      result = result.filter(entry =>162        Array.from(selectedTags()).every(tag => entry.tags?.includes(tag))163      );164    }165166    const q = search().trim();167    if (q) {168      result = result169        .filter((entry) => {170          const searchable = entry.type === "note"171            ? `${entry.title} ${entry.notes ?? ""}`172            : entry.type === "passkey"173              ? `${entry.title} ${entry.rpId ?? ""} ${entry.userName ?? ""} ${entry.userDisplayName ?? ""}`174            : `${entry.title} ${entry.url ?? ""} ${entry.username ?? ""}`;175          return fuzzyMatch(q, searchable);176        })177        .sort((a, b) => {178          const aSearchable = a.type === "note"179            ? `${a.title} ${a.notes ?? ""}`.toLowerCase()180            : a.type === "passkey"181              ? `${a.title} ${a.rpId ?? ""} ${a.userName ?? ""}`.toLowerCase()182            : `${a.title} ${a.url ?? ""} ${a.username ?? ""}`.toLowerCase();183          const bSearchable = b.type === "note"184            ? `${b.title} ${b.notes ?? ""}`.toLowerCase()185            : b.type === "passkey"186              ? `${b.title} ${b.rpId ?? ""} ${b.userName ?? ""}`.toLowerCase()187            : `${b.title} ${b.url ?? ""} ${b.username ?? ""}`.toLowerCase();188          const qLower = q.toLowerCase();189          const aExact = isExactPrefix(qLower, aSearchable);190          const bExact = isExactPrefix(qLower, bSearchable);191          if (aExact && !bExact) return -1;192          if (!aExact && bExact) return 1;193          return 0;194        });195      return result;196    }197198    const tabDomain = props.currentTabDomain ?? "";199    const order = props.sortOrder;200201    if (order === "az") {202      return [...result].sort((a, b) => a.title.localeCompare(b.title));203    }204    if (order === "za") {205      return [...result].sort((a, b) => b.title.localeCompare(a.title));206    }207    if (order === "recent") {208      return [...result].sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0));209    }210    if (tabDomain) {211      return [...result].sort((a, b) => {212        const aMatch = entryMatchesDomain(a, tabDomain) ? 0 : 1;213        const bMatch = entryMatchesDomain(b, tabDomain) ? 0 : 1;214        return aMatch - bMatch;215      });216    }217    return result;218  });219220  // Declaration order matters: `allVisibleIds` and `allSelected` read221  // `filteredEntries` eagerly on component mount via createMemo. Moving222  // `filteredEntries` below them throws ReferenceError under the production223  // minifier and freezes the vault list on mount (v1.4.34 regression).224  const allVisibleIds = createMemo(() => filteredEntries().map(e => e.id));225  const allSelected = createMemo(() =>226    allVisibleIds().length > 0 && allVisibleIds().every(id => selectedIds().has(id))227  );228229  function toggleSelectAll() {230    if (allSelected()) {231      setSelectedIds(new Set<string>());232    } else {233      setSelectedIds(new Set<string>(allVisibleIds()));234    }235  }236237  async function handleDeleteSelected() {238    const ids = Array.from(selectedIds());239    if (!ids.length || !props.onDeleteMany) return;240    setDeleting(true);241    await props.onDeleteMany(ids);242    setDeleting(false);243    exitSelectMode();244  }245246  function handleSearchKeydown(e: KeyboardEvent) {247    if (e.key === "ArrowDown") {248      e.preventDefault();249      const firstBtn = listRef?.querySelector<HTMLButtonElement>(".peach-entry-row");250      firstBtn?.focus();251    } else if (e.key === "ArrowUp") {252      e.preventDefault();253      const allBtns = listRef?.querySelectorAll<HTMLButtonElement>(".peach-entry-row");254      allBtns?.[allBtns.length - 1]?.focus();255    } else if (e.key === "Enter") {256      e.preventDefault();257      const firstEntry = filteredEntries()[0];258      if (firstEntry) props.onSelectEntry(firstEntry);259    } else if (e.key === "Escape" && selecting()) {260      exitSelectMode();261    }262  }263264  function handleListKeydown(e: KeyboardEvent) {265    if (!listRef) return;266    const btns = Array.from(listRef.querySelectorAll<HTMLButtonElement>(".peach-entry-row"));267    const idx = btns.indexOf(document.activeElement as HTMLButtonElement);268    if (e.key === "ArrowDown") {269      e.preventDefault();270      if (idx < btns.length - 1) btns[idx + 1]?.focus();271    } else if (e.key === "ArrowUp") {272      e.preventDefault();273      if (idx === 0) { searchRef?.focus(); }274      else btns[idx - 1]?.focus();275    } else if (e.key === "Escape") {276      if (selecting()) exitSelectMode();277      else props.onClose();278    }279  }280281  const sortLabels: { key: SortOrder; label: string }[] = [282    { key: "url-first", label: "smart" },283    { key: "az", label: "A–Z" },284    { key: "za", label: "Z–A" },285    { key: "recent", label: "newest" },286  ];287288  return (289    <div class="peach-overlay-view ov-enter-up" style="display:flex">290      <div class="peach-list-top">291        <button292          class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"293          onClick={props.onClose}294          aria-label="Back"295        >296          <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">297            <path d="M6 1L1 6L6 11" />298          </svg>299        </button>300        <input301          class="peach-search-input"302          placeholder="search vault..."303          autocomplete="off"304          style="font-size:14px"305          ref={focusSearch}306          value={search()}307          onInput={(e) => setSearch((e.currentTarget as HTMLInputElement).value)}308          onKeyDown={handleSearchKeydown}309        />310        <span class="peach-search-counter peach-search-counter-list">311          {search() || selectedTags().size > 0312            ? `${filteredEntries().length} of ${props.entries.length}`313            : `${props.entries.length} entries`}314        </span>315      </div>316317      {/* Sort bar — morphs into select-mode controls */}318      <div class="peach-sort-bar">319        <Show320          when={selecting()}321          fallback={322            <>323              <For each={sortLabels}>324                {({ key, label }) => (325                  <button326                    class={`peach-sort-btn${props.sortOrder === key ? " is-active" : ""}`}327                    onClick={() => props.onSortOrderChange(key)}328                  >329                    {label}330                  </button>331                )}332              </For>333              <span style="flex:1" />334              <button335                class={`peach-sort-trash-btn${notesOnly() ? " is-active" : ""}`}336                onClick={() => setNotesOnly(v => !v)}337                aria-label="Notes only"338                title="Show notes only"339              >340                {NOTE_ICON}341              </button>342              <Show when={props.onOpenTrash}>343                <button344                  class="peach-sort-trash-btn"345                  onClick={props.onOpenTrash}346                  aria-label="View trash"347                  title="View deleted entries"348                >349                  <svg width="11" height="12" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">350                    <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />351                  </svg>352                </button>353              </Show>354              <Show when={props.onDeleteMany}>355                <button356                  class="peach-sort-trash-btn"357                  onClick={enterSelectMode}358                  aria-label="Select entries"359                  title="Select entries"360                >361                  <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">362                    <circle cx="4" cy="4" r="2.5" />363                    <circle cx="9" cy="4" r="2.5" />364                    <circle cx="4" cy="9" r="2.5" />365                    <circle cx="9" cy="9" r="2.5" />366                  </svg>367                </button>368              </Show>369            </>370          }371        >372          {/* Select mode sort bar */}373          <button374            class="peach-select-cancel-btn"375            onClick={exitSelectMode}376          >377            cancel378          </button>379          <span class="peach-select-count-label">380            {selectedIds().size > 0 ? `${selectedIds().size} selected` : "select entries"}381          </span>382          <button383            class="peach-select-all-btn"384            onClick={toggleSelectAll}385          >386            {allSelected() ? "none" : "all"}387          </button>388        </Show>389      </div>390391      <Show when={hasTags() && !selecting()}>392        <div class="peach-tag-filter-strip" style="display:flex;gap:6px;padding:4px 12px 6px;overflow-x:auto;flex-wrap:nowrap;scrollbar-width:none;">393          <For each={allTags()}>394            {(tag) => (395              <button396                class={`peach-tag-filter-chip ${selectedTags().has(tag) ? "is-selected" : ""}`}397                onClick={() => toggleTag(tag)}398              >399                {tag}400              </button>401            )}402          </For>403        </div>404      </Show>405406      <div407        class={`peach-vault-list${selecting() ? " is-selecting" : ""}`}408        ref={listRef}409        onKeyDown={handleListKeydown}410      >411        <Show412          when={filteredEntries().length > 0}413          fallback={414            <div class="peach-list-empty">415              <span>no entries yet</span>416            </div>417          }418        >419          <For each={filteredEntries()}>420            {(entry, index) => {421              const isNote = entry.type === "note";422              const isPasskey = entry.type === "passkey";423              const domain = entry.type === "note"424                ? (entry.notes?.slice(0, 40) ?? "note")425                : entry.type === "passkey"426                  ? entry.rpId ?? "passkey"427                : entry.url428                  ? getDisplayDomain(entry.url)429                  : "saved entry";430              const isTabMatch = () =>431                props.currentTabDomain !== undefined &&432                props.currentTabDomain !== "" &&433                entryMatchesDomain(entry, props.currentTabDomain);434              const isSelected = () => selectedIds().has(entry.id);435436              return (437                <button438                  class={`peach-entry-row${!selecting() && props.selectedEntryId === entry.id ? " is-active" : ""}${!selecting() && isTabMatch() ? " is-tab-match" : ""}${selecting() && isSelected() ? " is-selected" : ""}`}439                  style={{ "animation-delay": `${Math.min(index(), 24) * 20}ms` }}440                  onClick={() => {441                    if (selecting()) {442                      toggleId(entry.id);443                    } else {444                      props.onSelectEntry(entry);445                    }446                  }}447                >448                  <div class={`peach-entry-row-icon${!selecting() && entry.breach ? " is-breach" : ""}${selecting() ? " is-select-icon" : ""}`}>449                    <Show450                      when={selecting()}451                      fallback={452                        <FaviconIcon453                          domain={entry.url ? getDisplayDomain(entry.url) : ""}454                          isNote={isNote}455                          isPasskey={isPasskey}456                        />457                      }458                    >459                      {/* Check indicator */}460                      <svg width="16" height="16" viewBox="0 0 16 16" fill="none">461                        <circle462                          cx="8" cy="8" r="7"463                          stroke="currentColor"464                          stroke-width="1"465                          fill={isSelected() ? "var(--peach-accent)" : "transparent"}466                          opacity={isSelected() ? "1" : "0.3"}467                        />468                        <Show when={isSelected()}>469                          <path470                            d="M5 8.5L7 10.5L11 6"471                            stroke="white"472                            stroke-width="1.2"473                            stroke-linecap="round"474                            stroke-linejoin="round"475                          />476                        </Show>477                      </svg>478                    </Show>479                  </div>480                  <div class="peach-entry-row-info">481                    <span class="peach-entry-row-name">482                      {entry.title}483                      <Show when={isPasskey && !selecting()}>484                        <span class="peach-passkey-badge">passkey</span>485                      </Show>486                      <Show when={isTabMatch() && !selecting()}>487                        <span class="peach-tab-match-badge" title="Matches current tab">●</span>488                      </Show>489                    </span>490                    <span class="peach-entry-row-meta">{domain}</span>491                  </div>492                  <Show when={entry.breach && !selecting()}>493                    <span class="peach-entry-row-dot" aria-label="breach"></span>494                  </Show>495                  <Show when={entry.canary && !selecting()}>496                    <span class="peach-entry-row-canary" title={`Canary: ${entry.canary?.label ?? "canary entry"}`}>🪤</span>497                  </Show>498                </button>499              );500            }}501          </For>502        </Show>503      </div>504505      {/* Bottom bar — new entry normally, delete action in select mode */}506      <Show507        when={selecting()}508        fallback={509          <button class="peach-new-entry-row" onClick={props.onAdd}>510            <svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round">511              <path d="M5 1v8M1 5h8" />512            </svg>513            new entry514          </button>515        }516      >517        <button518          class={`peach-select-action-bar${selectedIds().size === 0 ? " is-empty" : ""}`}519          disabled={selectedIds().size === 0 || deleting()}520          onClick={() => void handleDeleteSelected()}521        >522          <svg width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">523            <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />524          </svg>525          <Show526            when={selectedIds().size > 0}527            fallback={<span>select entries to delete</span>}528          >529            <span>530              {deleting()531                ? "moving to trash…"532                : `move ${selectedIds().size} to trash`}533            </span>534          </Show>535        </button>536      </Show>537    </div>538  );539}