Vault.tsx

21.0 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; title?: string; isNote: boolean; isPasskey: boolean }) {7  const src = props.domain && !props.isNote && !props.isPasskey8    ? faviconUrl(props.domain)9    : null;10  // Note/passkey entries keep their semantic glyphs. Login entries get a11  // colored-initials chip when the real favicon hasn't loaded — better than12  // a black square on freshly-imported entries the browser hasn't visited13  // yet (Chrome's `_favicon/` cache is empty for unvisited domains).14  const fallback = props.isPasskey15    ? PASSKEY_ICON16    : props.isNote17      ? NOTE_ICON18      : <InitialsChip title={props.title ?? props.domain ?? ""} />;19  const [failed, setFailed] = createSignal(false);20  return (21    <Show when={src && !failed()} fallback={fallback}>22      <img23        src={src!}24        width="16"25        height="16"26        alt=""27        style="border-radius:3px;"28        onError={() => setFailed(true)}29      />30    </Show>31  );32}3334/**35 * Title → "PM"-style initials. First letters of the first two words,36 * falling back to the first two characters of a single-word title. Empty37 * titles get a small dot. Deterministic so the same entry always renders38 * the same chip.39 */40function titleInitials(source: string): string {41  const cleaned = source.replace(/^https?:\/\//, "").replace(/^www\./, "").trim();42  if (!cleaned) return "·";43  const words = cleaned.split(/[\s.\-_/]+/).filter(Boolean);44  if (words.length >= 2 && words[0]![0] && words[1]![0]) {45    return (words[0]![0]! + words[1]![0]!).toUpperCase();46  }47  const single = words[0] ?? cleaned;48  return single.slice(0, 2).toUpperCase();49}5051/**52 * Hash a string to an HSL background. Fixed saturation + lightness so the53 * palette stays muted and dark enough that white initials read clearly on54 * top, while still giving each entry a recognisable colour signature.55 */56function titleBackground(source: string): string {57  let hash = 0;58  for (let i = 0; i < source.length; i++) {59    hash = ((hash << 5) - hash + source.charCodeAt(i)) | 0;60  }61  const hue = ((hash % 360) + 360) % 360;62  return `hsl(${hue}, 32%, 26%)`;63}6465function InitialsChip(props: { title: string }) {66  return (67    <span68      class="peach-favicon-initials"69      style={{ background: titleBackground(props.title) }}70      aria-hidden="true"71    >72      {titleInitials(props.title)}73    </span>74  );75}7677const LOGIN_ICON = (78  <svg width="11" height="12" viewBox="0 0 11 12" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round" aria-hidden="true">79    <rect x="2" y="5" width="7" height="5" rx="1.2" />80    <path d="M3.5 5V3.75A2 2 0 0 1 5.5 1.75A2 2 0 0 1 7.5 3.75V5" />81  </svg>82);8384const PASSKEY_ICON = (85  <svg width="11" height="12" viewBox="0 0 11 12" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round" aria-hidden="true">86    <circle cx="4" cy="4.5" r="3" />87    <path d="M6.5 7l3.5 3.5M8.5 8.5L10 10" />88  </svg>89);9091const NOTE_ICON = (92  <svg width="11" height="12" viewBox="0 0 11 12" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round" aria-hidden="true">93    <rect x="1.5" y="1.5" width="8" height="9" rx="1.5" />94    <path d="M3.5 4.5h4M3.5 6.5h4M3.5 8.5h2" />95  </svg>96);9798export type SortOrder = "url-first" | "az" | "za" | "recent";99100interface VaultProps {101  entries: VaultEntry[];102  selectedEntryId?: string;103  currentTabDomain?: string;104  sortOrder: SortOrder;105  searchSeed?: string;106  searchSeedNonce?: number;107  onSelectEntry: (entry: VaultEntry) => void;108  onSortOrderChange: (order: SortOrder) => void;109  onAdd: () => void;110  onClose: () => void;111  onOpenTrash?: () => void;112  onDeleteMany?: (ids: string[]) => Promise<void>;113}114115export function VaultView(props: VaultProps) {116  const [search, setSearch] = createSignal("");117  const [selectedTags, setSelectedTags] = createSignal<Set<string>>(new Set());118  const [notesOnly, setNotesOnly] = createSignal(false);119  const [selecting, setSelecting] = createSignal(false);120  const [selectedIds, setSelectedIds] = createSignal<Set<string>>(new Set());121  const [deleting, setDeleting] = createSignal(false);122123  let searchRef: HTMLInputElement | undefined;124  let listRef: HTMLDivElement | undefined;125126  const focusSearch = (el: HTMLInputElement) => {127    searchRef = el;128    setTimeout(() => el.focus(), 50);129  };130131  createEffect(() => {132    void props.searchSeedNonce;133    const seed = props.searchSeed;134    if (!seed) return;135    setSearch(seed);136    requestAnimationFrame(() => {137      searchRef?.focus();138      searchRef?.setSelectionRange(seed.length, seed.length);139    });140  });141142  function enterSelectMode() {143    setSelecting(true);144    setSelectedIds(new Set<string>());145  }146147  function exitSelectMode() {148    setSelecting(false);149    setSelectedIds(new Set<string>());150  }151152  function toggleId(id: string) {153    const s = new Set<string>(selectedIds());154    if (s.has(id)) s.delete(id); else s.add(id);155    setSelectedIds(s);156  }157158  const allTags = createMemo(() => {159    const tagSet = new Set<string>();160    for (const entry of props.entries) {161      for (const tag of entry.tags ?? []) {162        tagSet.add(tag);163      }164    }165    return Array.from(tagSet).sort();166  });167168  const hasTags = createMemo(() => allTags().length > 0);169170  function toggleTag(tag: string) {171    const current = new Set(selectedTags());172    if (current.has(tag)) {173      current.delete(tag);174    } else {175      current.add(tag);176    }177    setSelectedTags(current);178  }179180  function fuzzyMatch(query: string, target: string): boolean {181    query = query.toLowerCase();182    target = target.toLowerCase();183    let qi = 0;184    for (let i = 0; i < target.length && qi < query.length; i++) {185      if (target[i] === query[qi]) qi++;186    }187    return qi === query.length;188  }189190  function isExactPrefix(query: string, target: string): boolean {191    return target.toLowerCase().startsWith(query.toLowerCase());192  }193194  function entryDomain(entry: VaultEntry): string {195    if (entry.type === "login" && entry.url) return getDisplayDomain(entry.url);196    if (entry.passkey?.rpId) return entry.passkey.rpId;197    return "";198  }199200  function entryMatchesDomain(entry: VaultEntry, domain: string): boolean {201    if (!domain) return false;202    if (entry.passkey?.rpId === domain) return true;203    if (entry.type !== "login") return false;204    // Tab domain is the eTLD+1 from extractDomain — compare against eTLD+1 of205    // each entry URL too so subdomain entries also match.206    if (entry.url && extractDomain(entry.url) === domain) return true;207    for (const extra of entry.additionalUrls ?? []) {208      if (extractDomain(extra) === domain) return true;209    }210    return false;211  }212213  const filteredEntries = createMemo(() => {214    let result = props.entries;215216    if (notesOnly()) {217      result = result.filter(entry => entry.type === "note");218    }219220    if (selectedTags().size > 0) {221      result = result.filter(entry =>222        Array.from(selectedTags()).every(tag => entry.tags?.includes(tag))223      );224    }225226    const q = search().trim();227    if (q) {228      const searchableFor = (e: VaultEntry): string => {229        if (e.type === "note") return `${e.title} ${e.notes ?? ""}`;230        const passkeyText = e.passkey231          ? ` ${e.passkey.rpId} ${e.passkey.userName} ${e.passkey.userDisplayName}`232          : "";233        return `${e.title} ${e.url ?? ""} ${e.username ?? ""}${passkeyText}`;234      };235      result = result236        .filter((entry) => fuzzyMatch(q, searchableFor(entry)))237        .sort((a, b) => {238          const aSearchable = searchableFor(a).toLowerCase();239          const bSearchable = searchableFor(b).toLowerCase();240          const qLower = q.toLowerCase();241          const aExact = isExactPrefix(qLower, aSearchable);242          const bExact = isExactPrefix(qLower, bSearchable);243          if (aExact && !bExact) return -1;244          if (!aExact && bExact) return 1;245          return 0;246        });247      return result;248    }249250    const tabDomain = props.currentTabDomain ?? "";251    const order = props.sortOrder;252253    if (order === "az") {254      return [...result].sort((a, b) => a.title.localeCompare(b.title));255    }256    if (order === "za") {257      return [...result].sort((a, b) => b.title.localeCompare(a.title));258    }259    if (order === "recent") {260      return [...result].sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0));261    }262    if (tabDomain) {263      return [...result].sort((a, b) => {264        const aMatch = entryMatchesDomain(a, tabDomain) ? 0 : 1;265        const bMatch = entryMatchesDomain(b, tabDomain) ? 0 : 1;266        return aMatch - bMatch;267      });268    }269    return result;270  });271272  // Declaration order matters: `allVisibleIds` and `allSelected` read273  // `filteredEntries` eagerly on component mount via createMemo. Moving274  // `filteredEntries` below them throws ReferenceError under the production275  // minifier and freezes the vault list on mount (v1.4.34 regression).276  const allVisibleIds = createMemo(() => filteredEntries().map(e => e.id));277  const allSelected = createMemo(() =>278    allVisibleIds().length > 0 && allVisibleIds().every(id => selectedIds().has(id))279  );280281  function toggleSelectAll() {282    if (allSelected()) {283      setSelectedIds(new Set<string>());284    } else {285      setSelectedIds(new Set<string>(allVisibleIds()));286    }287  }288289  async function handleDeleteSelected() {290    const ids = Array.from(selectedIds());291    if (!ids.length || !props.onDeleteMany) return;292    setDeleting(true);293    await props.onDeleteMany(ids);294    setDeleting(false);295    exitSelectMode();296  }297298  function handleSearchKeydown(e: KeyboardEvent) {299    if (e.key === "ArrowDown") {300      e.preventDefault();301      const firstBtn = listRef?.querySelector<HTMLButtonElement>(".peach-entry-row");302      firstBtn?.focus();303    } else if (e.key === "ArrowUp") {304      e.preventDefault();305      const allBtns = listRef?.querySelectorAll<HTMLButtonElement>(".peach-entry-row");306      allBtns?.[allBtns.length - 1]?.focus();307    } else if (e.key === "Enter") {308      e.preventDefault();309      const firstEntry = filteredEntries()[0];310      if (firstEntry) props.onSelectEntry(firstEntry);311    } else if (e.key === "Escape" && selecting()) {312      exitSelectMode();313    }314  }315316  function handleListKeydown(e: KeyboardEvent) {317    if (!listRef) return;318    const btns = Array.from(listRef.querySelectorAll<HTMLButtonElement>(".peach-entry-row"));319    const idx = btns.indexOf(document.activeElement as HTMLButtonElement);320    if (e.key === "ArrowDown") {321      e.preventDefault();322      if (idx < btns.length - 1) btns[idx + 1]?.focus();323    } else if (e.key === "ArrowUp") {324      e.preventDefault();325      if (idx === 0) { searchRef?.focus(); }326      else btns[idx - 1]?.focus();327    } else if (e.key === "Escape") {328      if (selecting()) exitSelectMode();329      else props.onClose();330    }331  }332333  const sortLabels: { key: SortOrder; label: string }[] = [334    { key: "url-first", label: "smart" },335    { key: "az", label: "A–Z" },336    { key: "za", label: "Z–A" },337    { key: "recent", label: "newest" },338  ];339340  return (341    <div class="peach-overlay-view ov-enter-up" style="display:flex">342      <div class="peach-list-top">343        <button344          class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"345          onClick={props.onClose}346          aria-label="Back"347        >348          <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">349            <path d="M6 1L1 6L6 11" />350          </svg>351        </button>352        <input353          class="peach-search-input"354          placeholder="search vault..."355          autocomplete="off"356          style="font-size:14px"357          ref={focusSearch}358          value={search()}359          onInput={(e) => setSearch((e.currentTarget as HTMLInputElement).value)}360          onKeyDown={handleSearchKeydown}361        />362        <span class="peach-search-counter peach-search-counter-list">363          {search() || selectedTags().size > 0364            ? `${filteredEntries().length} of ${props.entries.length}`365            : `${props.entries.length} entries`}366        </span>367      </div>368369      {/* Sort bar — morphs into select-mode controls */}370      <div class="peach-sort-bar">371        <Show372          when={selecting()}373          fallback={374            <>375              <For each={sortLabels}>376                {({ key, label }) => (377                  <button378                    class={`peach-sort-btn${props.sortOrder === key ? " is-active" : ""}`}379                    onClick={() => props.onSortOrderChange(key)}380                  >381                    {label}382                  </button>383                )}384              </For>385              <span style="flex:1" />386              <button387                class={`peach-sort-trash-btn${notesOnly() ? " is-active" : ""}`}388                onClick={() => setNotesOnly(v => !v)}389                aria-label="Notes only"390                title="Show notes only"391              >392                {NOTE_ICON}393              </button>394              <Show when={props.onOpenTrash}>395                <button396                  class="peach-sort-trash-btn"397                  onClick={props.onOpenTrash}398                  aria-label="View trash"399                  title="View deleted entries"400                >401                  <svg width="11" height="12" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">402                    <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />403                  </svg>404                </button>405              </Show>406              <Show when={props.onDeleteMany}>407                <button408                  class="peach-sort-trash-btn"409                  onClick={enterSelectMode}410                  aria-label="Select entries"411                  title="Select entries"412                >413                  <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">414                    <circle cx="4" cy="4" r="2.5" />415                    <circle cx="9" cy="4" r="2.5" />416                    <circle cx="4" cy="9" r="2.5" />417                    <circle cx="9" cy="9" r="2.5" />418                  </svg>419                </button>420              </Show>421            </>422          }423        >424          {/* Select mode sort bar */}425          <button426            class="peach-select-cancel-btn"427            onClick={exitSelectMode}428          >429            cancel430          </button>431          <span class="peach-select-count-label">432            {selectedIds().size > 0 ? `${selectedIds().size} selected` : "select entries"}433          </span>434          <button435            class="peach-select-all-btn"436            onClick={toggleSelectAll}437          >438            {allSelected() ? "none" : "all"}439          </button>440        </Show>441      </div>442443      <Show when={hasTags() && !selecting()}>444        <div class="peach-tag-filter-strip" style="display:flex;gap:6px;padding:4px 12px 6px;overflow-x:auto;flex-wrap:nowrap;scrollbar-width:none;">445          <For each={allTags()}>446            {(tag) => (447              <button448                class={`peach-tag-filter-chip ${selectedTags().has(tag) ? "is-selected" : ""}`}449                onClick={() => toggleTag(tag)}450              >451                {tag}452              </button>453            )}454          </For>455        </div>456      </Show>457458      <div459        class={`peach-vault-list${selecting() ? " is-selecting" : ""}`}460        ref={listRef}461        onKeyDown={handleListKeydown}462      >463        <Show464          when={filteredEntries().length > 0}465          fallback={466            <div class="peach-list-empty">467              <span>no entries yet</span>468            </div>469          }470        >471          <For each={filteredEntries()}>472            {(entry, index) => {473              const isNote = entry.type === "note";474              const isPasskey = entry.passkey !== undefined;475              const domain = entry.type === "note"476                ? (entry.notes?.slice(0, 40) ?? "note")477                : entry.url478                  ? getDisplayDomain(entry.url)479                  : entry.passkey?.rpId480                    ? entry.passkey.rpId481                  : "saved entry";482              const isTabMatch = () =>483                props.currentTabDomain !== undefined &&484                props.currentTabDomain !== "" &&485                entryMatchesDomain(entry, props.currentTabDomain);486              const isSelected = () => selectedIds().has(entry.id);487488              return (489                <button490                  class={`peach-entry-row${!selecting() && props.selectedEntryId === entry.id ? " is-active" : ""}${!selecting() && isTabMatch() ? " is-tab-match" : ""}${selecting() && isSelected() ? " is-selected" : ""}`}491                  style={{ "animation-delay": `${Math.min(index(), 24) * 20}ms` }}492                  onClick={() => {493                    if (selecting()) {494                      toggleId(entry.id);495                    } else {496                      props.onSelectEntry(entry);497                    }498                  }}499                >500                  <div class={`peach-entry-row-icon${!selecting() && entry.breach ? " is-breach" : ""}${selecting() ? " is-select-icon" : ""}`}>501                    <Show502                      when={selecting()}503                      fallback={504                        <FaviconIcon505                          domain={entry.url ? getDisplayDomain(entry.url) : ""}506                          title={entry.title}507                          isNote={isNote}508                          isPasskey={isPasskey}509                        />510                      }511                    >512                      {/* Check indicator */}513                      <svg width="16" height="16" viewBox="0 0 16 16" fill="none">514                        <circle515                          cx="8" cy="8" r="7"516                          stroke="currentColor"517                          stroke-width="1"518                          fill={isSelected() ? "var(--peach-accent)" : "transparent"}519                          opacity={isSelected() ? "1" : "0.3"}520                        />521                        <Show when={isSelected()}>522                          <path523                            d="M5 8.5L7 10.5L11 6"524                            stroke="white"525                            stroke-width="1.2"526                            stroke-linecap="round"527                            stroke-linejoin="round"528                          />529                        </Show>530                      </svg>531                    </Show>532                  </div>533                  <div class="peach-entry-row-info">534                    <span class="peach-entry-row-name">535                      {entry.title}536                      <Show when={isPasskey && !selecting()}>537                        <span class="peach-passkey-badge">passkey</span>538                      </Show>539                      <Show when={isTabMatch() && !selecting()}>540                        <span class="peach-tab-match-badge" title="Matches current tab">●</span>541                      </Show>542                    </span>543                    <span class="peach-entry-row-meta">{domain}</span>544                  </div>545                  <Show when={entry.breach && !selecting()}>546                    <span class="peach-entry-row-dot" aria-label="breach"></span>547                  </Show>548                  <Show when={entry.canary && !selecting()}>549                    <span class="peach-entry-row-canary" title={`Canary: ${entry.canary?.label ?? "canary entry"}`}>🪤</span>550                  </Show>551                </button>552              );553            }}554          </For>555        </Show>556      </div>557558      {/* Bottom bar — new entry normally, delete action in select mode */}559      <Show560        when={selecting()}561        fallback={562          <button class="peach-new-entry-row" onClick={props.onAdd}>563            <svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round">564              <path d="M5 1v8M1 5h8" />565            </svg>566            new entry567          </button>568        }569      >570        <button571          class={`peach-select-action-bar${selectedIds().size === 0 ? " is-empty" : ""}`}572          disabled={selectedIds().size === 0 || deleting()}573          onClick={() => void handleDeleteSelected()}574        >575          <svg width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">576            <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />577          </svg>578          <Show579            when={selectedIds().size > 0}580            fallback={<span>select entries to delete</span>}581          >582            <span>583              {deleting()584                ? "moving to trash…"585                : `move ${selectedIds().size} to trash`}586            </span>587          </Show>588        </button>589      </Show>590    </div>591  );592}