Vault.tsx

18.0 KB · raw

1import { 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  onSelectEntry: (entry: VaultEntry) => void;46  onSortOrderChange: (order: SortOrder) => void;47  onAdd: () => void;48  onClose: () => void;49  onOpenTrash?: () => void;50  onDeleteMany?: (ids: string[]) => Promise<void>;51}5253export function VaultView(props: VaultProps) {54  const [search, setSearch] = createSignal("");55  const [selectedTags, setSelectedTags] = createSignal<Set<string>>(new Set());56  const [selecting, setSelecting] = createSignal(false);57  const [selectedIds, setSelectedIds] = createSignal<Set<string>>(new Set());58  const [deleting, setDeleting] = createSignal(false);5960  let searchRef: HTMLInputElement | undefined;61  let listRef: HTMLDivElement | undefined;6263  const focusSearch = (el: HTMLInputElement) => {64    searchRef = el;65    setTimeout(() => el.focus(), 50);66  };6768  function enterSelectMode() {69    setSelecting(true);70    setSelectedIds(new Set<string>());71  }7273  function exitSelectMode() {74    setSelecting(false);75    setSelectedIds(new Set<string>());76  }7778  function toggleId(id: string) {79    const s = new Set<string>(selectedIds());80    if (s.has(id)) s.delete(id); else s.add(id);81    setSelectedIds(s);82  }8384  const allTags = createMemo(() => {85    const tagSet = new Set<string>();86    for (const entry of props.entries) {87      for (const tag of entry.tags ?? []) {88        tagSet.add(tag);89      }90    }91    return Array.from(tagSet).sort();92  });9394  const hasTags = createMemo(() => allTags().length > 0);9596  function toggleTag(tag: string) {97    const current = new Set(selectedTags());98    if (current.has(tag)) {99      current.delete(tag);100    } else {101      current.add(tag);102    }103    setSelectedTags(current);104  }105106  function fuzzyMatch(query: string, target: string): boolean {107    query = query.toLowerCase();108    target = target.toLowerCase();109    let qi = 0;110    for (let i = 0; i < target.length && qi < query.length; i++) {111      if (target[i] === query[qi]) qi++;112    }113    return qi === query.length;114  }115116  function isExactPrefix(query: string, target: string): boolean {117    return target.toLowerCase().startsWith(query.toLowerCase());118  }119120  function entryDomain(entry: VaultEntry): string {121    if (entry.type === "login" && entry.url) return getDisplayDomain(entry.url);122    if (entry.type === "passkey" && entry.rpId) return entry.rpId;123    return "";124  }125126  const filteredEntries = createMemo(() => {127    let result = props.entries;128129    if (selectedTags().size > 0) {130      result = result.filter(entry =>131        Array.from(selectedTags()).every(tag => entry.tags?.includes(tag))132      );133    }134135    const q = search().trim();136    if (q) {137      result = result138        .filter((entry) => {139          const searchable = entry.type === "note"140            ? `${entry.title} ${entry.notes ?? ""}`141            : entry.type === "passkey"142              ? `${entry.title} ${entry.rpId ?? ""} ${entry.userName ?? ""} ${entry.userDisplayName ?? ""}`143            : `${entry.title} ${entry.url ?? ""} ${entry.username ?? ""}`;144          return fuzzyMatch(q, searchable);145        })146        .sort((a, b) => {147          const aSearchable = a.type === "note"148            ? `${a.title} ${a.notes ?? ""}`.toLowerCase()149            : a.type === "passkey"150              ? `${a.title} ${a.rpId ?? ""} ${a.userName ?? ""}`.toLowerCase()151            : `${a.title} ${a.url ?? ""} ${a.username ?? ""}`.toLowerCase();152          const bSearchable = b.type === "note"153            ? `${b.title} ${b.notes ?? ""}`.toLowerCase()154            : b.type === "passkey"155              ? `${b.title} ${b.rpId ?? ""} ${b.userName ?? ""}`.toLowerCase()156            : `${b.title} ${b.url ?? ""} ${b.username ?? ""}`.toLowerCase();157          const qLower = q.toLowerCase();158          const aExact = isExactPrefix(qLower, aSearchable);159          const bExact = isExactPrefix(qLower, bSearchable);160          if (aExact && !bExact) return -1;161          if (!aExact && bExact) return 1;162          return 0;163        });164      return result;165    }166167    const tabDomain = props.currentTabDomain ?? "";168    const order = props.sortOrder;169170    if (order === "az") {171      return [...result].sort((a, b) => a.title.localeCompare(b.title));172    }173    if (order === "za") {174      return [...result].sort((a, b) => b.title.localeCompare(a.title));175    }176    if (order === "recent") {177      return [...result].sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0));178    }179    if (tabDomain) {180      return [...result].sort((a, b) => {181        const aMatch = entryDomain(a) === tabDomain ? 0 : 1;182        const bMatch = entryDomain(b) === tabDomain ? 0 : 1;183        return aMatch - bMatch;184      });185    }186    return result;187  });188189  const allVisibleIds = createMemo(() => filteredEntries().map(e => e.id));190  const allSelected = createMemo(() =>191    allVisibleIds().length > 0 && allVisibleIds().every(id => selectedIds().has(id))192  );193194  function toggleSelectAll() {195    if (allSelected()) {196      setSelectedIds(new Set<string>());197    } else {198      setSelectedIds(new Set<string>(allVisibleIds()));199    }200  }201202  async function handleDeleteSelected() {203    const ids = Array.from(selectedIds());204    if (!ids.length || !props.onDeleteMany) return;205    setDeleting(true);206    await props.onDeleteMany(ids);207    setDeleting(false);208    exitSelectMode();209  }210211  function handleSearchKeydown(e: KeyboardEvent) {212    if (e.key === "ArrowDown") {213      e.preventDefault();214      const firstBtn = listRef?.querySelector<HTMLButtonElement>(".peach-entry-row");215      firstBtn?.focus();216    } else if (e.key === "ArrowUp") {217      e.preventDefault();218      const allBtns = listRef?.querySelectorAll<HTMLButtonElement>(".peach-entry-row");219      allBtns?.[allBtns.length - 1]?.focus();220    } else if (e.key === "Enter") {221      e.preventDefault();222      const firstEntry = filteredEntries()[0];223      if (firstEntry) props.onSelectEntry(firstEntry);224    } else if (e.key === "Escape" && selecting()) {225      exitSelectMode();226    }227  }228229  function handleListKeydown(e: KeyboardEvent) {230    if (!listRef) return;231    const btns = Array.from(listRef.querySelectorAll<HTMLButtonElement>(".peach-entry-row"));232    const idx = btns.indexOf(document.activeElement as HTMLButtonElement);233    if (e.key === "ArrowDown") {234      e.preventDefault();235      if (idx < btns.length - 1) btns[idx + 1]?.focus();236    } else if (e.key === "ArrowUp") {237      e.preventDefault();238      if (idx === 0) { searchRef?.focus(); }239      else btns[idx - 1]?.focus();240    } else if (e.key === "Escape") {241      if (selecting()) exitSelectMode();242      else props.onClose();243    }244  }245246  const sortLabels: { key: SortOrder; label: string }[] = [247    { key: "url-first", label: "smart" },248    { key: "az", label: "A–Z" },249    { key: "za", label: "Z–A" },250    { key: "recent", label: "newest" },251  ];252253  return (254    <div class="peach-overlay-view ov-enter-up" style="display:flex">255      <div class="peach-list-top">256        <button257          class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"258          onClick={props.onClose}259          aria-label="Back"260        >261          <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">262            <path d="M6 1L1 6L6 11" />263          </svg>264        </button>265        <input266          class="peach-search-input"267          placeholder="search vault..."268          autocomplete="off"269          style="font-size:14px"270          ref={focusSearch}271          value={search()}272          onInput={(e) => setSearch((e.currentTarget as HTMLInputElement).value)}273          onKeyDown={handleSearchKeydown}274        />275        <span class="peach-search-counter peach-search-counter-list">276          {search() || selectedTags().size > 0277            ? `${filteredEntries().length} of ${props.entries.length}`278            : `${props.entries.length} entries`}279        </span>280      </div>281282      {/* Sort bar — morphs into select-mode controls */}283      <div class="peach-sort-bar">284        <Show285          when={selecting()}286          fallback={287            <>288              <For each={sortLabels}>289                {({ key, label }) => (290                  <button291                    class={`peach-sort-btn${props.sortOrder === key ? " is-active" : ""}`}292                    onClick={() => props.onSortOrderChange(key)}293                  >294                    {label}295                  </button>296                )}297              </For>298              <Show when={props.currentTabDomain && props.sortOrder === "url-first"}>299                <span class="peach-sort-domain-hint">{props.currentTabDomain}</span>300              </Show>301              <span style="flex:1" />302              <Show when={props.onOpenTrash}>303                <button304                  class="peach-sort-trash-btn"305                  onClick={props.onOpenTrash}306                  aria-label="View trash"307                  title="View deleted entries"308                >309                  <svg width="11" height="12" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">310                    <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />311                  </svg>312                </button>313              </Show>314              <Show when={props.onDeleteMany}>315                <button316                  class="peach-sort-trash-btn"317                  onClick={enterSelectMode}318                  aria-label="Select entries"319                  title="Select entries"320                >321                  <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">322                    <circle cx="4" cy="4" r="2.5" />323                    <circle cx="9" cy="4" r="2.5" />324                    <circle cx="4" cy="9" r="2.5" />325                    <circle cx="9" cy="9" r="2.5" />326                  </svg>327                </button>328              </Show>329            </>330          }331        >332          {/* Select mode sort bar */}333          <button334            class="peach-select-cancel-btn"335            onClick={exitSelectMode}336          >337            cancel338          </button>339          <span class="peach-select-count-label">340            {selectedIds().size > 0 ? `${selectedIds().size} selected` : "select entries"}341          </span>342          <button343            class="peach-select-all-btn"344            onClick={toggleSelectAll}345          >346            {allSelected() ? "none" : "all"}347          </button>348        </Show>349      </div>350351      <Show when={hasTags() && !selecting()}>352        <div class="peach-tag-filter-strip" style="display:flex;gap:6px;padding:4px 12px 6px;overflow-x:auto;flex-wrap:nowrap;scrollbar-width:none;">353          <For each={allTags()}>354            {(tag) => (355              <button356                class={`peach-tag-filter-chip ${selectedTags().has(tag) ? "is-selected" : ""}`}357                onClick={() => toggleTag(tag)}358              >359                {tag}360              </button>361            )}362          </For>363        </div>364      </Show>365366      <div367        class={`peach-vault-list${selecting() ? " is-selecting" : ""}`}368        ref={listRef}369        onKeyDown={handleListKeydown}370      >371        <Show372          when={filteredEntries().length > 0}373          fallback={374            <div class="peach-list-empty">375              <span>no entries yet</span>376            </div>377          }378        >379          <For each={filteredEntries()}>380            {(entry, index) => {381              const isNote = entry.type === "note";382              const isPasskey = entry.type === "passkey";383              const domain = entry.type === "note"384                ? (entry.notes?.slice(0, 40) ?? "note")385                : entry.type === "passkey"386                  ? entry.rpId ?? "passkey"387                : entry.url388                  ? getDisplayDomain(entry.url)389                  : "saved entry";390              const isTabMatch = () =>391                props.currentTabDomain !== undefined &&392                props.currentTabDomain !== "" &&393                entryDomain(entry) === props.currentTabDomain;394              const isSelected = () => selectedIds().has(entry.id);395396              return (397                <button398                  class={`peach-entry-row${!selecting() && props.selectedEntryId === entry.id ? " is-active" : ""}${!selecting() && isTabMatch() ? " is-tab-match" : ""}${selecting() && isSelected() ? " is-selected" : ""}`}399                  style={{ "animation-delay": `${Math.min(index(), 24) * 20}ms` }}400                  onClick={() => {401                    if (selecting()) {402                      toggleId(entry.id);403                    } else {404                      props.onSelectEntry(entry);405                    }406                  }}407                >408                  <div class={`peach-entry-row-icon${!selecting() && entry.breach ? " is-breach" : ""}${selecting() ? " is-select-icon" : ""}`}>409                    <Show410                      when={selecting()}411                      fallback={412                        <FaviconIcon413                          domain={entry.url ? getDisplayDomain(entry.url) : ""}414                          isNote={isNote}415                          isPasskey={isPasskey}416                        />417                      }418                    >419                      {/* Check indicator */}420                      <svg width="16" height="16" viewBox="0 0 16 16" fill="none">421                        <circle422                          cx="8" cy="8" r="7"423                          stroke="currentColor"424                          stroke-width="1"425                          fill={isSelected() ? "var(--peach-accent)" : "transparent"}426                          opacity={isSelected() ? "1" : "0.3"}427                        />428                        <Show when={isSelected()}>429                          <path430                            d="M5 8.5L7 10.5L11 6"431                            stroke="white"432                            stroke-width="1.2"433                            stroke-linecap="round"434                            stroke-linejoin="round"435                          />436                        </Show>437                      </svg>438                    </Show>439                  </div>440                  <div class="peach-entry-row-info">441                    <span class="peach-entry-row-name">442                      {entry.title}443                      <Show when={isPasskey && !selecting()}>444                        <span class="peach-passkey-badge">passkey</span>445                      </Show>446                      <Show when={isTabMatch() && !selecting()}>447                        <span class="peach-tab-match-badge" title="Matches current tab">●</span>448                      </Show>449                    </span>450                    <span class="peach-entry-row-meta">{domain}</span>451                  </div>452                  <Show when={entry.breach && !selecting()}>453                    <span class="peach-entry-row-dot" aria-label="breach"></span>454                  </Show>455                  <Show when={entry.canary && !selecting()}>456                    <span class="peach-entry-row-canary" title={`Canary: ${entry.canary?.label ?? "canary entry"}`}>🪤</span>457                  </Show>458                </button>459              );460            }}461          </For>462        </Show>463      </div>464465      {/* Bottom bar — new entry normally, delete action in select mode */}466      <Show467        when={selecting()}468        fallback={469          <button class="peach-new-entry-row" onClick={props.onAdd}>470            <svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round">471              <path d="M5 1v8M1 5h8" />472            </svg>473            new entry474          </button>475        }476      >477        <button478          class={`peach-select-action-bar${selectedIds().size === 0 ? " is-empty" : ""}`}479          disabled={selectedIds().size === 0 || deleting()}480          onClick={() => void handleDeleteSelected()}481        >482          <svg width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">483            <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />484          </svg>485          <Show486            when={selectedIds().size > 0}487            fallback={<span>select entries to delete</span>}488          >489            <span>490              {deleting()491                ? "moving to trash…"492                : `move ${selectedIds().size} to trash`}493            </span>494          </Show>495        </button>496      </Show>497    </div>498  );499}