Add.tsx

29.1 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 [currentTabUrl, setCurrentTabUrl] = createSignal("");37  const [identityFilter, setIdentityFilter] = createSignal<"all" | "username" | "email">("all");3839  const allIdentities = createMemo(() => {40    const emailMap = new Map<string, number>();41    const usernameMap = new Map<string, number>();42    for (const entry of props.entries ?? []) {43      const uname = entry.username?.trim() ?? "";44      const em = (entry as VaultEntry & { email?: string }).email?.trim() ?? "";45      if (uname) usernameMap.set(uname, (usernameMap.get(uname) ?? 0) + 1);46      if (em) emailMap.set(em, (emailMap.get(em) ?? 0) + 1);47    }48    const result: Array<{ value: string; count: number; type: "email" | "username" }> = [];49    const seen = new Set<string>();50    for (const v of new Set([...emailMap.keys(), ...usernameMap.keys()])) {51      if (seen.has(v)) continue;52      seen.add(v);53      const ec = emailMap.get(v) ?? 0;54      const uc = usernameMap.get(v) ?? 0;55      result.push({ value: v, count: Math.max(ec, uc), type: ec >= uc ? "email" : "username" });56    }57    return result.sort((a, b) => b.count - a.count);58  });5960  const identitySuggestions = createMemo(() => {61    const field = activeIdentityField();62    if (!field) return [];63    const input = (field === "username" ? username() : email()).toLowerCase();64    const filter = identityFilter();65    return allIdentities()66      .filter(s => (filter === "all" || s.type === filter) && (!input || s.value.toLowerCase().includes(input)))67      .slice(0, 8);68  });6970  const isNoteEntry = () => props.entry?.type === "note";71  const isCardEntry = () => props.entry?.type === "card";72  const [selectedType, setSelectedType] = createSignal<"login" | "note" | "card">(73    isNoteEntry() ? "note" : isCardEntry() ? "card" : "login"74  );7576  // Card signals77  const [cardNickname, setCardNickname] = createSignal(props.entry?.cardNickname ?? "");78  const [cardholderName, setCardholderName] = createSignal(props.entry?.cardholderName ?? "");79  const [cardNumber, setCardNumber] = createSignal(props.entry?.cardNumber ?? "");80  const [expMonth, setExpMonth] = createSignal(props.entry?.expMonth ?? "");81  const [expYear, setExpYear] = createSignal(props.entry?.expYear ?? "");82  const [cvc, setCvc] = createSignal(props.entry?.cvc ?? "");83  const [showCvc, setShowCvc] = createSignal(false);84  const [cardNumberFocused, setCardNumberFocused] = createSignal(false);85  const [noteEditorEl, setNoteEditorEl] = createSignal<HTMLDivElement | null>(null);8687  function noteExec(cmd: string, val?: string) {88    document.execCommand(cmd, false, val);89    noteEditorEl()?.focus();90  }9192  function getCardNetwork(num: string): string {93    const n = num.replace(/\s/g, '');94    if (/^4/.test(n)) return 'Visa';95    if (/^5[1-5]|^2[2-7]/.test(n)) return 'Mastercard';96    if (/^3[47]/.test(n)) return 'Amex';97    if (/^6(?:011|5)/.test(n)) return 'Discover';98    return '';99  }100101  function formatCardNumberDisplay(raw: string, focused: boolean): string {102    const n = raw.replace(/\s/g, '');103    if (!focused && n.length >= 4) return '•••• •••• •••• ' + n.slice(-4);104    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(' '));105    return n.replace(/(\d{4})(?=\d)/g, '$1 ').trim();106  }107108  const canSave = () => {109    if (!title().trim()) return false;110    if (selectedType() === "login" && !password().trim()) return false;111    if (selectedType() === "card" && (!cardholderName().trim() || !cardNumber().trim())) return false;112    return true;113  };114115  const allTags = createMemo(() => {116    const tagSet = new Set<string>();117    for (const entry of props.entries ?? []) {118      for (const tag of entry.tags ?? []) tagSet.add(tag);119    }120    return Array.from(tagSet).sort();121  });122123  const tagSuggestions = createMemo(() => {124    const input = tagInput().toLowerCase().trim();125    if (!input) return [];126    return allTags().filter(t =>127      t.toLowerCase().includes(input) && !tags().includes(t)128    ).slice(0, 5);129  });130131  function addTag(tag: string) {132    const n = tag.toLowerCase().trim();133    if (!n || n.length > 32 || tags().includes(n) || tags().length >= 10) return;134    setTags([...tags(), n]);135  }136137  function removeTag(tag: string) {138    setTags(tags().filter(t => t !== tag));139  }140141  function handleTagKeyDown(e: KeyboardEvent) {142    if (e.key === "Enter" || e.key === ",") {143      e.preventDefault();144      if (tagInput()) { addTag(tagInput()); setTagInput(""); setShowTagSuggestions(false); }145    } else if (e.key === "Backspace" && !tagInput() && tags().length > 0) {146      removeTag(tags()[tags().length - 1]);147    }148  }149150  // Strength check151  createEffect(async () => {152    const pwd = password();153    if (pwd.length < 4) { setPasswordStrength(null); return; }154    try {155      if (!zxcvbnFn()) {156        const mod = await import("zxcvbn");157        setZxcvbnFn(() => mod.default);158      }159      const fn = zxcvbnFn();160      if (fn) setPasswordStrength(fn(pwd).score);161    } catch {162      setPasswordStrength(pwd.length >= 16 ? 3 : pwd.length >= 12 ? 2 : 1);163    }164  });165166  onMount(() => {167    const el = document.querySelector(".peach-new-title-input") as HTMLInputElement | null;168    if (el) el.focus();169    chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {170      const tabUrl = tabs[0]?.url;171      if (tabUrl && tabUrl.startsWith("http")) setCurrentTabUrl(tabUrl);172    });173  });174175  function getNormalizedTotp(): string | undefined {176    const value = totp().trim();177    if (!value) return undefined;178    if (value.startsWith("otpauth://")) {179      const parsed = parseOtpauthURI(value);180      return parsed?.secret ?? value;181    }182    return value;183  }184185  async function handleSave() {186    if (!canSave()) {187      setError(188        selectedType() === "note"189          ? "title is required"190          : selectedType() === "card"191          ? "cardholder name and card number are required"192          : "title and password are required"193      );194      return;195    }196197    setSaving(true);198    setError("");199200    const payload: Record<string, unknown> = {201      type: selectedType(),202      title: title().trim(),203      notes: notes().trim() || undefined,204      tags: tags().length > 0 ? tags() : undefined,205    };206207    if (selectedType() === "login") {208      payload.username = username().trim();209      payload.email = email().trim() || undefined;210      payload.password = password();211      payload.url = url().trim();212      payload.totp = getNormalizedTotp();213    }214215    if (selectedType() === "card") {216      const raw = cardNumber().replace(/\s/g, '');217      payload.cardholderName = cardholderName().trim();218      payload.cardNumber = raw;219      payload.expMonth = expMonth().trim();220      payload.expYear = expYear().trim();221      payload.cvc = cvc().trim();222      payload.cardNickname = cardNickname().trim() || undefined;223      const n = raw;224      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';225      payload.last4 = raw.slice(-4);226    }227228    if (props.entry) {229      const res = await sendMessage({ type: "UPDATE_ENTRY", payload: { id: props.entry.id, patch: payload } });230      const result = res as { ok: boolean; error?: string };231      if (!result.ok) { setError(result.error ?? "could not update entry"); setSaving(false); return; }232      await props.onSave({ ...props.entry, ...payload } as VaultEntry);233      return;234    }235236    const res = await sendMessage({ type: "ADD_ENTRY", payload });237    const result = res as { ok: boolean; data?: VaultEntry; error?: string };238    if (!result.ok) {239      if (result.error === "vault locked") {240        setError("vault is locked — tap the lock icon to re-enter your master password");241      } else {242        setError(result.error ?? "could not save entry");243      }244      setSaving(false);245      return;246    }247    await props.onSave(result.data);248  }249250  const strengthLabel = () => {251    const s = passwordStrength();252    if (s === null) return null;253    return ["weak", "weak", "fair", "good", "strong"][s] as string;254  };255256  return (257    <div class="peach-overlay-view ov-enter-right" style="display:flex">258      <div class="peach-overlay-header">259        <button260          class="peach-btn peach-btn--ghost peach-btn--sm peach-overlay-action"261          onClick={props.onBack}262        >263          cancel264        </button>265        <span class="peach-overlay-title">266          {props.entry ? "edit entry" : "new entry"}267        </span>268        <button269          class="peach-btn peach-btn--accent peach-btn--sm peach-overlay-action"270          onClick={() => void handleSave()}271          disabled={saving() || !canSave()}272        >273          {saving() ? "saving…" : props.entry ? "save" : "create"}274        </button>275      </div>276277      {/* ── Body — mirrors Entry.tsx field-card structure ── */}278      <div class="peach-entry-body peach-entry-body--new">279280        {/* Type toggle */}281        <Show when={!props.entry}>282          <div class="peach-new-type-row">283            <button284              class={`peach-new-type-btn ${selectedType() === "login" ? "is-active" : ""}`}285              onClick={() => setSelectedType("login")}286            >login</button>287            <button288              class={`peach-new-type-btn ${selectedType() === "note" ? "is-active" : ""}`}289              onClick={() => setSelectedType("note")}290            >note</button>291            <button292              class={`peach-new-type-btn ${selectedType() === "card" ? "is-active" : ""}`}293              onClick={() => setSelectedType("card")}294            >card</button>295          </div>296        </Show>297298        {/* Note formatting toolbar — own row so it never overflows */}299        <Show when={selectedType() === "note"}>300          <Toolbar exec={noteExec} class="peach-note-toolbar-body" />301        </Show>302303        {/* Title */}304        <div class="peach-new-title-wrap">305          <input306            class="peach-new-title-input"307            placeholder={selectedType() === "note" ? "note title" : "entry name"}308            autocomplete="off"309            value={title()}310            onInput={(e) => setTitle((e.currentTarget as HTMLInputElement).value)}311            onKeyDown={(e) => { if (e.key === "Enter") void handleSave(); }}312          />313        </div>314315        {/* ── Login fields ── */}316        <Show when={selectedType() === "login"}>317          <div class="peach-domain">318            <div class="peach-domain-edit-row">319              <input320                class="peach-domain-value peach-domain-value--edit"321                placeholder="domain.com"322                autocomplete="off"323                value={url()}324                onInput={(e) => setUrl((e.currentTarget as HTMLInputElement).value)}325              />326              <Show when={currentTabUrl()}>327                <button328                  class="peach-domain-url-btn"329                  type="button"330                  onClick={() => setUrl(currentTabUrl())}331                  title="Insert current page URL"332                >333                  <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">334                    <path d="M4.5 7.5a3 3 0 0 0 4.243 0L10.5 5.75a3 3 0 0 0-4.243-4.243L5.5 2.26" />335                    <path d="M7.5 4.5a3 3 0 0 0-4.243 0L1.5 6.25a3 3 0 0 0 4.243 4.243L6.5 9.74" />336                  </svg>337                </button>338              </Show>339            </div>340          </div>341342          <section class="peach-fields">343            {/* Username */}344            <div class="peach-field-card">345              <span class="peach-label">username</span>346              <div class="peach-field-value-row">347                <input348                  class="peach-field-value peach-field-value--edit"349                  placeholder="johndoe"350                  autocomplete="off"351                  value={username()}352                  onInput={(e) => setUsername((e.currentTarget as HTMLInputElement).value)}353                  onFocus={() => setActiveIdentityField("username")}354                  onBlur={() => setTimeout(() => setActiveIdentityField(null), 150)}355                />356              </div>357              <Show when={activeIdentityField() === "username" && identitySuggestions().length > 0}>358                <IdentitySuggestList359                  suggestions={identitySuggestions()}360                  filter={identityFilter()}361                  onFilter={setIdentityFilter}362                  onPick={(v) => { setUsername(v); setActiveIdentityField(null); }}363                />364              </Show>365            </div>366367            {/* Email */}368            <div class="peach-field-card">369              <span class="peach-label">email</span>370              <div class="peach-field-value-row">371                <input372                  class="peach-field-value peach-field-value--edit"373                  type="email"374                  placeholder="[email protected]"375                  autocomplete="off"376                  value={email()}377                  onInput={(e) => setEmail((e.currentTarget as HTMLInputElement).value)}378                  onFocus={() => setActiveIdentityField("email")}379                  onBlur={() => setTimeout(() => setActiveIdentityField(null), 150)}380                />381              </div>382              <Show when={activeIdentityField() === "email" && identitySuggestions().length > 0}>383                <IdentitySuggestList384                  suggestions={identitySuggestions()}385                  filter={identityFilter()}386                  onFilter={setIdentityFilter}387                  onPick={(v) => { setEmail(v); setActiveIdentityField(null); }}388                />389              </Show>390            </div>391392            {/* Password */}393            <div class="peach-field-card">394              <span class="peach-label">password</span>395              <div class="peach-password-edit-row">396                <input397                  class="peach-field-value peach-field-value--edit"398                  type={showPassword() ? "text" : "password"}399                  placeholder="password"400                  autocomplete="new-password"401                  value={password()}402                  onInput={(e) => setPassword((e.currentTarget as HTMLInputElement).value)}403                />404                <button405                  class="peach-inline-action peach-password-toggle-btn"406                  type="button"407                  onClick={() => setShowPassword(!showPassword())}408                  aria-label={showPassword() ? "Hide password" : "Show password"}409                >410                  <Show411                    when={showPassword()}412                    fallback={413                      <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">414                        <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" />415                        <circle cx="7" cy="5" r="1.75" />416                      </svg>417                    }418                  >419                    <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">420                      <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />421                      <path d="M3.5 5.5L2.5 7.5" />422                      <path d="M7 6.5V8.5" />423                      <path d="M10.5 5.5L11.5 7.5" />424                    </svg>425                  </Show>426                </button>427              </div>428429              {/* Strength */}430              <Show when={strengthLabel() !== null}>431                <div class="peach-strength-hairline">432                  <div class="peach-strength-hairline-track">433                    <div434                      class="peach-strength-hairline-fill"435                      style={{436                        width: ["25%","40%","60%","80%","100%"][passwordStrength()!],437                        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()!],438                      }}439                    />440                  </div>441                  <span class="peach-strength-label">{strengthLabel()}</span>442                </div>443              </Show>444445              {/* Generator toggle */}446              <button447                class="peach-generate-toggle-link"448                type="button"449                onClick={() => { setShowGenerator(!showGenerator()); }}450              >451                {showGenerator() ? "☰ hide generator" : "⚙ generate"}452              </button>453              <GeneratorDrawer454                compact455                open={showGenerator()}456                onUse={(pw) => { setPassword(pw); setShowGenerator(false); }}457              />458            </div>459460            {/* 2FA — optional */}461            <Show when={showTotp() || totp()}>462              <div class="peach-field-card">463                <span class="peach-label">2fa secret</span>464                <div class="peach-field-value-row">465                  <input466                    class="peach-field-value peach-field-value--edit"467                    placeholder="BASE32 or otpauth:// URI"468                    autocomplete="off"469                    value={totp()}470                    onInput={(e) => setTotp((e.currentTarget as HTMLInputElement).value)}471                  />472                </div>473              </div>474            </Show>475          </section>476        </Show>477478        {/* ── Card fields ── */}479        <Show when={selectedType() === "card"}>480          <section class="peach-fields">481            {/* Nickname */}482            <div class="peach-field-card">483              <span class="peach-label">nickname</span>484              <div class="peach-field-value-row">485                <input class="peach-field-value peach-field-value--edit" placeholder="Personal Visa"486                  autocomplete="off" value={cardNickname()}487                  onInput={(e) => setCardNickname((e.currentTarget as HTMLInputElement).value)} />488              </div>489            </div>490            {/* Cardholder name */}491            <div class="peach-field-card">492              <span class="peach-label">cardholder name</span>493              <div class="peach-field-value-row">494                <input class="peach-field-value peach-field-value--edit" placeholder="Jane Smith"495                  autocomplete="cc-name" value={cardholderName()}496                  onInput={(e) => setCardholderName((e.currentTarget as HTMLInputElement).value)} />497              </div>498            </div>499            {/* Card number */}500            <div class="peach-field-card">501              <span class="peach-label">502                card number503                <Show when={getCardNetwork(cardNumber())}>504                  <span class="peach-card-network-badge">{getCardNetwork(cardNumber())}</span>505                </Show>506              </span>507              <div class="peach-field-value-row">508                <input class="peach-field-value peach-field-value--edit peach-card-number"509                  placeholder="•••• •••• •••• ••••"510                  autocomplete="cc-number" inputmode="numeric" maxLength={19}511                  value={formatCardNumberDisplay(cardNumber(), cardNumberFocused())}512                  onFocus={() => setCardNumberFocused(true)}513                  onBlur={() => setCardNumberFocused(false)}514                  onInput={(e) => {515                    const raw = (e.currentTarget as HTMLInputElement).value.replace(/\s/g, '');516                    setCardNumber(raw);517                  }} />518              </div>519            </div>520            {/* Expiry */}521            <div class="peach-field-card">522              <span class="peach-label">expiry</span>523              <div class="peach-card-expiry-row">524                <input class="peach-field-value peach-field-value--edit" placeholder="MM"525                  autocomplete="cc-exp-month" inputmode="numeric" maxLength={2} style="width:56px"526                  value={expMonth()}527                  onInput={(e) => setExpMonth((e.currentTarget as HTMLInputElement).value)} />528                <span class="peach-card-expiry-sep">/</span>529                <input class="peach-field-value peach-field-value--edit" placeholder="YYYY"530                  autocomplete="cc-exp-year" inputmode="numeric" maxLength={4} style="width:72px"531                  value={expYear()}532                  onInput={(e) => setExpYear((e.currentTarget as HTMLInputElement).value)} />533              </div>534            </div>535            {/* CVC */}536            <div class="peach-field-card">537              <span class="peach-label">CVC</span>538              <div class="peach-field-value-row">539                <input class="peach-field-value peach-field-value--edit"540                  type={showCvc() ? "text" : "password"} placeholder="•••"541                  autocomplete="cc-csc" inputmode="numeric" maxLength={4}542                  value={cvc()}543                  onInput={(e) => setCvc((e.currentTarget as HTMLInputElement).value)} />544                <button class="peach-inline-action peach-password-toggle-btn" type="button"545                  onClick={() => setShowCvc(!showCvc())} aria-label={showCvc() ? "Hide CVC" : "Show CVC"}>546                  <Show when={showCvc()} fallback={547                    <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">548                      <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" />549                      <circle cx="7" cy="5" r="1.75" />550                    </svg>551                  }>552                    <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">553                      <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />554                    </svg>555                  </Show>556                </button>557              </div>558            </div>559          </section>560        </Show>561562        {/* ── Note content ── */}563        <Show when={selectedType() === "note"}>564          <div class="peach-note-body">565            <NoteEditorInline566              initialContent={notes()}567              onContentChange={setNotes}568              onReady={(el) => setNoteEditorEl(el)}569            />570          </div>571        </Show>572573        {/* ── Login notes ── */}574        <Show when={selectedType() === "login" && (showNotes() || notes())}>575          <div class="peach-field-card" style="margin:0 12px">576            <span class="peach-label">notes</span>577            <textarea578              class="peach-new-textarea"579              placeholder="anything worth remembering"580              value={notes()}581              onInput={(e) => setNotes((e.currentTarget as HTMLTextAreaElement).value)}582            />583          </div>584        </Show>585586        {/* ── "+ add" optional field links ── */}587        <div class="peach-entry-add-links">588          <Show when={selectedType() === "login" && !showTotp() && !totp()}>589            <button class="peach-add-field-link" type="button" onClick={() => setShowTotp(true)}>590              + add 2FA591            </button>592          </Show>593          <Show when={selectedType() === "login" && !showNotes() && !notes()}>594            <button class="peach-add-field-link" type="button" onClick={() => setShowNotes(true)}>595              + add notes596            </button>597          </Show>598        </div>599600        {/* ── Tags ── */}601        <div class="peach-field-card" style="margin:4px 12px 12px">602          <span class="peach-label">tags</span>603          <div class="peach-tag-input-wrap">604            <For each={tags()}>605              {(tag) => (606                <span class="peach-tag-chip">607                  {tag}608                  <button class="peach-tag-chip-remove" onClick={() => removeTag(tag)} aria-label={`Remove ${tag}`}>×</button>609                </span>610              )}611            </For>612            <input613              class="peach-tag-input"614              placeholder={tags().length === 0 ? "add tags…" : ""}615              value={tagInput()}616              onInput={(e) => { setTagInput((e.currentTarget as HTMLInputElement).value); setShowTagSuggestions(true); }}617              onKeyDown={handleTagKeyDown}618              onFocus={() => setShowTagSuggestions(true)}619              onBlur={() => setTimeout(() => setShowTagSuggestions(false), 150)}620              style="flex:1;min-width:80px;background:transparent;border:none;outline:none;color:var(--t1);font-size:12px;padding:2px;"621            />622          </div>623          <Show when={showTagSuggestions() && tagSuggestions().length > 0}>624            <div class="peach-tag-suggestions" style="margin-top:4px;">625              <For each={tagSuggestions()}>626                {(s) => (627                  <button628                    class="peach-tag-suggestion"629                    onMouseDown={() => { addTag(s); setTagInput(""); setShowTagSuggestions(false); }}630                  >{s}</button>631                )}632              </For>633            </div>634          </Show>635        </div>636637        <Show when={error()}>638          <p class="peach-hint peach-hint-error" style="margin:0 16px 12px">{error()}</p>639        </Show>640641      </div>642    </div>643  );644}645646interface IdentitySuggestListProps {647  suggestions: Array<{ value: string; count: number; type: "email" | "username" }>;648  filter: "all" | "username" | "email";649  onFilter: (f: "all" | "username" | "email") => void;650  onPick: (value: string) => void;651}652653function IdentitySuggestList(props: IdentitySuggestListProps) {654  const [collapsed, setCollapsed] = createSignal(false);655  const ChevronIcon = () => (656    <svg width="10" height="6" viewBox="0 0 10 6" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">657      <path d="M1 1L5 5L9 1" />658    </svg>659  );660  return (661    <div class="peach-identity-suggestions">662      <div class="peach-identity-filter-row">663        <button class={`peach-identity-filter-pill ${props.filter === "all" ? "is-on" : ""}`} type="button" onMouseDown={() => props.onFilter("all")}>all</button>664        <button class={`peach-identity-filter-pill ${props.filter === "username" ? "is-on" : ""}`} type="button" onMouseDown={() => props.onFilter("username")}>user</button>665        <button class={`peach-identity-filter-pill ${props.filter === "email" ? "is-on" : ""}`} type="button" onMouseDown={() => props.onFilter("email")}>email</button>666        <button667          class={`peach-identity-collapse-btn${collapsed() ? " is-collapsed" : ""}`}668          type="button"669          onMouseDown={(e) => { e.preventDefault(); setCollapsed(c => !c); }}670          aria-label={collapsed() ? "Show suggestions" : "Hide suggestions"}671        >672          <ChevronIcon />673        </button>674      </div>675      <div class={`peach-identity-rows${collapsed() ? " is-collapsed" : ""}`}>676        <div class="peach-identity-rows-inner">677          <For each={props.suggestions}>678            {(s) => (679              <button class="peach-identity-suggestion" type="button" onMouseDown={() => props.onPick(s.value)}>680                <span class="peach-identity-suggestion-value">{s.value}</span>681                <span class={`peach-identity-suggestion-type peach-identity-suggestion-type--${s.type}`}>{s.type === "email" ? "@" : "u"}</span>682                <span class="peach-identity-suggestion-meta">{s.count}×</span>683              </button>684            )}685          </For>686        </div>687      </div>688    </div>689  );690}