Vault.tsx

19.2 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.passkey?.rpId) return entry.passkey.rpId;146    return "";147  }148149  function entryMatchesDomain(entry: VaultEntry, domain: string): boolean {150    if (!domain) return false;151    if (entry.passkey?.rpId === domain) return true;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      const searchableFor = (e: VaultEntry): string => {178        if (e.type === "note") return `${e.title} ${e.notes ?? ""}`;179        const passkeyText = e.passkey180          ? ` ${e.passkey.rpId} ${e.passkey.userName} ${e.passkey.userDisplayName}`181          : "";182        return `${e.title} ${e.url ?? ""} ${e.username ?? ""}${passkeyText}`;183      };184      result = result185        .filter((entry) => fuzzyMatch(q, searchableFor(entry)))186        .sort((a, b) => {187          const aSearchable = searchableFor(a).toLowerCase();188          const bSearchable = searchableFor(b).toLowerCase();189          const qLower = q.toLowerCase();190          const aExact = isExactPrefix(qLower, aSearchable);191          const bExact = isExactPrefix(qLower, bSearchable);192          if (aExact && !bExact) return -1;193          if (!aExact && bExact) return 1;194          return 0;195        });196      return result;197    }198199    const tabDomain = props.currentTabDomain ?? "";200    const order = props.sortOrder;201202    if (order === "az") {203      return [...result].sort((a, b) => a.title.localeCompare(b.title));204    }205    if (order === "za") {206      return [...result].sort((a, b) => b.title.localeCompare(a.title));207    }208    if (order === "recent") {209      return [...result].sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0));210    }211    if (tabDomain) {212      return [...result].sort((a, b) => {213        const aMatch = entryMatchesDomain(a, tabDomain) ? 0 : 1;214        const bMatch = entryMatchesDomain(b, tabDomain) ? 0 : 1;215        return aMatch - bMatch;216      });217    }218    return result;219  });220221  // Declaration order matters: `allVisibleIds` and `allSelected` read222  // `filteredEntries` eagerly on component mount via createMemo. Moving223  // `filteredEntries` below them throws ReferenceError under the production224  // minifier and freezes the vault list on mount (v1.4.34 regression).225  const allVisibleIds = createMemo(() => filteredEntries().map(e => e.id));226  const allSelected = createMemo(() =>227    allVisibleIds().length > 0 && allVisibleIds().every(id => selectedIds().has(id))228  );229230  function toggleSelectAll() {231    if (allSelected()) {232      setSelectedIds(new Set<string>());233    } else {234      setSelectedIds(new Set<string>(allVisibleIds()));235    }236  }237238  async function handleDeleteSelected() {239    const ids = Array.from(selectedIds());240    if (!ids.length || !props.onDeleteMany) return;241    setDeleting(true);242    await props.onDeleteMany(ids);243    setDeleting(false);244    exitSelectMode();245  }246247  function handleSearchKeydown(e: KeyboardEvent) {248    if (e.key === "ArrowDown") {249      e.preventDefault();250      const firstBtn = listRef?.querySelector<HTMLButtonElement>(".peach-entry-row");251      firstBtn?.focus();252    } else if (e.key === "ArrowUp") {253      e.preventDefault();254      const allBtns = listRef?.querySelectorAll<HTMLButtonElement>(".peach-entry-row");255      allBtns?.[allBtns.length - 1]?.focus();256    } else if (e.key === "Enter") {257      e.preventDefault();258      const firstEntry = filteredEntries()[0];259      if (firstEntry) props.onSelectEntry(firstEntry);260    } else if (e.key === "Escape" && selecting()) {261      exitSelectMode();262    }263  }264265  function handleListKeydown(e: KeyboardEvent) {266    if (!listRef) return;267    const btns = Array.from(listRef.querySelectorAll<HTMLButtonElement>(".peach-entry-row"));268    const idx = btns.indexOf(document.activeElement as HTMLButtonElement);269    if (e.key === "ArrowDown") {270      e.preventDefault();271      if (idx < btns.length - 1) btns[idx + 1]?.focus();272    } else if (e.key === "ArrowUp") {273      e.preventDefault();274      if (idx === 0) { searchRef?.focus(); }275      else btns[idx - 1]?.focus();276    } else if (e.key === "Escape") {277      if (selecting()) exitSelectMode();278      else props.onClose();279    }280  }281282  const sortLabels: { key: SortOrder; label: string }[] = [283    { key: "url-first", label: "smart" },284    { key: "az", label: "A–Z" },285    { key: "za", label: "Z–A" },286    { key: "recent", label: "newest" },287  ];288289  return (290    <div class="peach-overlay-view ov-enter-up" style="display:flex">291      <div class="peach-list-top">292        <button293          class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"294          onClick={props.onClose}295          aria-label="Back"296        >297          <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">298            <path d="M6 1L1 6L6 11" />299          </svg>300        </button>301        <input302          class="peach-search-input"303          placeholder="search vault..."304          autocomplete="off"305          style="font-size:14px"306          ref={focusSearch}307          value={search()}308          onInput={(e) => setSearch((e.currentTarget as HTMLInputElement).value)}309          onKeyDown={handleSearchKeydown}310        />311        <span class="peach-search-counter peach-search-counter-list">312          {search() || selectedTags().size > 0313            ? `${filteredEntries().length} of ${props.entries.length}`314            : `${props.entries.length} entries`}315        </span>316      </div>317318      {/* Sort bar — morphs into select-mode controls */}319      <div class="peach-sort-bar">320        <Show321          when={selecting()}322          fallback={323            <>324              <For each={sortLabels}>325                {({ key, label }) => (326                  <button327                    class={`peach-sort-btn${props.sortOrder === key ? " is-active" : ""}`}328                    onClick={() => props.onSortOrderChange(key)}329                  >330                    {label}331                  </button>332                )}333              </For>334              <span style="flex:1" />335              <button336                class={`peach-sort-trash-btn${notesOnly() ? " is-active" : ""}`}337                onClick={() => setNotesOnly(v => !v)}338                aria-label="Notes only"339                title="Show notes only"340              >341                {NOTE_ICON}342              </button>343              <Show when={props.onOpenTrash}>344                <button345                  class="peach-sort-trash-btn"346                  onClick={props.onOpenTrash}347                  aria-label="View trash"348                  title="View deleted entries"349                >350                  <svg width="11" height="12" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">351                    <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />352                  </svg>353                </button>354              </Show>355              <Show when={props.onDeleteMany}>356                <button357                  class="peach-sort-trash-btn"358                  onClick={enterSelectMode}359                  aria-label="Select entries"360                  title="Select entries"361                >362                  <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">363                    <circle cx="4" cy="4" r="2.5" />364                    <circle cx="9" cy="4" r="2.5" />365                    <circle cx="4" cy="9" r="2.5" />366                    <circle cx="9" cy="9" r="2.5" />367                  </svg>368                </button>369              </Show>370            </>371          }372        >373          {/* Select mode sort bar */}374          <button375            class="peach-select-cancel-btn"376            onClick={exitSelectMode}377          >378            cancel379          </button>380          <span class="peach-select-count-label">381            {selectedIds().size > 0 ? `${selectedIds().size} selected` : "select entries"}382          </span>383          <button384            class="peach-select-all-btn"385            onClick={toggleSelectAll}386          >387            {allSelected() ? "none" : "all"}388          </button>389        </Show>390      </div>391392      <Show when={hasTags() && !selecting()}>393        <div class="peach-tag-filter-strip" style="display:flex;gap:6px;padding:4px 12px 6px;overflow-x:auto;flex-wrap:nowrap;scrollbar-width:none;">394          <For each={allTags()}>395            {(tag) => (396              <button397                class={`peach-tag-filter-chip ${selectedTags().has(tag) ? "is-selected" : ""}`}398                onClick={() => toggleTag(tag)}399              >400                {tag}401              </button>402            )}403          </For>404        </div>405      </Show>406407      <div408        class={`peach-vault-list${selecting() ? " is-selecting" : ""}`}409        ref={listRef}410        onKeyDown={handleListKeydown}411      >412        <Show413          when={filteredEntries().length > 0}414          fallback={415            <div class="peach-list-empty">416              <span>no entries yet</span>417            </div>418          }419        >420          <For each={filteredEntries()}>421            {(entry, index) => {422              const isNote = entry.type === "note";423              const isPasskey = entry.passkey !== undefined;424              const domain = entry.type === "note"425                ? (entry.notes?.slice(0, 40) ?? "note")426                : entry.url427                  ? getDisplayDomain(entry.url)428                  : entry.passkey?.rpId429                    ? entry.passkey.rpId430                  : "saved entry";431              const isTabMatch = () =>432                props.currentTabDomain !== undefined &&433                props.currentTabDomain !== "" &&434                entryMatchesDomain(entry, props.currentTabDomain);435              const isSelected = () => selectedIds().has(entry.id);436437              return (438                <button439                  class={`peach-entry-row${!selecting() && props.selectedEntryId === entry.id ? " is-active" : ""}${!selecting() && isTabMatch() ? " is-tab-match" : ""}${selecting() && isSelected() ? " is-selected" : ""}`}440                  style={{ "animation-delay": `${Math.min(index(), 24) * 20}ms` }}441                  onClick={() => {442                    if (selecting()) {443                      toggleId(entry.id);444                    } else {445                      props.onSelectEntry(entry);446                    }447                  }}448                >449                  <div class={`peach-entry-row-icon${!selecting() && entry.breach ? " is-breach" : ""}${selecting() ? " is-select-icon" : ""}`}>450                    <Show451                      when={selecting()}452                      fallback={453                        <FaviconIcon454                          domain={entry.url ? getDisplayDomain(entry.url) : ""}455                          isNote={isNote}456                          isPasskey={isPasskey}457                        />458                      }459                    >460                      {/* Check indicator */}461                      <svg width="16" height="16" viewBox="0 0 16 16" fill="none">462                        <circle463                          cx="8" cy="8" r="7"464                          stroke="currentColor"465                          stroke-width="1"466                          fill={isSelected() ? "var(--peach-accent)" : "transparent"}467                          opacity={isSelected() ? "1" : "0.3"}468                        />469                        <Show when={isSelected()}>470                          <path471                            d="M5 8.5L7 10.5L11 6"472                            stroke="white"473                            stroke-width="1.2"474                            stroke-linecap="round"475                            stroke-linejoin="round"476                          />477                        </Show>478                      </svg>479                    </Show>480                  </div>481                  <div class="peach-entry-row-info">482                    <span class="peach-entry-row-name">483                      {entry.title}484                      <Show when={isPasskey && !selecting()}>485                        <span class="peach-passkey-badge">passkey</span>486                      </Show>487                      <Show when={isTabMatch() && !selecting()}>488                        <span class="peach-tab-match-badge" title="Matches current tab">●</span>489                      </Show>490                    </span>491                    <span class="peach-entry-row-meta">{domain}</span>492                  </div>493                  <Show when={entry.breach && !selecting()}>494                    <span class="peach-entry-row-dot" aria-label="breach"></span>495                  </Show>496                  <Show when={entry.canary && !selecting()}>497                    <span class="peach-entry-row-canary" title={`Canary: ${entry.canary?.label ?? "canary entry"}`}>🪤</span>498                  </Show>499                </button>500              );501            }}502          </For>503        </Show>504      </div>505506      {/* Bottom bar — new entry normally, delete action in select mode */}507      <Show508        when={selecting()}509        fallback={510          <button class="peach-new-entry-row" onClick={props.onAdd}>511            <svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round">512              <path d="M5 1v8M1 5h8" />513            </svg>514            new entry515          </button>516        }517      >518        <button519          class={`peach-select-action-bar${selectedIds().size === 0 ? " is-empty" : ""}`}520          disabled={selectedIds().size === 0 || deleting()}521          onClick={() => void handleDeleteSelected()}522        >523          <svg width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">524            <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />525          </svg>526          <Show527            when={selectedIds().size > 0}528            fallback={<span>select entries to delete</span>}529          >530            <span>531              {deleting()532                ? "moving to trash…"533                : `move ${selectedIds().size} to trash`}534            </span>535          </Show>536        </button>537      </Show>538    </div>539  );540}