Add.tsx

27.3 KB · raw

1import { createSignal, createMemo, createEffect, For, Show, onMount } from "solid-js";2import type { VaultEntry } from "../../shared/types";3import { sendMessage } from "../App";4import { parseOtpauthURI } from "../../crypto/totp";5import { NoteEditorInline, Toolbar } from "./NoteEditor";6import { GeneratorDrawer } from "../components/GeneratorDrawer";78interface AddProps {9  entry?: VaultEntry;10  entries?: VaultEntry[];11  autoOpenGenerator?: boolean;12  onBack: () => void;13  onSave: (entry?: VaultEntry) => void | Promise<void>;14}1516export function AddView(props: AddProps) {17  const [title, setTitle] = createSignal(props.entry?.title ?? "");18  const [username, setUsername] = createSignal(props.entry?.username ?? "");19  const [password, setPassword] = createSignal(props.entry?.password ?? "");20  const [url, setUrl] = createSignal(props.entry?.url ?? "");21  const [totp, setTotp] = createSignal(props.entry?.totp ?? "");22  const [notes, setNotes] = createSignal(props.entry?.notes ?? "");23  const [saving, setSaving] = createSignal(false);24  const [error, setError] = createSignal("");25  const [passwordStrength, setPasswordStrength] = createSignal<number | null>(null);26  const [zxcvbnFn, setZxcvbnFn] = createSignal<((pwd: string) => { score: number }) | null>(null);27  const [tags, setTags] = createSignal<string[]>(props.entry?.tags ?? []);28  const [tagInput, setTagInput] = createSignal("");29  const [showTagSuggestions, setShowTagSuggestions] = createSignal(false);30  const [showGenerator, setShowGenerator] = createSignal(props.autoOpenGenerator ?? false);31  const [showPassword, setShowPassword] = createSignal(false);32  const [showTotp, setShowTotp] = createSignal(!!(props.entry?.totp));33  const [showNotes, setShowNotes] = createSignal(!!(props.entry?.notes));34  const [email, setEmail] = createSignal((props.entry as VaultEntry & { email?: string })?.email ?? "");35  const [activeIdentityField, setActiveIdentityField] = createSignal<"username" | "email" | null>(null);36  const [identityFilter, setIdentityFilter] = createSignal<"all" | "username" | "email">("all");3738  const allIdentities = createMemo(() => {39    const emailMap = new Map<string, number>();40    const usernameMap = new Map<string, number>();41    for (const entry of props.entries ?? []) {42      const uname = entry.username?.trim() ?? "";43      const em = (entry as VaultEntry & { email?: string }).email?.trim() ?? "";44      if (uname) usernameMap.set(uname, (usernameMap.get(uname) ?? 0) + 1);45      if (em) emailMap.set(em, (emailMap.get(em) ?? 0) + 1);46    }47    const result: Array<{ value: string; count: number; type: "email" | "username" }> = [];48    const seen = new Set<string>();49    for (const v of new Set([...emailMap.keys(), ...usernameMap.keys()])) {50      if (seen.has(v)) continue;51      seen.add(v);52      const ec = emailMap.get(v) ?? 0;53      const uc = usernameMap.get(v) ?? 0;54      result.push({ value: v, count: Math.max(ec, uc), type: ec >= uc ? "email" : "username" });55    }56    return result.sort((a, b) => b.count - a.count);57  });5859  const identitySuggestions = createMemo(() => {60    const field = activeIdentityField();61    if (!field) return [];62    const input = (field === "username" ? username() : email()).toLowerCase();63    const filter = identityFilter();64    return allIdentities()65      .filter(s => (filter === "all" || s.type === filter) && (!input || s.value.toLowerCase().includes(input)))66      .slice(0, 8);67  });6869  const isNoteEntry = () => props.entry?.type === "note";70  const isCardEntry = () => props.entry?.type === "card";71  const [selectedType, setSelectedType] = createSignal<"login" | "note" | "card">(72    isNoteEntry() ? "note" : isCardEntry() ? "card" : "login"73  );7475  // Card signals76  const [cardNickname, setCardNickname] = createSignal(props.entry?.cardNickname ?? "");77  const [cardholderName, setCardholderName] = createSignal(props.entry?.cardholderName ?? "");78  const [cardNumber, setCardNumber] = createSignal(props.entry?.cardNumber ?? "");79  const [expMonth, setExpMonth] = createSignal(props.entry?.expMonth ?? "");80  const [expYear, setExpYear] = createSignal(props.entry?.expYear ?? "");81  const [cvc, setCvc] = createSignal(props.entry?.cvc ?? "");82  const [showCvc, setShowCvc] = createSignal(false);83  const [cardNumberFocused, setCardNumberFocused] = createSignal(false);84  const [noteEditorEl, setNoteEditorEl] = createSignal<HTMLDivElement | null>(null);8586  function noteExec(cmd: string, val?: string) {87    document.execCommand(cmd, false, val);88    noteEditorEl()?.focus();89  }9091  function getCardNetwork(num: string): string {92    const n = num.replace(/\s/g, '');93    if (/^4/.test(n)) return 'Visa';94    if (/^5[1-5]|^2[2-7]/.test(n)) return 'Mastercard';95    if (/^3[47]/.test(n)) return 'Amex';96    if (/^6(?:011|5)/.test(n)) return 'Discover';97    return '';98  }99100  function formatCardNumberDisplay(raw: string, focused: boolean): string {101    const n = raw.replace(/\s/g, '');102    if (!focused && n.length >= 4) return '•••• •••• •••• ' + n.slice(-4);103    if (/^3[47]/.test(n)) return n.replace(/(\d{4})(\d{1,6})?(\d{1,5})?/, (_, a, b, c) => [a, b, c].filter(Boolean).join(' '));104    return n.replace(/(\d{4})(?=\d)/g, '$1 ').trim();105  }106107  const canSave = () => {108    if (!title().trim()) return false;109    if (selectedType() === "login" && !password().trim()) return false;110    if (selectedType() === "card" && (!cardholderName().trim() || !cardNumber().trim())) return false;111    return true;112  };113114  const allTags = createMemo(() => {115    const tagSet = new Set<string>();116    for (const entry of props.entries ?? []) {117      for (const tag of entry.tags ?? []) tagSet.add(tag);118    }119    return Array.from(tagSet).sort();120  });121122  const tagSuggestions = createMemo(() => {123    const input = tagInput().toLowerCase().trim();124    if (!input) return [];125    return allTags().filter(t =>126      t.toLowerCase().includes(input) && !tags().includes(t)127    ).slice(0, 5);128  });129130  function addTag(tag: string) {131    const n = tag.toLowerCase().trim();132    if (!n || n.length > 32 || tags().includes(n) || tags().length >= 10) return;133    setTags([...tags(), n]);134  }135136  function removeTag(tag: string) {137    setTags(tags().filter(t => t !== tag));138  }139140  function handleTagKeyDown(e: KeyboardEvent) {141    if (e.key === "Enter" || e.key === ",") {142      e.preventDefault();143      if (tagInput()) { addTag(tagInput()); setTagInput(""); setShowTagSuggestions(false); }144    } else if (e.key === "Backspace" && !tagInput() && tags().length > 0) {145      removeTag(tags()[tags().length - 1]);146    }147  }148149  // Strength check150  createEffect(async () => {151    const pwd = password();152    if (pwd.length < 4) { setPasswordStrength(null); return; }153    try {154      if (!zxcvbnFn()) {155        const mod = await import("zxcvbn");156        setZxcvbnFn(() => mod.default);157      }158      const fn = zxcvbnFn();159      if (fn) setPasswordStrength(fn(pwd).score);160    } catch {161      setPasswordStrength(pwd.length >= 16 ? 3 : pwd.length >= 12 ? 2 : 1);162    }163  });164165  onMount(() => {166    const el = document.querySelector(".peach-new-title-input") as HTMLInputElement | null;167    if (el) el.focus();168  });169170  function getNormalizedTotp(): string | undefined {171    const value = totp().trim();172    if (!value) return undefined;173    if (value.startsWith("otpauth://")) {174      const parsed = parseOtpauthURI(value);175      return parsed?.secret ?? value;176    }177    return value;178  }179180  async function handleSave() {181    if (!canSave()) {182      setError(183        selectedType() === "note"184          ? "title is required"185          : selectedType() === "card"186          ? "cardholder name and card number are required"187          : "title and password are required"188      );189      return;190    }191192    setSaving(true);193    setError("");194195    const payload: Record<string, unknown> = {196      type: selectedType(),197      title: title().trim(),198      notes: notes().trim() || undefined,199      tags: tags().length > 0 ? tags() : undefined,200    };201202    if (selectedType() === "login") {203      payload.username = username().trim();204      payload.email = email().trim() || undefined;205      payload.password = password();206      payload.url = url().trim();207      payload.totp = getNormalizedTotp();208    }209210    if (selectedType() === "card") {211      const raw = cardNumber().replace(/\s/g, '');212      payload.cardholderName = cardholderName().trim();213      payload.cardNumber = raw;214      payload.expMonth = expMonth().trim();215      payload.expYear = expYear().trim();216      payload.cvc = cvc().trim();217      payload.cardNickname = cardNickname().trim() || undefined;218      const n = raw;219      payload.network = /^4/.test(n) ? 'visa' : /^5[1-5]|^2[2-7]/.test(n) ? 'mastercard' : /^3[47]/.test(n) ? 'amex' : /^6(?:011|5)/.test(n) ? 'discover' : 'unknown';220      payload.last4 = raw.slice(-4);221    }222223    if (props.entry) {224      const res = await sendMessage({ type: "UPDATE_ENTRY", payload: { id: props.entry.id, patch: payload } });225      const result = res as { ok: boolean; error?: string };226      if (!result.ok) { setError(result.error ?? "could not update entry"); setSaving(false); return; }227      await props.onSave({ ...props.entry, ...payload } as VaultEntry);228      return;229    }230231    const res = await sendMessage({ type: "ADD_ENTRY", payload });232    const result = res as { ok: boolean; data?: VaultEntry; error?: string };233    if (!result.ok) {234      if (result.error === "vault locked") {235        setError("vault is locked — tap the lock icon to re-enter your master password");236      } else {237        setError(result.error ?? "could not save entry");238      }239      setSaving(false);240      return;241    }242    await props.onSave(result.data);243  }244245  const strengthLabel = () => {246    const s = passwordStrength();247    if (s === null) return null;248    return ["weak", "weak", "fair", "good", "strong"][s] as string;249  };250251  return (252    <div class="peach-overlay-view ov-enter-right" style="display:flex">253      <div class="peach-overlay-header">254        <button255          class="peach-btn peach-btn--ghost peach-btn--sm peach-overlay-action"256          onClick={props.onBack}257        >258          cancel259        </button>260        <span class="peach-overlay-title">261          {props.entry ? "edit entry" : "new entry"}262        </span>263        <button264          class="peach-btn peach-btn--accent peach-btn--sm peach-overlay-action"265          onClick={() => void handleSave()}266          disabled={saving() || !canSave()}267        >268          {saving() ? "saving…" : props.entry ? "save" : "create"}269        </button>270      </div>271272      {/* ── Body — mirrors Entry.tsx field-card structure ── */}273      <div class="peach-entry-body peach-entry-body--new">274275        {/* Type toggle */}276        <Show when={!props.entry}>277          <div class="peach-new-type-row">278            <button279              class={`peach-new-type-btn ${selectedType() === "login" ? "is-active" : ""}`}280              onClick={() => setSelectedType("login")}281            >login</button>282            <button283              class={`peach-new-type-btn ${selectedType() === "note" ? "is-active" : ""}`}284              onClick={() => setSelectedType("note")}285            >note</button>286            <button287              class={`peach-new-type-btn ${selectedType() === "card" ? "is-active" : ""}`}288              onClick={() => setSelectedType("card")}289            >card</button>290          </div>291        </Show>292293        {/* Note formatting toolbar — own row so it never overflows */}294        <Show when={selectedType() === "note"}>295          <Toolbar exec={noteExec} class="peach-note-toolbar-body" />296        </Show>297298        {/* Title */}299        <div class="peach-new-title-wrap">300          <input301            class="peach-new-title-input"302            placeholder={selectedType() === "note" ? "note title" : "entry name"}303            autocomplete="off"304            value={title()}305            onInput={(e) => setTitle((e.currentTarget as HTMLInputElement).value)}306            onKeyDown={(e) => { if (e.key === "Enter") void handleSave(); }}307          />308        </div>309310        {/* ── Login fields ── */}311        <Show when={selectedType() === "login"}>312          <div class="peach-domain">313            <input314              class="peach-domain-value peach-domain-value--edit"315              placeholder="domain.com"316              autocomplete="off"317              value={url()}318              onInput={(e) => setUrl((e.currentTarget as HTMLInputElement).value)}319            />320          </div>321322          <section class="peach-fields">323            {/* Username */}324            <div class="peach-field-card">325              <span class="peach-label">username</span>326              <div class="peach-field-value-row">327                <input328                  class="peach-field-value peach-field-value--edit"329                  placeholder="johndoe"330                  autocomplete="off"331                  value={username()}332                  onInput={(e) => setUsername((e.currentTarget as HTMLInputElement).value)}333                  onFocus={() => setActiveIdentityField("username")}334                  onBlur={() => setTimeout(() => setActiveIdentityField(null), 150)}335                />336              </div>337              <Show when={activeIdentityField() === "username" && identitySuggestions().length > 0}>338                <IdentitySuggestList339                  suggestions={identitySuggestions()}340                  filter={identityFilter()}341                  onFilter={setIdentityFilter}342                  onPick={(v) => { setUsername(v); setActiveIdentityField(null); }}343                />344              </Show>345            </div>346347            {/* Email */}348            <div class="peach-field-card">349              <span class="peach-label">email</span>350              <div class="peach-field-value-row">351                <input352                  class="peach-field-value peach-field-value--edit"353                  type="email"354                  placeholder="[email protected]"355                  autocomplete="off"356                  value={email()}357                  onInput={(e) => setEmail((e.currentTarget as HTMLInputElement).value)}358                  onFocus={() => setActiveIdentityField("email")}359                  onBlur={() => setTimeout(() => setActiveIdentityField(null), 150)}360                />361              </div>362              <Show when={activeIdentityField() === "email" && identitySuggestions().length > 0}>363                <IdentitySuggestList364                  suggestions={identitySuggestions()}365                  filter={identityFilter()}366                  onFilter={setIdentityFilter}367                  onPick={(v) => { setEmail(v); setActiveIdentityField(null); }}368                />369              </Show>370            </div>371372            {/* Password */}373            <div class="peach-field-card">374              <span class="peach-label">password</span>375              <div class="peach-password-edit-row">376                <input377                  class="peach-field-value peach-field-value--edit"378                  type={showPassword() ? "text" : "password"}379                  placeholder="password"380                  autocomplete="new-password"381                  value={password()}382                  onInput={(e) => setPassword((e.currentTarget as HTMLInputElement).value)}383                />384                <button385                  class="peach-inline-action peach-password-toggle-btn"386                  type="button"387                  onClick={() => setShowPassword(!showPassword())}388                  aria-label={showPassword() ? "Hide password" : "Show password"}389                >390                  <Show391                    when={showPassword()}392                    fallback={393                      <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">394                        <path d="M1 5C1 5 2.8 1 7 1C11.2 1 13 5 13 5C13 5 11.2 9 7 9C2.8 9 1 5 1 5Z" />395                        <circle cx="7" cy="5" r="1.75" />396                      </svg>397                    }398                  >399                    <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">400                      <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />401                      <path d="M3.5 5.5L2.5 7.5" />402                      <path d="M7 6.5V8.5" />403                      <path d="M10.5 5.5L11.5 7.5" />404                    </svg>405                  </Show>406                </button>407              </div>408409              {/* Strength */}410              <Show when={strengthLabel() !== null}>411                <div class="peach-strength-hairline">412                  <div class="peach-strength-hairline-track">413                    <div414                      class="peach-strength-hairline-fill"415                      style={{416                        width: ["25%","40%","60%","80%","100%"][passwordStrength()!],417                        background: ["rgba(220,80,60,0.65)","rgba(220,80,60,0.65)","rgba(200,140,60,0.6)","rgba(180,162,100,0.55)","rgba(150,175,110,0.6)"][passwordStrength()!],418                      }}419                    />420                  </div>421                  <span class="peach-strength-label">{strengthLabel()}</span>422                </div>423              </Show>424425              {/* Generator toggle */}426              <button427                class="peach-generate-toggle-link"428                type="button"429                onClick={() => { setShowGenerator(!showGenerator()); }}430              >431                {showGenerator() ? "☰ hide generator" : "⚙ generate"}432              </button>433              <GeneratorDrawer434                compact435                open={showGenerator()}436                onUse={(pw) => { setPassword(pw); setShowGenerator(false); }}437              />438            </div>439440            {/* 2FA — optional */}441            <Show when={showTotp() || totp()}>442              <div class="peach-field-card">443                <span class="peach-label">2fa secret</span>444                <div class="peach-field-value-row">445                  <input446                    class="peach-field-value peach-field-value--edit"447                    placeholder="BASE32 or otpauth:// URI"448                    autocomplete="off"449                    value={totp()}450                    onInput={(e) => setTotp((e.currentTarget as HTMLInputElement).value)}451                  />452                </div>453              </div>454            </Show>455          </section>456        </Show>457458        {/* ── Card fields ── */}459        <Show when={selectedType() === "card"}>460          <section class="peach-fields">461            {/* Nickname */}462            <div class="peach-field-card">463              <span class="peach-label">nickname</span>464              <div class="peach-field-value-row">465                <input class="peach-field-value peach-field-value--edit" placeholder="Personal Visa"466                  autocomplete="off" value={cardNickname()}467                  onInput={(e) => setCardNickname((e.currentTarget as HTMLInputElement).value)} />468              </div>469            </div>470            {/* Cardholder name */}471            <div class="peach-field-card">472              <span class="peach-label">cardholder name</span>473              <div class="peach-field-value-row">474                <input class="peach-field-value peach-field-value--edit" placeholder="Jane Smith"475                  autocomplete="cc-name" value={cardholderName()}476                  onInput={(e) => setCardholderName((e.currentTarget as HTMLInputElement).value)} />477              </div>478            </div>479            {/* Card number */}480            <div class="peach-field-card">481              <span class="peach-label">482                card number483                <Show when={getCardNetwork(cardNumber())}>484                  <span class="peach-card-network-badge">{getCardNetwork(cardNumber())}</span>485                </Show>486              </span>487              <div class="peach-field-value-row">488                <input class="peach-field-value peach-field-value--edit peach-card-number"489                  placeholder="•••• •••• •••• ••••"490                  autocomplete="cc-number" inputmode="numeric" maxLength={19}491                  value={formatCardNumberDisplay(cardNumber(), cardNumberFocused())}492                  onFocus={() => setCardNumberFocused(true)}493                  onBlur={() => setCardNumberFocused(false)}494                  onInput={(e) => {495                    const raw = (e.currentTarget as HTMLInputElement).value.replace(/\s/g, '');496                    setCardNumber(raw);497                  }} />498              </div>499            </div>500            {/* Expiry */}501            <div class="peach-field-card">502              <span class="peach-label">expiry</span>503              <div class="peach-card-expiry-row">504                <input class="peach-field-value peach-field-value--edit" placeholder="MM"505                  autocomplete="cc-exp-month" inputmode="numeric" maxLength={2} style="width:56px"506                  value={expMonth()}507                  onInput={(e) => setExpMonth((e.currentTarget as HTMLInputElement).value)} />508                <span class="peach-card-expiry-sep">/</span>509                <input class="peach-field-value peach-field-value--edit" placeholder="YYYY"510                  autocomplete="cc-exp-year" inputmode="numeric" maxLength={4} style="width:72px"511                  value={expYear()}512                  onInput={(e) => setExpYear((e.currentTarget as HTMLInputElement).value)} />513              </div>514            </div>515            {/* CVC */}516            <div class="peach-field-card">517              <span class="peach-label">CVC</span>518              <div class="peach-field-value-row">519                <input class="peach-field-value peach-field-value--edit"520                  type={showCvc() ? "text" : "password"} placeholder="•••"521                  autocomplete="cc-csc" inputmode="numeric" maxLength={4}522                  value={cvc()}523                  onInput={(e) => setCvc((e.currentTarget as HTMLInputElement).value)} />524                <button class="peach-inline-action peach-password-toggle-btn" type="button"525                  onClick={() => setShowCvc(!showCvc())} aria-label={showCvc() ? "Hide CVC" : "Show CVC"}>526                  <Show when={showCvc()} fallback={527                    <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">528                      <path d="M1 5C1 5 2.8 1 7 1C11.2 1 13 5 13 5C13 5 11.2 9 7 9C2.8 9 1 5 1 5Z" />529                      <circle cx="7" cy="5" r="1.75" />530                    </svg>531                  }>532                    <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">533                      <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />534                    </svg>535                  </Show>536                </button>537              </div>538            </div>539          </section>540        </Show>541542        {/* ── Note content ── */}543        <Show when={selectedType() === "note"}>544          <div class="peach-note-body">545            <NoteEditorInline546              initialContent={notes()}547              onContentChange={setNotes}548              onReady={(el) => setNoteEditorEl(el)}549            />550          </div>551        </Show>552553        {/* ── Login notes ── */}554        <Show when={selectedType() === "login" && (showNotes() || notes())}>555          <div class="peach-field-card" style="margin:0 12px">556            <span class="peach-label">notes</span>557            <textarea558              class="peach-new-textarea"559              placeholder="anything worth remembering"560              value={notes()}561              onInput={(e) => setNotes((e.currentTarget as HTMLTextAreaElement).value)}562            />563          </div>564        </Show>565566        {/* ── "+ add" optional field links ── */}567        <div class="peach-entry-add-links">568          <Show when={selectedType() === "login" && !showTotp() && !totp()}>569            <button class="peach-add-field-link" type="button" onClick={() => setShowTotp(true)}>570              + add 2FA571            </button>572          </Show>573          <Show when={selectedType() === "login" && !showNotes() && !notes()}>574            <button class="peach-add-field-link" type="button" onClick={() => setShowNotes(true)}>575              + add notes576            </button>577          </Show>578        </div>579580        {/* ── Tags ── */}581        <div class="peach-field-card" style="margin:4px 12px 12px">582          <span class="peach-label">tags</span>583          <div class="peach-tag-input-wrap">584            <For each={tags()}>585              {(tag) => (586                <span class="peach-tag-chip">587                  {tag}588                  <button class="peach-tag-chip-remove" onClick={() => removeTag(tag)} aria-label={`Remove ${tag}`}>×</button>589                </span>590              )}591            </For>592            <input593              class="peach-tag-input"594              placeholder={tags().length === 0 ? "add tags…" : ""}595              value={tagInput()}596              onInput={(e) => { setTagInput((e.currentTarget as HTMLInputElement).value); setShowTagSuggestions(true); }}597              onKeyDown={handleTagKeyDown}598              onFocus={() => setShowTagSuggestions(true)}599              onBlur={() => setTimeout(() => setShowTagSuggestions(false), 150)}600              style="flex:1;min-width:80px;background:transparent;border:none;outline:none;color:var(--t1);font-size:12px;padding:2px;"601            />602          </div>603          <Show when={showTagSuggestions() && tagSuggestions().length > 0}>604            <div class="peach-tag-suggestions" style="margin-top:4px;">605              <For each={tagSuggestions()}>606                {(s) => (607                  <button608                    class="peach-tag-suggestion"609                    onMouseDown={() => { addTag(s); setTagInput(""); setShowTagSuggestions(false); }}610                  >{s}</button>611                )}612              </For>613            </div>614          </Show>615        </div>616617        <Show when={error()}>618          <p class="peach-hint peach-hint-error" style="margin:0 16px 12px">{error()}</p>619        </Show>620621      </div>622    </div>623  );624}625626interface IdentitySuggestListProps {627  suggestions: Array<{ value: string; count: number; type: "email" | "username" }>;628  filter: "all" | "username" | "email";629  onFilter: (f: "all" | "username" | "email") => void;630  onPick: (value: string) => void;631}632633function IdentitySuggestList(props: IdentitySuggestListProps) {634  return (635    <div class="peach-identity-suggestions">636      <div class="peach-identity-filter-row">637        <button class={`peach-identity-filter-pill ${props.filter === "all" ? "is-on" : ""}`} type="button" onMouseDown={() => props.onFilter("all")}>all</button>638        <button class={`peach-identity-filter-pill ${props.filter === "username" ? "is-on" : ""}`} type="button" onMouseDown={() => props.onFilter("username")}>user</button>639        <button class={`peach-identity-filter-pill ${props.filter === "email" ? "is-on" : ""}`} type="button" onMouseDown={() => props.onFilter("email")}>email</button>640      </div>641      <For each={props.suggestions}>642        {(s) => (643          <button class="peach-identity-suggestion" type="button" onMouseDown={() => props.onPick(s.value)}>644            <span class="peach-identity-suggestion-value">{s.value}</span>645            <span class={`peach-identity-suggestion-type peach-identity-suggestion-type--${s.type}`}>{s.type === "email" ? "@" : "u"}</span>646            <span class="peach-identity-suggestion-meta">{s.count}×</span>647          </button>648        )}649      </For>650    </div>651  );652}