Add.tsx

32.9 KB · raw

1import { createSignal, createMemo, createEffect, For, Show, onMount, onCleanup } 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  // Mutable refs for keyboard navigation between inputs and suggestion lists.40  // Not signals — focus state doesn't need to be reactive.41  let usernameInputEl: HTMLInputElement | undefined;42  let emailInputEl: HTMLInputElement | undefined;43  let usernameSuggestEl: HTMLDivElement | undefined;44  let emailSuggestEl: HTMLDivElement | undefined;45  let suppressIdentityOpen = false;4647  function openIdentityField(field: "username" | "email") {48    if (suppressIdentityOpen) { suppressIdentityOpen = false; return; }49    setActiveIdentityField(field);50  }5152  const allIdentities = createMemo(() => {53    const emailMap = new Map<string, number>();54    const usernameMap = new Map<string, number>();55    for (const entry of props.entries ?? []) {56      const uname = entry.username?.trim() ?? "";57      const em = (entry as VaultEntry & { email?: string }).email?.trim() ?? "";58      if (uname) usernameMap.set(uname, (usernameMap.get(uname) ?? 0) + 1);59      if (em) emailMap.set(em, (emailMap.get(em) ?? 0) + 1);60    }61    const result: Array<{ value: string; count: number; type: "email" | "username" }> = [];62    const seen = new Set<string>();63    for (const v of new Set([...emailMap.keys(), ...usernameMap.keys()])) {64      if (seen.has(v)) continue;65      seen.add(v);66      const ec = emailMap.get(v) ?? 0;67      const uc = usernameMap.get(v) ?? 0;68      result.push({ value: v, count: Math.max(ec, uc), type: ec >= uc ? "email" : "username" });69    }70    return result.sort((a, b) => b.count - a.count);71  });7273  const identitySuggestions = createMemo(() => {74    const field = activeIdentityField();75    if (!field) return [];76    const input = (field === "username" ? username() : email()).toLowerCase();77    const filter = identityFilter();78    return allIdentities()79      .filter(s => (filter === "all" || s.type === filter) && (!input || s.value.toLowerCase().includes(input)))80      .slice(0, 8);81  });8283  const isNoteEntry = () => props.entry?.type === "note";84  const isCardEntry = () => props.entry?.type === "card";85  const [selectedType, setSelectedType] = createSignal<"login" | "note" | "card">(86    isNoteEntry() ? "note" : isCardEntry() ? "card" : "login"87  );8889  // Card signals90  const [cardNickname, setCardNickname] = createSignal(props.entry?.cardNickname ?? "");91  const [cardholderName, setCardholderName] = createSignal(props.entry?.cardholderName ?? "");92  const [cardNumber, setCardNumber] = createSignal(props.entry?.cardNumber ?? "");93  const [expMonth, setExpMonth] = createSignal(props.entry?.expMonth ?? "");94  const [expYear, setExpYear] = createSignal(props.entry?.expYear ?? "");95  const [cvc, setCvc] = createSignal(props.entry?.cvc ?? "");96  const [showCvc, setShowCvc] = createSignal(false);97  const [cardNumberFocused, setCardNumberFocused] = createSignal(false);98  const [noteEditorEl, setNoteEditorEl] = createSignal<HTMLDivElement | null>(null);99100  function noteExec(cmd: string, val?: string) {101    document.execCommand(cmd, false, val);102    noteEditorEl()?.focus();103  }104105  function getCardNetwork(num: string): string {106    const n = num.replace(/\s/g, '');107    if (/^4/.test(n)) return 'Visa';108    if (/^5[1-5]|^2[2-7]/.test(n)) return 'Mastercard';109    if (/^3[47]/.test(n)) return 'Amex';110    if (/^6(?:011|5)/.test(n)) return 'Discover';111    return '';112  }113114  function formatCardNumberDisplay(raw: string, focused: boolean): string {115    const n = raw.replace(/\s/g, '');116    if (!focused && n.length >= 4) return '•••• •••• •••• ' + n.slice(-4);117    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(' '));118    return n.replace(/(\d{4})(?=\d)/g, '$1 ').trim();119  }120121  const canSave = () => {122    if (!title().trim()) return false;123    if (selectedType() === "login" && !password().trim()) return false;124    if (selectedType() === "card" && (!cardholderName().trim() || !cardNumber().trim())) return false;125    return true;126  };127128  const allTags = createMemo(() => {129    const tagSet = new Set<string>();130    for (const entry of props.entries ?? []) {131      for (const tag of entry.tags ?? []) tagSet.add(tag);132    }133    return Array.from(tagSet).sort();134  });135136  const tagSuggestions = createMemo(() => {137    const input = tagInput().toLowerCase().trim();138    if (!input) return [];139    return allTags().filter(t =>140      t.toLowerCase().includes(input) && !tags().includes(t)141    ).slice(0, 5);142  });143144  function addTag(tag: string) {145    const n = tag.toLowerCase().trim();146    if (!n || n.length > 32 || tags().includes(n) || tags().length >= 10) return;147    setTags([...tags(), n]);148  }149150  function removeTag(tag: string) {151    setTags(tags().filter(t => t !== tag));152  }153154  function handleTagKeyDown(e: KeyboardEvent) {155    if (e.key === "Enter" || e.key === ",") {156      e.preventDefault();157      if (tagInput()) { addTag(tagInput()); setTagInput(""); setShowTagSuggestions(false); }158    } else if (e.key === "Backspace" && !tagInput() && tags().length > 0) {159      removeTag(tags()[tags().length - 1]);160    }161  }162163  // Strength check164  createEffect(async () => {165    const pwd = password();166    if (pwd.length < 4) { setPasswordStrength(null); return; }167    try {168      if (!zxcvbnFn()) {169        const mod = await import("zxcvbn");170        setZxcvbnFn(() => mod.default);171      }172      const fn = zxcvbnFn();173      if (fn) setPasswordStrength(fn(pwd).score);174    } catch {175      setPasswordStrength(pwd.length >= 16 ? 3 : pwd.length >= 12 ? 2 : 1);176    }177  });178179  onMount(() => {180    const el = document.querySelector(".peach-new-title-input") as HTMLInputElement | null;181    if (el) el.focus();182    chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {183      const tabUrl = tabs[0]?.url;184      if (tabUrl && tabUrl.startsWith("http")) setCurrentTabUrl(tabUrl);185    });186    // Keep the vault alive while the form is open — the auto-lock alarm fires on187    // a timer and doesn't know the user is actively filling in a new entry.188    const keepAlive = setInterval(() => {189      chrome.runtime.sendMessage({ type: "TOUCH_AUTO_LOCK" }).catch(() => {});190    }, 30_000);191    onCleanup(() => clearInterval(keepAlive));192  });193194  function getNormalizedTotp(): string | undefined {195    const value = totp().trim();196    if (!value) return undefined;197    if (value.startsWith("otpauth://")) {198      const parsed = parseOtpauthURI(value);199      return parsed?.secret ?? value;200    }201    return value;202  }203204  async function handleSave() {205    if (!canSave()) {206      setError(207        selectedType() === "note"208          ? "title is required"209          : selectedType() === "card"210          ? "cardholder name and card number are required"211          : "title and password are required"212      );213      return;214    }215216    setSaving(true);217    setError("");218219    const payload: Record<string, unknown> = {220      type: selectedType(),221      title: title().trim(),222      notes: notes().trim() || undefined,223      tags: tags().length > 0 ? tags() : undefined,224    };225226    if (selectedType() === "login") {227      payload.username = username().trim();228      payload.email = email().trim() || undefined;229      payload.password = password();230      payload.url = url().trim();231      payload.totp = getNormalizedTotp();232    }233234    if (selectedType() === "card") {235      const raw = cardNumber().replace(/\s/g, '');236      payload.cardholderName = cardholderName().trim();237      payload.cardNumber = raw;238      payload.expMonth = expMonth().trim();239      payload.expYear = expYear().trim();240      payload.cvc = cvc().trim();241      payload.cardNickname = cardNickname().trim() || undefined;242      const n = raw;243      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';244      payload.last4 = raw.slice(-4);245    }246247    if (props.entry) {248      const res = await sendMessage({ type: "UPDATE_ENTRY", payload: { id: props.entry.id, patch: payload } });249      const result = res as { ok: boolean; error?: string };250      if (!result.ok) { setError(result.error ?? "could not update entry"); setSaving(false); return; }251      await props.onSave({ ...props.entry, ...payload } as VaultEntry);252      return;253    }254255    const res = await sendMessage({ type: "ADD_ENTRY", payload });256    const result = res as { ok: boolean; data?: VaultEntry; error?: string };257    if (!result.ok) {258      if (result.error === "vault locked") {259        setError("vault is locked — tap the lock icon to re-enter your master password");260      } else {261        setError(result.error ?? "could not save entry");262      }263      setSaving(false);264      return;265    }266    await props.onSave(result.data);267  }268269  const strengthLabel = () => {270    const s = passwordStrength();271    if (s === null) return null;272    return ["weak", "weak", "fair", "good", "strong"][s] as string;273  };274275  return (276    <div class="peach-overlay-view ov-enter-right" style="display:flex">277      <div class="peach-overlay-header">278        <button279          class="peach-btn peach-btn--ghost peach-btn--sm peach-overlay-action"280          onClick={props.onBack}281        >282          cancel283        </button>284        <span class="peach-overlay-title">285          {props.entry ? "edit entry" : "new entry"}286        </span>287        <button288          class="peach-btn peach-btn--accent peach-btn--sm peach-overlay-action"289          onClick={() => void handleSave()}290          disabled={saving() || !canSave()}291        >292          {saving() ? "saving…" : props.entry ? "save" : "create"}293        </button>294      </div>295296      {/* ── Body — mirrors Entry.tsx field-card structure ── */}297      <div class="peach-entry-body peach-entry-body--new">298299        {/* Type toggle */}300        <Show when={!props.entry}>301          <div class="peach-new-type-row">302            <button303              class={`peach-new-type-btn ${selectedType() === "login" ? "is-active" : ""}`}304              onClick={() => setSelectedType("login")}305            >login</button>306            <button307              class={`peach-new-type-btn ${selectedType() === "note" ? "is-active" : ""}`}308              onClick={() => setSelectedType("note")}309            >note</button>310            <button311              class={`peach-new-type-btn ${selectedType() === "card" ? "is-active" : ""}`}312              onClick={() => setSelectedType("card")}313            >card</button>314          </div>315        </Show>316317        {/* Note formatting toolbar — own row so it never overflows */}318        <Show when={selectedType() === "note"}>319          <Toolbar exec={noteExec} class="peach-note-toolbar-body" />320        </Show>321322        {/* Title */}323        <div class="peach-new-title-wrap">324          <input325            class="peach-new-title-input"326            placeholder={selectedType() === "note" ? "note title" : "entry name"}327            autocomplete="off"328            value={title()}329            onInput={(e) => setTitle((e.currentTarget as HTMLInputElement).value)}330            onKeyDown={(e) => { if (e.key === "Enter") void handleSave(); }}331          />332        </div>333334        {/* ── Login fields ── */}335        <Show when={selectedType() === "login"}>336          <div class="peach-domain">337            <div class="peach-domain-edit-row">338              <input339                class="peach-domain-value peach-domain-value--edit"340                placeholder="domain.com"341                autocomplete="off"342                value={url()}343                onInput={(e) => setUrl((e.currentTarget as HTMLInputElement).value)}344              />345              <Show when={currentTabUrl()}>346                <button347                  class="peach-domain-url-btn"348                  type="button"349                  onClick={() => setUrl(currentTabUrl())}350                  title="Insert current page URL"351                >352                  <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">353                    <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" />354                    <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" />355                  </svg>356                </button>357              </Show>358            </div>359          </div>360361          <section class="peach-fields">362            {/* Username */}363            <div class="peach-field-card">364              <span class="peach-label">username</span>365              <div class="peach-field-value-row">366                <input367                  ref={(el) => (usernameInputEl = el)}368                  class="peach-field-value peach-field-value--edit"369                  placeholder="johndoe"370                  autocomplete="off"371                  value={username()}372                  onInput={(e) => setUsername((e.currentTarget as HTMLInputElement).value)}373                  onFocus={() => openIdentityField("username")}374                  onBlur={(e) => {375                    if (usernameSuggestEl?.contains(e.relatedTarget as Node)) return;376                    setTimeout(() => setActiveIdentityField(null), 150);377                  }}378                  onKeyDown={(e) => {379                    if (activeIdentityField() !== "username") return;380                    if (e.key === "ArrowDown") {381                      e.preventDefault();382                      usernameSuggestEl?.querySelector<HTMLElement>(".peach-identity-suggestion")?.focus();383                    } else if (e.key === "Escape") {384                      e.preventDefault();385                      setActiveIdentityField(null);386                    }387                  }}388                />389              </div>390              <Show when={activeIdentityField() === "username" && allIdentities().length > 0}>391                <IdentitySuggestList392                  listRef={(el) => (usernameSuggestEl = el)}393                  inputRef={() => usernameInputEl}394                  suggestions={identitySuggestions()}395                  filter={identityFilter()}396                  onFilter={setIdentityFilter}397                  onPick={(v) => { setUsername(v); setActiveIdentityField(null); suppressIdentityOpen = true; usernameInputEl?.focus(); }}398                  onClose={() => { setActiveIdentityField(null); suppressIdentityOpen = true; usernameInputEl?.focus(); }}399                />400              </Show>401            </div>402403            {/* Email */}404            <div class="peach-field-card">405              <span class="peach-label">email</span>406              <div class="peach-field-value-row">407                <input408                  ref={(el) => (emailInputEl = el)}409                  class="peach-field-value peach-field-value--edit"410                  type="email"411                  placeholder="[email protected]"412                  autocomplete="off"413                  value={email()}414                  onInput={(e) => setEmail((e.currentTarget as HTMLInputElement).value)}415                  onFocus={() => openIdentityField("email")}416                  onBlur={(e) => {417                    if (emailSuggestEl?.contains(e.relatedTarget as Node)) return;418                    setTimeout(() => setActiveIdentityField(null), 150);419                  }}420                  onKeyDown={(e) => {421                    if (activeIdentityField() !== "email") return;422                    if (e.key === "ArrowDown") {423                      e.preventDefault();424                      emailSuggestEl?.querySelector<HTMLElement>(".peach-identity-suggestion")?.focus();425                    } else if (e.key === "Escape") {426                      e.preventDefault();427                      setActiveIdentityField(null);428                    }429                  }}430                />431              </div>432              <Show when={activeIdentityField() === "email" && allIdentities().length > 0}>433                <IdentitySuggestList434                  listRef={(el) => (emailSuggestEl = el)}435                  inputRef={() => emailInputEl}436                  suggestions={identitySuggestions()}437                  filter={identityFilter()}438                  onFilter={setIdentityFilter}439                  onPick={(v) => { setEmail(v); setActiveIdentityField(null); suppressIdentityOpen = true; emailInputEl?.focus(); }}440                  onClose={() => { setActiveIdentityField(null); suppressIdentityOpen = true; emailInputEl?.focus(); }}441                />442              </Show>443            </div>444445            {/* Password */}446            <div class="peach-field-card">447              <span class="peach-label">password</span>448              <div class="peach-password-edit-row">449                <input450                  class="peach-field-value peach-field-value--edit"451                  type={showPassword() ? "text" : "password"}452                  placeholder="password"453                  autocomplete="new-password"454                  value={password()}455                  onInput={(e) => setPassword((e.currentTarget as HTMLInputElement).value)}456                />457                <button458                  class="peach-inline-action peach-password-toggle-btn"459                  type="button"460                  onClick={() => setShowPassword(!showPassword())}461                  aria-label={showPassword() ? "Hide password" : "Show password"}462                >463                  <Show464                    when={showPassword()}465                    fallback={466                      <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">467                        <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" />468                        <circle cx="7" cy="5" r="1.75" />469                      </svg>470                    }471                  >472                    <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">473                      <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />474                      <path d="M3.5 5.5L2.5 7.5" />475                      <path d="M7 6.5V8.5" />476                      <path d="M10.5 5.5L11.5 7.5" />477                    </svg>478                  </Show>479                </button>480              </div>481482              {/* Strength */}483              <Show when={strengthLabel() !== null}>484                <div class="peach-strength-hairline">485                  <div class="peach-strength-hairline-track">486                    <div487                      class="peach-strength-hairline-fill"488                      style={{489                        width: ["25%","40%","60%","80%","100%"][passwordStrength()!],490                        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()!],491                      }}492                    />493                  </div>494                  <span class="peach-strength-label">{strengthLabel()}</span>495                </div>496              </Show>497498              {/* Generator toggle */}499              <button500                class="peach-generate-toggle-link"501                type="button"502                onClick={() => { setShowGenerator(!showGenerator()); }}503              >504                {showGenerator() ? "☰ hide generator" : "⚙ generate"}505              </button>506              <GeneratorDrawer507                compact508                open={showGenerator()}509                onUse={(pw) => { setPassword(pw); setShowGenerator(false); }}510              />511            </div>512513            {/* 2FA — optional */}514            <Show when={showTotp() || totp()}>515              <div class="peach-field-card">516                <span class="peach-label">2fa secret</span>517                <div class="peach-field-value-row">518                  <input519                    class="peach-field-value peach-field-value--edit"520                    placeholder="BASE32 or otpauth:// URI"521                    autocomplete="off"522                    value={totp()}523                    onInput={(e) => setTotp((e.currentTarget as HTMLInputElement).value)}524                  />525                </div>526              </div>527            </Show>528          </section>529        </Show>530531        {/* ── Card fields ── */}532        <Show when={selectedType() === "card"}>533          <section class="peach-fields">534            {/* Nickname */}535            <div class="peach-field-card">536              <span class="peach-label">nickname</span>537              <div class="peach-field-value-row">538                <input class="peach-field-value peach-field-value--edit" placeholder="Personal Visa"539                  autocomplete="off" value={cardNickname()}540                  onInput={(e) => setCardNickname((e.currentTarget as HTMLInputElement).value)} />541              </div>542            </div>543            {/* Cardholder name */}544            <div class="peach-field-card">545              <span class="peach-label">cardholder name</span>546              <div class="peach-field-value-row">547                <input class="peach-field-value peach-field-value--edit" placeholder="Jane Smith"548                  autocomplete="cc-name" value={cardholderName()}549                  onInput={(e) => setCardholderName((e.currentTarget as HTMLInputElement).value)} />550              </div>551            </div>552            {/* Card number */}553            <div class="peach-field-card">554              <span class="peach-label">555                card number556                <Show when={getCardNetwork(cardNumber())}>557                  <span class="peach-card-network-badge">{getCardNetwork(cardNumber())}</span>558                </Show>559              </span>560              <div class="peach-field-value-row">561                <input class="peach-field-value peach-field-value--edit peach-card-number"562                  placeholder="•••• •••• •••• ••••"563                  autocomplete="cc-number" inputmode="numeric" maxLength={19}564                  value={formatCardNumberDisplay(cardNumber(), cardNumberFocused())}565                  onFocus={() => setCardNumberFocused(true)}566                  onBlur={() => setCardNumberFocused(false)}567                  onInput={(e) => {568                    const raw = (e.currentTarget as HTMLInputElement).value.replace(/\s/g, '');569                    setCardNumber(raw);570                  }} />571              </div>572            </div>573            {/* Expiry */}574            <div class="peach-field-card">575              <span class="peach-label">expiry</span>576              <div class="peach-card-expiry-row">577                <input class="peach-field-value peach-field-value--edit" placeholder="MM"578                  autocomplete="cc-exp-month" inputmode="numeric" maxLength={2} style="width:56px"579                  value={expMonth()}580                  onInput={(e) => setExpMonth((e.currentTarget as HTMLInputElement).value)} />581                <span class="peach-card-expiry-sep">/</span>582                <input class="peach-field-value peach-field-value--edit" placeholder="YYYY"583                  autocomplete="cc-exp-year" inputmode="numeric" maxLength={4} style="width:72px"584                  value={expYear()}585                  onInput={(e) => setExpYear((e.currentTarget as HTMLInputElement).value)} />586              </div>587            </div>588            {/* CVC */}589            <div class="peach-field-card">590              <span class="peach-label">CVC</span>591              <div class="peach-field-value-row">592                <input class="peach-field-value peach-field-value--edit"593                  type={showCvc() ? "text" : "password"} placeholder="•••"594                  autocomplete="cc-csc" inputmode="numeric" maxLength={4}595                  value={cvc()}596                  onInput={(e) => setCvc((e.currentTarget as HTMLInputElement).value)} />597                <button class="peach-inline-action peach-password-toggle-btn" type="button"598                  onClick={() => setShowCvc(!showCvc())} aria-label={showCvc() ? "Hide CVC" : "Show CVC"}>599                  <Show when={showCvc()} fallback={600                    <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">601                      <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" />602                      <circle cx="7" cy="5" r="1.75" />603                    </svg>604                  }>605                    <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">606                      <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />607                    </svg>608                  </Show>609                </button>610              </div>611            </div>612          </section>613        </Show>614615        {/* ── Note content ── */}616        <Show when={selectedType() === "note"}>617          <div class="peach-note-body">618            <NoteEditorInline619              initialContent={notes()}620              onContentChange={setNotes}621              onReady={(el) => setNoteEditorEl(el)}622            />623          </div>624        </Show>625626        {/* ── Login notes ── */}627        <Show when={selectedType() === "login" && (showNotes() || notes())}>628          <div class="peach-field-card" style="margin:0 12px">629            <span class="peach-label">notes</span>630            <textarea631              class="peach-new-textarea"632              placeholder="anything worth remembering"633              value={notes()}634              onInput={(e) => setNotes((e.currentTarget as HTMLTextAreaElement).value)}635            />636          </div>637        </Show>638639        {/* ── "+ add" optional field links ── */}640        <div class="peach-entry-add-links">641          <Show when={selectedType() === "login" && !showTotp() && !totp()}>642            <button class="peach-add-field-link" type="button" onClick={() => setShowTotp(true)}>643              + add 2FA644            </button>645          </Show>646          <Show when={selectedType() === "login" && !showNotes() && !notes()}>647            <button class="peach-add-field-link" type="button" onClick={() => setShowNotes(true)}>648              + add notes649            </button>650          </Show>651        </div>652653        {/* ── Tags ── */}654        <div class="peach-field-card" style="margin:4px 12px 12px">655          <span class="peach-label">tags</span>656          <div class="peach-tag-input-wrap">657            <For each={tags()}>658              {(tag) => (659                <span class="peach-tag-chip">660                  {tag}661                  <button class="peach-tag-chip-remove" onClick={() => removeTag(tag)} aria-label={`Remove ${tag}`}>×</button>662                </span>663              )}664            </For>665            <input666              class="peach-tag-input"667              placeholder={tags().length === 0 ? "add tags…" : ""}668              value={tagInput()}669              onInput={(e) => { setTagInput((e.currentTarget as HTMLInputElement).value); setShowTagSuggestions(true); }}670              onKeyDown={handleTagKeyDown}671              onFocus={() => setShowTagSuggestions(true)}672              onBlur={() => setTimeout(() => setShowTagSuggestions(false), 150)}673              style="flex:1;min-width:80px;background:transparent;border:none;outline:none;color:var(--t1);font-size:12px;padding:2px;"674            />675          </div>676          <Show when={showTagSuggestions() && tagSuggestions().length > 0}>677            <div class="peach-tag-suggestions" style="margin-top:4px;">678              <For each={tagSuggestions()}>679                {(s) => (680                  <button681                    class="peach-tag-suggestion"682                    onMouseDown={() => { addTag(s); setTagInput(""); setShowTagSuggestions(false); }}683                  >{s}</button>684                )}685              </For>686            </div>687          </Show>688        </div>689690        <Show when={error()}>691          <p class="peach-hint peach-hint-error" style="margin:0 16px 12px">{error()}</p>692        </Show>693694      </div>695    </div>696  );697}698699interface IdentitySuggestListProps {700  suggestions: Array<{ value: string; count: number; type: "email" | "username" }>;701  filter: "all" | "username" | "email";702  onFilter: (f: "all" | "username" | "email") => void;703  onPick: (value: string) => void;704  onClose?: () => void;705  listRef?: (el: HTMLDivElement) => void;706  inputRef?: () => HTMLInputElement | undefined;707}708709function IdentitySuggestList(props: IdentitySuggestListProps) {710  const [collapsed, setCollapsed] = createSignal(false);711  const ChevronIcon = () => (712    <svg width="10" height="6" viewBox="0 0 10 6" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">713      <path d="M1 1L5 5L9 1" />714    </svg>715  );716717  function handleRowKeyDown(e: KeyboardEvent, value: string) {718    const row = e.currentTarget as HTMLElement;719    if (e.key === "ArrowDown") {720      e.preventDefault();721      (row.nextElementSibling as HTMLElement | null)?.focus();722    } else if (e.key === "ArrowUp") {723      e.preventDefault();724      const prev = row.previousElementSibling as HTMLElement | null;725      if (prev) prev.focus();726      else props.inputRef?.()?.focus(); // back to input, list stays open727    } else if (e.key === "Enter" || e.key === " ") {728      e.preventDefault();729      props.onPick(value);730    } else if (e.key === "Escape") {731      e.preventDefault();732      props.onClose?.();733    }734  }735736  return (737    <div class="peach-identity-suggestions" ref={props.listRef}>738      <div class="peach-identity-filter-row">739        <button class={`peach-identity-filter-pill ${props.filter === "all" ? "is-on" : ""}`} type="button" onMouseDown={(e) => { e.preventDefault(); props.onFilter("all"); }}>all</button>740        <button class={`peach-identity-filter-pill ${props.filter === "username" ? "is-on" : ""}`} type="button" onMouseDown={(e) => { e.preventDefault(); props.onFilter("username"); }}>user</button>741        <button class={`peach-identity-filter-pill ${props.filter === "email" ? "is-on" : ""}`} type="button" onMouseDown={(e) => { e.preventDefault(); props.onFilter("email"); }}>email</button>742        <button743          class={`peach-identity-collapse-btn${collapsed() ? " is-collapsed" : ""}`}744          type="button"745          onMouseDown={(e) => { e.preventDefault(); setCollapsed(c => !c); }}746          aria-label={collapsed() ? "Show suggestions" : "Hide suggestions"}747        >748          <ChevronIcon />749        </button>750      </div>751      <div class={`peach-identity-rows${collapsed() ? " is-collapsed" : ""}`}>752        <div class="peach-identity-rows-inner">753          <For each={props.suggestions}>754            {(s) => (755              <button756                class="peach-identity-suggestion"757                type="button"758                onMouseDown={(e) => { e.preventDefault(); props.onPick(s.value); }}759                onKeyDown={(e) => handleRowKeyDown(e, s.value)}760              >761                <span class="peach-identity-suggestion-value">{s.value}</span>762                <span class={`peach-identity-suggestion-type peach-identity-suggestion-type--${s.type}`}>{s.type === "email" ? "@" : "u"}</span>763                <span class="peach-identity-suggestion-meta">{s.count}×</span>764              </button>765            )}766          </For>767        </div>768      </div>769    </div>770  );771}