Vault.tsx

17.8 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              <span style="flex:1" />299              <Show when={props.onOpenTrash}>300                <button301                  class="peach-sort-trash-btn"302                  onClick={props.onOpenTrash}303                  aria-label="View trash"304                  title="View deleted entries"305                >306                  <svg width="11" height="12" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">307                    <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />308                  </svg>309                </button>310              </Show>311              <Show when={props.onDeleteMany}>312                <button313                  class="peach-sort-trash-btn"314                  onClick={enterSelectMode}315                  aria-label="Select entries"316                  title="Select entries"317                >318                  <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">319                    <circle cx="4" cy="4" r="2.5" />320                    <circle cx="9" cy="4" r="2.5" />321                    <circle cx="4" cy="9" r="2.5" />322                    <circle cx="9" cy="9" r="2.5" />323                  </svg>324                </button>325              </Show>326            </>327          }328        >329          {/* Select mode sort bar */}330          <button331            class="peach-select-cancel-btn"332            onClick={exitSelectMode}333          >334            cancel335          </button>336          <span class="peach-select-count-label">337            {selectedIds().size > 0 ? `${selectedIds().size} selected` : "select entries"}338          </span>339          <button340            class="peach-select-all-btn"341            onClick={toggleSelectAll}342          >343            {allSelected() ? "none" : "all"}344          </button>345        </Show>346      </div>347348      <Show when={hasTags() && !selecting()}>349        <div class="peach-tag-filter-strip" style="display:flex;gap:6px;padding:4px 12px 6px;overflow-x:auto;flex-wrap:nowrap;scrollbar-width:none;">350          <For each={allTags()}>351            {(tag) => (352              <button353                class={`peach-tag-filter-chip ${selectedTags().has(tag) ? "is-selected" : ""}`}354                onClick={() => toggleTag(tag)}355              >356                {tag}357              </button>358            )}359          </For>360        </div>361      </Show>362363      <div364        class={`peach-vault-list${selecting() ? " is-selecting" : ""}`}365        ref={listRef}366        onKeyDown={handleListKeydown}367      >368        <Show369          when={filteredEntries().length > 0}370          fallback={371            <div class="peach-list-empty">372              <span>no entries yet</span>373            </div>374          }375        >376          <For each={filteredEntries()}>377            {(entry, index) => {378              const isNote = entry.type === "note";379              const isPasskey = entry.type === "passkey";380              const domain = entry.type === "note"381                ? (entry.notes?.slice(0, 40) ?? "note")382                : entry.type === "passkey"383                  ? entry.rpId ?? "passkey"384                : entry.url385                  ? getDisplayDomain(entry.url)386                  : "saved entry";387              const isTabMatch = () =>388                props.currentTabDomain !== undefined &&389                props.currentTabDomain !== "" &&390                entryDomain(entry) === props.currentTabDomain;391              const isSelected = () => selectedIds().has(entry.id);392393              return (394                <button395                  class={`peach-entry-row${!selecting() && props.selectedEntryId === entry.id ? " is-active" : ""}${!selecting() && isTabMatch() ? " is-tab-match" : ""}${selecting() && isSelected() ? " is-selected" : ""}`}396                  style={{ "animation-delay": `${Math.min(index(), 24) * 20}ms` }}397                  onClick={() => {398                    if (selecting()) {399                      toggleId(entry.id);400                    } else {401                      props.onSelectEntry(entry);402                    }403                  }}404                >405                  <div class={`peach-entry-row-icon${!selecting() && entry.breach ? " is-breach" : ""}${selecting() ? " is-select-icon" : ""}`}>406                    <Show407                      when={selecting()}408                      fallback={409                        <FaviconIcon410                          domain={entry.url ? getDisplayDomain(entry.url) : ""}411                          isNote={isNote}412                          isPasskey={isPasskey}413                        />414                      }415                    >416                      {/* Check indicator */}417                      <svg width="16" height="16" viewBox="0 0 16 16" fill="none">418                        <circle419                          cx="8" cy="8" r="7"420                          stroke="currentColor"421                          stroke-width="1"422                          fill={isSelected() ? "var(--peach-accent)" : "transparent"}423                          opacity={isSelected() ? "1" : "0.3"}424                        />425                        <Show when={isSelected()}>426                          <path427                            d="M5 8.5L7 10.5L11 6"428                            stroke="white"429                            stroke-width="1.2"430                            stroke-linecap="round"431                            stroke-linejoin="round"432                          />433                        </Show>434                      </svg>435                    </Show>436                  </div>437                  <div class="peach-entry-row-info">438                    <span class="peach-entry-row-name">439                      {entry.title}440                      <Show when={isPasskey && !selecting()}>441                        <span class="peach-passkey-badge">passkey</span>442                      </Show>443                      <Show when={isTabMatch() && !selecting()}>444                        <span class="peach-tab-match-badge" title="Matches current tab">●</span>445                      </Show>446                    </span>447                    <span class="peach-entry-row-meta">{domain}</span>448                  </div>449                  <Show when={entry.breach && !selecting()}>450                    <span class="peach-entry-row-dot" aria-label="breach"></span>451                  </Show>452                  <Show when={entry.canary && !selecting()}>453                    <span class="peach-entry-row-canary" title={`Canary: ${entry.canary?.label ?? "canary entry"}`}>🪤</span>454                  </Show>455                </button>456              );457            }}458          </For>459        </Show>460      </div>461462      {/* Bottom bar — new entry normally, delete action in select mode */}463      <Show464        when={selecting()}465        fallback={466          <button class="peach-new-entry-row" onClick={props.onAdd}>467            <svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round">468              <path d="M5 1v8M1 5h8" />469            </svg>470            new entry471          </button>472        }473      >474        <button475          class={`peach-select-action-bar${selectedIds().size === 0 ? " is-empty" : ""}`}476          disabled={selectedIds().size === 0 || deleting()}477          onClick={() => void handleDeleteSelected()}478        >479          <svg width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">480            <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />481          </svg>482          <Show483            when={selectedIds().size > 0}484            fallback={<span>select entries to delete</span>}485          >486            <span>487              {deleting()488                ? "moving to trash…"489                : `move ${selectedIds().size} to trash`}490            </span>491          </Show>492        </button>493      </Show>494    </div>495  );496}