Vault.tsx

18.2 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 [selecting, setSelecting] = createSignal(false);59  const [selectedIds, setSelectedIds] = createSignal<Set<string>>(new Set());60  const [deleting, setDeleting] = createSignal(false);6162  let searchRef: HTMLInputElement | undefined;63  let listRef: HTMLDivElement | undefined;6465  const focusSearch = (el: HTMLInputElement) => {66    searchRef = el;67    setTimeout(() => el.focus(), 50);68  };6970  createEffect(() => {71    void props.searchSeedNonce;72    const seed = props.searchSeed;73    if (!seed) return;74    setSearch(seed);75    requestAnimationFrame(() => {76      searchRef?.focus();77      searchRef?.setSelectionRange(seed.length, seed.length);78    });79  });8081  function enterSelectMode() {82    setSelecting(true);83    setSelectedIds(new Set<string>());84  }8586  function exitSelectMode() {87    setSelecting(false);88    setSelectedIds(new Set<string>());89  }9091  function toggleId(id: string) {92    const s = new Set<string>(selectedIds());93    if (s.has(id)) s.delete(id); else s.add(id);94    setSelectedIds(s);95  }9697  const allTags = createMemo(() => {98    const tagSet = new Set<string>();99    for (const entry of props.entries) {100      for (const tag of entry.tags ?? []) {101        tagSet.add(tag);102      }103    }104    return Array.from(tagSet).sort();105  });106107  const hasTags = createMemo(() => allTags().length > 0);108109  function toggleTag(tag: string) {110    const current = new Set(selectedTags());111    if (current.has(tag)) {112      current.delete(tag);113    } else {114      current.add(tag);115    }116    setSelectedTags(current);117  }118119  function fuzzyMatch(query: string, target: string): boolean {120    query = query.toLowerCase();121    target = target.toLowerCase();122    let qi = 0;123    for (let i = 0; i < target.length && qi < query.length; i++) {124      if (target[i] === query[qi]) qi++;125    }126    return qi === query.length;127  }128129  function isExactPrefix(query: string, target: string): boolean {130    return target.toLowerCase().startsWith(query.toLowerCase());131  }132133  function entryDomain(entry: VaultEntry): string {134    if (entry.type === "login" && entry.url) return getDisplayDomain(entry.url);135    if (entry.type === "passkey" && entry.rpId) return entry.rpId;136    return "";137  }138139  const filteredEntries = createMemo(() => {140    let result = props.entries;141142    if (selectedTags().size > 0) {143      result = result.filter(entry =>144        Array.from(selectedTags()).every(tag => entry.tags?.includes(tag))145      );146    }147148    const q = search().trim();149    if (q) {150      result = result151        .filter((entry) => {152          const searchable = entry.type === "note"153            ? `${entry.title} ${entry.notes ?? ""}`154            : entry.type === "passkey"155              ? `${entry.title} ${entry.rpId ?? ""} ${entry.userName ?? ""} ${entry.userDisplayName ?? ""}`156            : `${entry.title} ${entry.url ?? ""} ${entry.username ?? ""}`;157          return fuzzyMatch(q, searchable);158        })159        .sort((a, b) => {160          const aSearchable = a.type === "note"161            ? `${a.title} ${a.notes ?? ""}`.toLowerCase()162            : a.type === "passkey"163              ? `${a.title} ${a.rpId ?? ""} ${a.userName ?? ""}`.toLowerCase()164            : `${a.title} ${a.url ?? ""} ${a.username ?? ""}`.toLowerCase();165          const bSearchable = b.type === "note"166            ? `${b.title} ${b.notes ?? ""}`.toLowerCase()167            : b.type === "passkey"168              ? `${b.title} ${b.rpId ?? ""} ${b.userName ?? ""}`.toLowerCase()169            : `${b.title} ${b.url ?? ""} ${b.username ?? ""}`.toLowerCase();170          const qLower = q.toLowerCase();171          const aExact = isExactPrefix(qLower, aSearchable);172          const bExact = isExactPrefix(qLower, bSearchable);173          if (aExact && !bExact) return -1;174          if (!aExact && bExact) return 1;175          return 0;176        });177      return result;178    }179180    const tabDomain = props.currentTabDomain ?? "";181    const order = props.sortOrder;182183    if (order === "az") {184      return [...result].sort((a, b) => a.title.localeCompare(b.title));185    }186    if (order === "za") {187      return [...result].sort((a, b) => b.title.localeCompare(a.title));188    }189    if (order === "recent") {190      return [...result].sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0));191    }192    if (tabDomain) {193      return [...result].sort((a, b) => {194        const aMatch = entryDomain(a) === tabDomain ? 0 : 1;195        const bMatch = entryDomain(b) === tabDomain ? 0 : 1;196        return aMatch - bMatch;197      });198    }199    return result;200  });201202  const allVisibleIds = createMemo(() => filteredEntries().map(e => e.id));203  const allSelected = createMemo(() =>204    allVisibleIds().length > 0 && allVisibleIds().every(id => selectedIds().has(id))205  );206207  function toggleSelectAll() {208    if (allSelected()) {209      setSelectedIds(new Set<string>());210    } else {211      setSelectedIds(new Set<string>(allVisibleIds()));212    }213  }214215  async function handleDeleteSelected() {216    const ids = Array.from(selectedIds());217    if (!ids.length || !props.onDeleteMany) return;218    setDeleting(true);219    await props.onDeleteMany(ids);220    setDeleting(false);221    exitSelectMode();222  }223224  function handleSearchKeydown(e: KeyboardEvent) {225    if (e.key === "ArrowDown") {226      e.preventDefault();227      const firstBtn = listRef?.querySelector<HTMLButtonElement>(".peach-entry-row");228      firstBtn?.focus();229    } else if (e.key === "ArrowUp") {230      e.preventDefault();231      const allBtns = listRef?.querySelectorAll<HTMLButtonElement>(".peach-entry-row");232      allBtns?.[allBtns.length - 1]?.focus();233    } else if (e.key === "Enter") {234      e.preventDefault();235      const firstEntry = filteredEntries()[0];236      if (firstEntry) props.onSelectEntry(firstEntry);237    } else if (e.key === "Escape" && selecting()) {238      exitSelectMode();239    }240  }241242  function handleListKeydown(e: KeyboardEvent) {243    if (!listRef) return;244    const btns = Array.from(listRef.querySelectorAll<HTMLButtonElement>(".peach-entry-row"));245    const idx = btns.indexOf(document.activeElement as HTMLButtonElement);246    if (e.key === "ArrowDown") {247      e.preventDefault();248      if (idx < btns.length - 1) btns[idx + 1]?.focus();249    } else if (e.key === "ArrowUp") {250      e.preventDefault();251      if (idx === 0) { searchRef?.focus(); }252      else btns[idx - 1]?.focus();253    } else if (e.key === "Escape") {254      if (selecting()) exitSelectMode();255      else props.onClose();256    }257  }258259  const sortLabels: { key: SortOrder; label: string }[] = [260    { key: "url-first", label: "smart" },261    { key: "az", label: "A–Z" },262    { key: "za", label: "Z–A" },263    { key: "recent", label: "newest" },264  ];265266  return (267    <div class="peach-overlay-view ov-enter-up" style="display:flex">268      <div class="peach-list-top">269        <button270          class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"271          onClick={props.onClose}272          aria-label="Back"273        >274          <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">275            <path d="M6 1L1 6L6 11" />276          </svg>277        </button>278        <input279          class="peach-search-input"280          placeholder="search vault..."281          autocomplete="off"282          style="font-size:14px"283          ref={focusSearch}284          value={search()}285          onInput={(e) => setSearch((e.currentTarget as HTMLInputElement).value)}286          onKeyDown={handleSearchKeydown}287        />288        <span class="peach-search-counter peach-search-counter-list">289          {search() || selectedTags().size > 0290            ? `${filteredEntries().length} of ${props.entries.length}`291            : `${props.entries.length} entries`}292        </span>293      </div>294295      {/* Sort bar — morphs into select-mode controls */}296      <div class="peach-sort-bar">297        <Show298          when={selecting()}299          fallback={300            <>301              <For each={sortLabels}>302                {({ key, label }) => (303                  <button304                    class={`peach-sort-btn${props.sortOrder === key ? " is-active" : ""}`}305                    onClick={() => props.onSortOrderChange(key)}306                  >307                    {label}308                  </button>309                )}310              </For>311              <span style="flex:1" />312              <Show when={props.onOpenTrash}>313                <button314                  class="peach-sort-trash-btn"315                  onClick={props.onOpenTrash}316                  aria-label="View trash"317                  title="View deleted entries"318                >319                  <svg width="11" height="12" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">320                    <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />321                  </svg>322                </button>323              </Show>324              <Show when={props.onDeleteMany}>325                <button326                  class="peach-sort-trash-btn"327                  onClick={enterSelectMode}328                  aria-label="Select entries"329                  title="Select entries"330                >331                  <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">332                    <circle cx="4" cy="4" r="2.5" />333                    <circle cx="9" cy="4" r="2.5" />334                    <circle cx="4" cy="9" r="2.5" />335                    <circle cx="9" cy="9" r="2.5" />336                  </svg>337                </button>338              </Show>339            </>340          }341        >342          {/* Select mode sort bar */}343          <button344            class="peach-select-cancel-btn"345            onClick={exitSelectMode}346          >347            cancel348          </button>349          <span class="peach-select-count-label">350            {selectedIds().size > 0 ? `${selectedIds().size} selected` : "select entries"}351          </span>352          <button353            class="peach-select-all-btn"354            onClick={toggleSelectAll}355          >356            {allSelected() ? "none" : "all"}357          </button>358        </Show>359      </div>360361      <Show when={hasTags() && !selecting()}>362        <div class="peach-tag-filter-strip" style="display:flex;gap:6px;padding:4px 12px 6px;overflow-x:auto;flex-wrap:nowrap;scrollbar-width:none;">363          <For each={allTags()}>364            {(tag) => (365              <button366                class={`peach-tag-filter-chip ${selectedTags().has(tag) ? "is-selected" : ""}`}367                onClick={() => toggleTag(tag)}368              >369                {tag}370              </button>371            )}372          </For>373        </div>374      </Show>375376      <div377        class={`peach-vault-list${selecting() ? " is-selecting" : ""}`}378        ref={listRef}379        onKeyDown={handleListKeydown}380      >381        <Show382          when={filteredEntries().length > 0}383          fallback={384            <div class="peach-list-empty">385              <span>no entries yet</span>386            </div>387          }388        >389          <For each={filteredEntries()}>390            {(entry, index) => {391              const isNote = entry.type === "note";392              const isPasskey = entry.type === "passkey";393              const domain = entry.type === "note"394                ? (entry.notes?.slice(0, 40) ?? "note")395                : entry.type === "passkey"396                  ? entry.rpId ?? "passkey"397                : entry.url398                  ? getDisplayDomain(entry.url)399                  : "saved entry";400              const isTabMatch = () =>401                props.currentTabDomain !== undefined &&402                props.currentTabDomain !== "" &&403                entryDomain(entry) === props.currentTabDomain;404              const isSelected = () => selectedIds().has(entry.id);405406              return (407                <button408                  class={`peach-entry-row${!selecting() && props.selectedEntryId === entry.id ? " is-active" : ""}${!selecting() && isTabMatch() ? " is-tab-match" : ""}${selecting() && isSelected() ? " is-selected" : ""}`}409                  style={{ "animation-delay": `${Math.min(index(), 24) * 20}ms` }}410                  onClick={() => {411                    if (selecting()) {412                      toggleId(entry.id);413                    } else {414                      props.onSelectEntry(entry);415                    }416                  }}417                >418                  <div class={`peach-entry-row-icon${!selecting() && entry.breach ? " is-breach" : ""}${selecting() ? " is-select-icon" : ""}`}>419                    <Show420                      when={selecting()}421                      fallback={422                        <FaviconIcon423                          domain={entry.url ? getDisplayDomain(entry.url) : ""}424                          isNote={isNote}425                          isPasskey={isPasskey}426                        />427                      }428                    >429                      {/* Check indicator */}430                      <svg width="16" height="16" viewBox="0 0 16 16" fill="none">431                        <circle432                          cx="8" cy="8" r="7"433                          stroke="currentColor"434                          stroke-width="1"435                          fill={isSelected() ? "var(--peach-accent)" : "transparent"}436                          opacity={isSelected() ? "1" : "0.3"}437                        />438                        <Show when={isSelected()}>439                          <path440                            d="M5 8.5L7 10.5L11 6"441                            stroke="white"442                            stroke-width="1.2"443                            stroke-linecap="round"444                            stroke-linejoin="round"445                          />446                        </Show>447                      </svg>448                    </Show>449                  </div>450                  <div class="peach-entry-row-info">451                    <span class="peach-entry-row-name">452                      {entry.title}453                      <Show when={isPasskey && !selecting()}>454                        <span class="peach-passkey-badge">passkey</span>455                      </Show>456                      <Show when={isTabMatch() && !selecting()}>457                        <span class="peach-tab-match-badge" title="Matches current tab">●</span>458                      </Show>459                    </span>460                    <span class="peach-entry-row-meta">{domain}</span>461                  </div>462                  <Show when={entry.breach && !selecting()}>463                    <span class="peach-entry-row-dot" aria-label="breach"></span>464                  </Show>465                  <Show when={entry.canary && !selecting()}>466                    <span class="peach-entry-row-canary" title={`Canary: ${entry.canary?.label ?? "canary entry"}`}>🪤</span>467                  </Show>468                </button>469              );470            }}471          </For>472        </Show>473      </div>474475      {/* Bottom bar — new entry normally, delete action in select mode */}476      <Show477        when={selecting()}478        fallback={479          <button class="peach-new-entry-row" onClick={props.onAdd}>480            <svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round">481              <path d="M5 1v8M1 5h8" />482            </svg>483            new entry484          </button>485        }486      >487        <button488          class={`peach-select-action-bar${selectedIds().size === 0 ? " is-empty" : ""}`}489          disabled={selectedIds().size === 0 || deleting()}490          onClick={() => void handleDeleteSelected()}491        >492          <svg width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">493            <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />494          </svg>495          <Show496            when={selectedIds().size > 0}497            fallback={<span>select entries to delete</span>}498          >499            <span>500              {deleting()501                ? "moving to trash…"502                : `move ${selectedIds().size} to trash`}503            </span>504          </Show>505        </button>506      </Show>507    </div>508  );509}