Vault.tsx

18.6 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  const allVisibleIds = createMemo(() => filteredEntries().map(e => e.id));208  const allSelected = createMemo(() =>209    allVisibleIds().length > 0 && allVisibleIds().every(id => selectedIds().has(id))210  );211212  function toggleSelectAll() {213    if (allSelected()) {214      setSelectedIds(new Set<string>());215    } else {216      setSelectedIds(new Set<string>(allVisibleIds()));217    }218  }219220  async function handleDeleteSelected() {221    const ids = Array.from(selectedIds());222    if (!ids.length || !props.onDeleteMany) return;223    setDeleting(true);224    await props.onDeleteMany(ids);225    setDeleting(false);226    exitSelectMode();227  }228229  function handleSearchKeydown(e: KeyboardEvent) {230    if (e.key === "ArrowDown") {231      e.preventDefault();232      const firstBtn = listRef?.querySelector<HTMLButtonElement>(".peach-entry-row");233      firstBtn?.focus();234    } else if (e.key === "ArrowUp") {235      e.preventDefault();236      const allBtns = listRef?.querySelectorAll<HTMLButtonElement>(".peach-entry-row");237      allBtns?.[allBtns.length - 1]?.focus();238    } else if (e.key === "Enter") {239      e.preventDefault();240      const firstEntry = filteredEntries()[0];241      if (firstEntry) props.onSelectEntry(firstEntry);242    } else if (e.key === "Escape" && selecting()) {243      exitSelectMode();244    }245  }246247  function handleListKeydown(e: KeyboardEvent) {248    if (!listRef) return;249    const btns = Array.from(listRef.querySelectorAll<HTMLButtonElement>(".peach-entry-row"));250    const idx = btns.indexOf(document.activeElement as HTMLButtonElement);251    if (e.key === "ArrowDown") {252      e.preventDefault();253      if (idx < btns.length - 1) btns[idx + 1]?.focus();254    } else if (e.key === "ArrowUp") {255      e.preventDefault();256      if (idx === 0) { searchRef?.focus(); }257      else btns[idx - 1]?.focus();258    } else if (e.key === "Escape") {259      if (selecting()) exitSelectMode();260      else props.onClose();261    }262  }263264  const sortLabels: { key: SortOrder; label: string }[] = [265    { key: "url-first", label: "smart" },266    { key: "az", label: "A–Z" },267    { key: "za", label: "Z–A" },268    { key: "recent", label: "newest" },269  ];270271  return (272    <div class="peach-overlay-view ov-enter-up" style="display:flex">273      <div class="peach-list-top">274        <button275          class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"276          onClick={props.onClose}277          aria-label="Back"278        >279          <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">280            <path d="M6 1L1 6L6 11" />281          </svg>282        </button>283        <input284          class="peach-search-input"285          placeholder="search vault..."286          autocomplete="off"287          style="font-size:14px"288          ref={focusSearch}289          value={search()}290          onInput={(e) => setSearch((e.currentTarget as HTMLInputElement).value)}291          onKeyDown={handleSearchKeydown}292        />293        <span class="peach-search-counter peach-search-counter-list">294          {search() || selectedTags().size > 0295            ? `${filteredEntries().length} of ${props.entries.length}`296            : `${props.entries.length} entries`}297        </span>298      </div>299300      {/* Sort bar — morphs into select-mode controls */}301      <div class="peach-sort-bar">302        <Show303          when={selecting()}304          fallback={305            <>306              <For each={sortLabels}>307                {({ key, label }) => (308                  <button309                    class={`peach-sort-btn${props.sortOrder === key ? " is-active" : ""}`}310                    onClick={() => props.onSortOrderChange(key)}311                  >312                    {label}313                  </button>314                )}315              </For>316              <span style="flex:1" />317              <button318                class={`peach-sort-trash-btn${notesOnly() ? " is-active" : ""}`}319                onClick={() => setNotesOnly(v => !v)}320                aria-label="Notes only"321                title="Show notes only"322              >323                {NOTE_ICON}324              </button>325              <Show when={props.onOpenTrash}>326                <button327                  class="peach-sort-trash-btn"328                  onClick={props.onOpenTrash}329                  aria-label="View trash"330                  title="View deleted entries"331                >332                  <svg width="11" height="12" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">333                    <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />334                  </svg>335                </button>336              </Show>337              <Show when={props.onDeleteMany}>338                <button339                  class="peach-sort-trash-btn"340                  onClick={enterSelectMode}341                  aria-label="Select entries"342                  title="Select entries"343                >344                  <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">345                    <circle cx="4" cy="4" r="2.5" />346                    <circle cx="9" cy="4" r="2.5" />347                    <circle cx="4" cy="9" r="2.5" />348                    <circle cx="9" cy="9" r="2.5" />349                  </svg>350                </button>351              </Show>352            </>353          }354        >355          {/* Select mode sort bar */}356          <button357            class="peach-select-cancel-btn"358            onClick={exitSelectMode}359          >360            cancel361          </button>362          <span class="peach-select-count-label">363            {selectedIds().size > 0 ? `${selectedIds().size} selected` : "select entries"}364          </span>365          <button366            class="peach-select-all-btn"367            onClick={toggleSelectAll}368          >369            {allSelected() ? "none" : "all"}370          </button>371        </Show>372      </div>373374      <Show when={hasTags() && !selecting()}>375        <div class="peach-tag-filter-strip" style="display:flex;gap:6px;padding:4px 12px 6px;overflow-x:auto;flex-wrap:nowrap;scrollbar-width:none;">376          <For each={allTags()}>377            {(tag) => (378              <button379                class={`peach-tag-filter-chip ${selectedTags().has(tag) ? "is-selected" : ""}`}380                onClick={() => toggleTag(tag)}381              >382                {tag}383              </button>384            )}385          </For>386        </div>387      </Show>388389      <div390        class={`peach-vault-list${selecting() ? " is-selecting" : ""}`}391        ref={listRef}392        onKeyDown={handleListKeydown}393      >394        <Show395          when={filteredEntries().length > 0}396          fallback={397            <div class="peach-list-empty">398              <span>no entries yet</span>399            </div>400          }401        >402          <For each={filteredEntries()}>403            {(entry, index) => {404              const isNote = entry.type === "note";405              const isPasskey = entry.type === "passkey";406              const domain = entry.type === "note"407                ? (entry.notes?.slice(0, 40) ?? "note")408                : entry.type === "passkey"409                  ? entry.rpId ?? "passkey"410                : entry.url411                  ? getDisplayDomain(entry.url)412                  : "saved entry";413              const isTabMatch = () =>414                props.currentTabDomain !== undefined &&415                props.currentTabDomain !== "" &&416                entryDomain(entry) === props.currentTabDomain;417              const isSelected = () => selectedIds().has(entry.id);418419              return (420                <button421                  class={`peach-entry-row${!selecting() && props.selectedEntryId === entry.id ? " is-active" : ""}${!selecting() && isTabMatch() ? " is-tab-match" : ""}${selecting() && isSelected() ? " is-selected" : ""}`}422                  style={{ "animation-delay": `${Math.min(index(), 24) * 20}ms` }}423                  onClick={() => {424                    if (selecting()) {425                      toggleId(entry.id);426                    } else {427                      props.onSelectEntry(entry);428                    }429                  }}430                >431                  <div class={`peach-entry-row-icon${!selecting() && entry.breach ? " is-breach" : ""}${selecting() ? " is-select-icon" : ""}`}>432                    <Show433                      when={selecting()}434                      fallback={435                        <FaviconIcon436                          domain={entry.url ? getDisplayDomain(entry.url) : ""}437                          isNote={isNote}438                          isPasskey={isPasskey}439                        />440                      }441                    >442                      {/* Check indicator */}443                      <svg width="16" height="16" viewBox="0 0 16 16" fill="none">444                        <circle445                          cx="8" cy="8" r="7"446                          stroke="currentColor"447                          stroke-width="1"448                          fill={isSelected() ? "var(--peach-accent)" : "transparent"}449                          opacity={isSelected() ? "1" : "0.3"}450                        />451                        <Show when={isSelected()}>452                          <path453                            d="M5 8.5L7 10.5L11 6"454                            stroke="white"455                            stroke-width="1.2"456                            stroke-linecap="round"457                            stroke-linejoin="round"458                          />459                        </Show>460                      </svg>461                    </Show>462                  </div>463                  <div class="peach-entry-row-info">464                    <span class="peach-entry-row-name">465                      {entry.title}466                      <Show when={isPasskey && !selecting()}>467                        <span class="peach-passkey-badge">passkey</span>468                      </Show>469                      <Show when={isTabMatch() && !selecting()}>470                        <span class="peach-tab-match-badge" title="Matches current tab">●</span>471                      </Show>472                    </span>473                    <span class="peach-entry-row-meta">{domain}</span>474                  </div>475                  <Show when={entry.breach && !selecting()}>476                    <span class="peach-entry-row-dot" aria-label="breach"></span>477                  </Show>478                  <Show when={entry.canary && !selecting()}>479                    <span class="peach-entry-row-canary" title={`Canary: ${entry.canary?.label ?? "canary entry"}`}>🪤</span>480                  </Show>481                </button>482              );483            }}484          </For>485        </Show>486      </div>487488      {/* Bottom bar — new entry normally, delete action in select mode */}489      <Show490        when={selecting()}491        fallback={492          <button class="peach-new-entry-row" onClick={props.onAdd}>493            <svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round">494              <path d="M5 1v8M1 5h8" />495            </svg>496            new entry497          </button>498        }499      >500        <button501          class={`peach-select-action-bar${selectedIds().size === 0 ? " is-empty" : ""}`}502          disabled={selectedIds().size === 0 || deleting()}503          onClick={() => void handleDeleteSelected()}504        >505          <svg width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">506            <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />507          </svg>508          <Show509            when={selectedIds().size > 0}510            fallback={<span>select entries to delete</span>}511          >512            <span>513              {deleting()514                ? "moving to trash…"515                : `move ${selectedIds().size} to trash`}516            </span>517          </Show>518        </button>519      </Show>520    </div>521  );522}