Add.tsx

31.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("");3738  // Mutable refs for keyboard navigation between inputs and suggestion lists.39  // Not signals — focus state doesn't need to be reactive.40  let usernameInputEl: HTMLInputElement | undefined;41  let emailInputEl: HTMLInputElement | undefined;42  let usernameSuggestEl: HTMLDivElement | undefined;43  let emailSuggestEl: HTMLDivElement | undefined;44  let suppressIdentityOpen = false;4546  function openIdentityField(field: "username" | "email") {47    if (suppressIdentityOpen) { suppressIdentityOpen = false; return; }48    setActiveIdentityField(field);49  }5051  const allIdentities = createMemo(() => {52    const emailMap = new Map<string, number>();53    const usernameMap = new Map<string, number>();54    for (const entry of props.entries ?? []) {55      const uname = entry.username?.trim() ?? "";56      const em = (entry as VaultEntry & { email?: string }).email?.trim() ?? "";57      if (uname) usernameMap.set(uname, (usernameMap.get(uname) ?? 0) + 1);58      if (em) emailMap.set(em, (emailMap.get(em) ?? 0) + 1);59    }60    const result: Array<{ value: string; count: number; type: "email" | "username" }> = [];61    const seen = new Set<string>();62    for (const v of new Set([...emailMap.keys(), ...usernameMap.keys()])) {63      if (seen.has(v)) continue;64      seen.add(v);65      const ec = emailMap.get(v) ?? 0;66      const uc = usernameMap.get(v) ?? 0;67      result.push({ value: v, count: Math.max(ec, uc), type: ec >= uc ? "email" : "username" });68    }69    return result.sort((a, b) => b.count - a.count);70  });7172  const identitySuggestions = createMemo(() => {73    const field = activeIdentityField();74    if (!field) return [];75    const input = (field === "username" ? username() : email()).toLowerCase();76    return allIdentities()77      .filter(s => s.type === field && (!input || s.value.toLowerCase().includes(input)))78      .slice(0, 8);79  });8081  const isNoteEntry = () => props.entry?.type === "note";82  const isCardEntry = () => props.entry?.type === "card";83  const [selectedType, setSelectedType] = createSignal<"login" | "note" | "card">(84    isNoteEntry() ? "note" : isCardEntry() ? "card" : "login"85  );8687  // Card signals88  const [cardNickname, setCardNickname] = createSignal(props.entry?.cardNickname ?? "");89  const [cardholderName, setCardholderName] = createSignal(props.entry?.cardholderName ?? "");90  const [cardNumber, setCardNumber] = createSignal(props.entry?.cardNumber ?? "");91  const [expMonth, setExpMonth] = createSignal(props.entry?.expMonth ?? "");92  const [expYear, setExpYear] = createSignal(props.entry?.expYear ?? "");93  const [cvc, setCvc] = createSignal(props.entry?.cvc ?? "");94  const [showCvc, setShowCvc] = createSignal(false);95  const [cardNumberFocused, setCardNumberFocused] = createSignal(false);96  const [noteEditorEl, setNoteEditorEl] = createSignal<HTMLDivElement | null>(null);9798  function noteExec(cmd: string, val?: string) {99    document.execCommand(cmd, false, val);100    noteEditorEl()?.focus();101  }102103  function getCardNetwork(num: string): string {104    const n = num.replace(/\s/g, '');105    if (/^4/.test(n)) return 'Visa';106    if (/^5[1-5]|^2[2-7]/.test(n)) return 'Mastercard';107    if (/^3[47]/.test(n)) return 'Amex';108    if (/^6(?:011|5)/.test(n)) return 'Discover';109    return '';110  }111112  function formatCardNumberDisplay(raw: string, focused: boolean): string {113    const n = raw.replace(/\s/g, '');114    if (!focused && n.length >= 4) return '•••• •••• •••• ' + n.slice(-4);115    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(' '));116    return n.replace(/(\d{4})(?=\d)/g, '$1 ').trim();117  }118119  const canSave = () => {120    if (!title().trim()) return false;121    if (selectedType() === "login" && !password().trim()) return false;122    if (selectedType() === "card" && (!cardholderName().trim() || !cardNumber().trim())) return false;123    return true;124  };125126  const allTags = createMemo(() => {127    const tagSet = new Set<string>();128    for (const entry of props.entries ?? []) {129      for (const tag of entry.tags ?? []) tagSet.add(tag);130    }131    return Array.from(tagSet).sort();132  });133134  const tagSuggestions = createMemo(() => {135    const input = tagInput().toLowerCase().trim();136    if (!input) return [];137    return allTags().filter(t =>138      t.toLowerCase().includes(input) && !tags().includes(t)139    ).slice(0, 5);140  });141142  function addTag(tag: string) {143    const n = tag.toLowerCase().trim();144    if (!n || n.length > 32 || tags().includes(n) || tags().length >= 10) return;145    setTags([...tags(), n]);146  }147148  function removeTag(tag: string) {149    setTags(tags().filter(t => t !== tag));150  }151152  function handleTagKeyDown(e: KeyboardEvent) {153    if (e.key === "Enter" || e.key === ",") {154      e.preventDefault();155      if (tagInput()) { addTag(tagInput()); setTagInput(""); setShowTagSuggestions(false); }156    } else if (e.key === "Backspace" && !tagInput() && tags().length > 0) {157      removeTag(tags()[tags().length - 1]);158    }159  }160161  // Strength check162  createEffect(async () => {163    const pwd = password();164    if (pwd.length < 4) { setPasswordStrength(null); return; }165    try {166      if (!zxcvbnFn()) {167        const mod = await import("zxcvbn");168        setZxcvbnFn(() => mod.default);169      }170      const fn = zxcvbnFn();171      if (fn) setPasswordStrength(fn(pwd).score);172    } catch {173      setPasswordStrength(pwd.length >= 16 ? 3 : pwd.length >= 12 ? 2 : 1);174    }175  });176177  onMount(() => {178    const el = document.querySelector(".peach-new-title-input") as HTMLInputElement | null;179    if (el) el.focus();180    chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {181      const tabUrl = tabs[0]?.url;182      if (tabUrl && tabUrl.startsWith("http")) setCurrentTabUrl(tabUrl);183    });184    // Keep the vault alive while the form is open — the auto-lock alarm fires on185    // a timer and doesn't know the user is actively filling in a new entry.186    const keepAlive = setInterval(() => {187      chrome.runtime.sendMessage({ type: "TOUCH_AUTO_LOCK" }).catch(() => {});188    }, 30_000);189    onCleanup(() => clearInterval(keepAlive));190  });191192  function getNormalizedTotp(): string | undefined {193    const value = totp().trim();194    if (!value) return undefined;195    if (value.startsWith("otpauth://")) {196      const parsed = parseOtpauthURI(value);197      return parsed?.secret ?? value;198    }199    return value;200  }201202  async function handleSave() {203    if (!canSave()) {204      setError(205        selectedType() === "note"206          ? "title is required"207          : selectedType() === "card"208          ? "cardholder name and card number are required"209          : "title and password are required"210      );211      return;212    }213214    setSaving(true);215    setError("");216217    const payload: Record<string, unknown> = {218      type: selectedType(),219      title: title().trim(),220      notes: notes().trim() || undefined,221      tags: tags().length > 0 ? tags() : undefined,222    };223224    if (selectedType() === "login") {225      payload.username = username().trim();226      payload.email = email().trim() || undefined;227      payload.password = password();228      payload.url = url().trim();229      payload.totp = getNormalizedTotp();230    }231232    if (selectedType() === "card") {233      const raw = cardNumber().replace(/\s/g, '');234      payload.cardholderName = cardholderName().trim();235      payload.cardNumber = raw;236      payload.expMonth = expMonth().trim();237      payload.expYear = expYear().trim();238      payload.cvc = cvc().trim();239      payload.cardNickname = cardNickname().trim() || undefined;240      const n = raw;241      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';242      payload.last4 = raw.slice(-4);243    }244245    if (props.entry) {246      const res = await sendMessage({ type: "UPDATE_ENTRY", payload: { id: props.entry.id, patch: payload } });247      const result = res as { ok: boolean; error?: string };248      if (!result.ok) { setError(result.error ?? "could not update entry"); setSaving(false); return; }249      await props.onSave({ ...props.entry, ...payload } as VaultEntry);250      return;251    }252253    const res = await sendMessage({ type: "ADD_ENTRY", payload });254    const result = res as { ok: boolean; data?: VaultEntry; error?: string };255    if (!result.ok) {256      if (result.error === "vault locked") {257        setError("vault is locked — tap the lock icon to re-enter your master password");258      } else {259        setError(result.error ?? "could not save entry");260      }261      setSaving(false);262      return;263    }264    await props.onSave(result.data);265  }266267  const strengthLabel = () => {268    const s = passwordStrength();269    if (s === null) return null;270    return ["weak", "weak", "fair", "good", "strong"][s] as string;271  };272273  return (274    <div class="peach-overlay-view ov-enter-right" style="display:flex">275      <div class="peach-overlay-header">276        <button277          class="peach-btn peach-btn--ghost peach-btn--sm peach-overlay-action"278          onClick={props.onBack}279        >280          cancel281        </button>282        <span class="peach-overlay-title">283          {props.entry ? "edit entry" : "new entry"}284        </span>285        <button286          class="peach-btn peach-btn--accent peach-btn--sm peach-overlay-action"287          onClick={() => void handleSave()}288          disabled={saving() || !canSave()}289        >290          {saving() ? "saving…" : props.entry ? "save" : "create"}291        </button>292      </div>293294      {/* ── Body — mirrors Entry.tsx field-card structure ── */}295      <div class="peach-entry-body peach-entry-body--new">296297        {/* Type toggle */}298        <Show when={!props.entry}>299          <div class="peach-new-type-row">300            <button301              class={`peach-new-type-btn ${selectedType() === "login" ? "is-active" : ""}`}302              onClick={() => setSelectedType("login")}303            >login</button>304            <button305              class={`peach-new-type-btn ${selectedType() === "note" ? "is-active" : ""}`}306              onClick={() => setSelectedType("note")}307            >note</button>308            <button309              class={`peach-new-type-btn ${selectedType() === "card" ? "is-active" : ""}`}310              onClick={() => setSelectedType("card")}311            >card</button>312          </div>313        </Show>314315        {/* Note formatting toolbar — own row so it never overflows */}316        <Show when={selectedType() === "note"}>317          <Toolbar exec={noteExec} class="peach-note-toolbar-body" />318        </Show>319320        {/* Title */}321        <div class="peach-new-title-wrap">322          <input323            class="peach-new-title-input"324            placeholder={selectedType() === "note" ? "note title" : "entry name"}325            autocomplete="off"326            value={title()}327            onInput={(e) => setTitle((e.currentTarget as HTMLInputElement).value)}328            onKeyDown={(e) => { if (e.key === "Enter") void handleSave(); }}329          />330        </div>331332        {/* ── Login fields ── */}333        <Show when={selectedType() === "login"}>334          <div class="peach-domain">335            <div class="peach-domain-edit-row">336              <input337                class="peach-domain-value peach-domain-value--edit"338                placeholder="domain.com"339                autocomplete="off"340                value={url()}341                onInput={(e) => setUrl((e.currentTarget as HTMLInputElement).value)}342              />343              <Show when={currentTabUrl()}>344                <button345                  class="peach-domain-url-btn"346                  type="button"347                  onClick={() => setUrl(currentTabUrl())}348                  title="Insert current page URL"349                >350                  <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">351                    <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" />352                    <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" />353                  </svg>354                </button>355              </Show>356            </div>357          </div>358359          <section class="peach-fields">360            {/* Username */}361            <div class="peach-field-card">362              <span class="peach-label">username</span>363              <div class="peach-field-value-row">364                <input365                  ref={(el) => (usernameInputEl = el)}366                  class="peach-field-value peach-field-value--edit"367                  placeholder="johndoe"368                  autocomplete="off"369                  value={username()}370                  onInput={(e) => setUsername((e.currentTarget as HTMLInputElement).value)}371                  onFocus={() => openIdentityField("username")}372                  onBlur={(e) => {373                    if (usernameSuggestEl?.contains(e.relatedTarget as Node)) return;374                    setTimeout(() => setActiveIdentityField(null), 150);375                  }}376                  onKeyDown={(e) => {377                    if (activeIdentityField() !== "username") return;378                    if (e.key === "ArrowDown") {379                      e.preventDefault();380                      usernameSuggestEl?.querySelector<HTMLElement>(".peach-identity-suggestion")?.focus();381                    } else if (e.key === "Escape") {382                      e.preventDefault();383                      setActiveIdentityField(null);384                    }385                  }}386                />387              </div>388              <Show when={activeIdentityField() === "username" && allIdentities().length > 0}>389                <IdentitySuggestList390                  listRef={(el) => (usernameSuggestEl = el)}391                  inputRef={() => usernameInputEl}392                  suggestions={identitySuggestions()}393                  onPick={(v) => { setUsername(v); setActiveIdentityField(null); suppressIdentityOpen = true; usernameInputEl?.focus(); }}394                  onClose={() => { setActiveIdentityField(null); suppressIdentityOpen = true; usernameInputEl?.focus(); }}395                />396              </Show>397            </div>398399            {/* Email */}400            <div class="peach-field-card">401              <span class="peach-label">email</span>402              <div class="peach-field-value-row">403                <input404                  ref={(el) => (emailInputEl = el)}405                  class="peach-field-value peach-field-value--edit"406                  type="email"407                  placeholder="[email protected]"408                  autocomplete="off"409                  value={email()}410                  onInput={(e) => setEmail((e.currentTarget as HTMLInputElement).value)}411                  onFocus={() => openIdentityField("email")}412                  onBlur={(e) => {413                    if (emailSuggestEl?.contains(e.relatedTarget as Node)) return;414                    setTimeout(() => setActiveIdentityField(null), 150);415                  }}416                  onKeyDown={(e) => {417                    if (activeIdentityField() !== "email") return;418                    if (e.key === "ArrowDown") {419                      e.preventDefault();420                      emailSuggestEl?.querySelector<HTMLElement>(".peach-identity-suggestion")?.focus();421                    } else if (e.key === "Escape") {422                      e.preventDefault();423                      setActiveIdentityField(null);424                    }425                  }}426                />427              </div>428              <Show when={activeIdentityField() === "email" && allIdentities().length > 0}>429                <IdentitySuggestList430                  listRef={(el) => (emailSuggestEl = el)}431                  inputRef={() => emailInputEl}432                  suggestions={identitySuggestions()}433                  onPick={(v) => { setEmail(v); setActiveIdentityField(null); suppressIdentityOpen = true; emailInputEl?.focus(); }}434                  onClose={() => { setActiveIdentityField(null); suppressIdentityOpen = true; emailInputEl?.focus(); }}435                />436              </Show>437            </div>438439            {/* Password */}440            <div class="peach-field-card">441              <span class="peach-label">password</span>442              <Show when={!showGenerator()}>443                <div class="peach-password-edit-row">444                  <input445                    class="peach-field-value peach-field-value--edit"446                    type={showPassword() ? "text" : "password"}447                    placeholder="password"448                    autocomplete="new-password"449                    value={password()}450                    onInput={(e) => setPassword((e.currentTarget as HTMLInputElement).value)}451                  />452                  <button453                    class="peach-inline-action peach-password-toggle-btn"454                    type="button"455                    onClick={() => setShowPassword(!showPassword())}456                    aria-label={showPassword() ? "Hide password" : "Show password"}457                  >458                    <Show459                      when={showPassword()}460                      fallback={461                        <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">462                          <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" />463                          <circle cx="7" cy="5" r="1.75" />464                        </svg>465                      }466                    >467                      <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">468                        <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />469                        <path d="M3.5 5.5L2.5 7.5" />470                        <path d="M7 6.5V8.5" />471                        <path d="M10.5 5.5L11.5 7.5" />472                      </svg>473                    </Show>474                  </button>475                </div>476              </Show>477478              {/* Strength */}479              <Show when={strengthLabel() !== null}>480                <div class="peach-strength-hairline">481                  <div class="peach-strength-hairline-track">482                    <div483                      class="peach-strength-hairline-fill"484                      style={{485                        width: ["25%","40%","60%","80%","100%"][passwordStrength()!],486                        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()!],487                      }}488                    />489                  </div>490                  <span class="peach-strength-label">{strengthLabel()}</span>491                </div>492              </Show>493494              {/* Generator toggle */}495              <button496                class="peach-generate-toggle-link"497                type="button"498                onClick={() => { setShowGenerator(!showGenerator()); }}499              >500                {showGenerator() ? "☰ hide generator" : "⚙ generate"}501              </button>502              <GeneratorDrawer503                compact504                open={showGenerator()}505                onChange={(pw) => setPassword(pw)}506                onUse={(pw) => { setPassword(pw); setShowGenerator(false); }}507              />508            </div>509510            {/* 2FA — optional */}511            <Show when={showTotp() || totp()}>512              <div class="peach-field-card">513                <span class="peach-label">2fa secret</span>514                <div class="peach-field-value-row">515                  <input516                    class="peach-field-value peach-field-value--edit"517                    placeholder="BASE32 or otpauth:// URI"518                    autocomplete="off"519                    value={totp()}520                    onInput={(e) => setTotp((e.currentTarget as HTMLInputElement).value)}521                  />522                </div>523              </div>524            </Show>525          </section>526        </Show>527528        {/* ── Card fields ── */}529        <Show when={selectedType() === "card"}>530          <section class="peach-fields">531            {/* Nickname */}532            <div class="peach-field-card">533              <span class="peach-label">nickname</span>534              <div class="peach-field-value-row">535                <input class="peach-field-value peach-field-value--edit" placeholder="Personal Visa"536                  autocomplete="off" value={cardNickname()}537                  onInput={(e) => setCardNickname((e.currentTarget as HTMLInputElement).value)} />538              </div>539            </div>540            {/* Cardholder name */}541            <div class="peach-field-card">542              <span class="peach-label">cardholder name</span>543              <div class="peach-field-value-row">544                <input class="peach-field-value peach-field-value--edit" placeholder="Jane Smith"545                  autocomplete="cc-name" value={cardholderName()}546                  onInput={(e) => setCardholderName((e.currentTarget as HTMLInputElement).value)} />547              </div>548            </div>549            {/* Card number */}550            <div class="peach-field-card">551              <span class="peach-label">552                card number553                <Show when={getCardNetwork(cardNumber())}>554                  <span class="peach-card-network-badge">{getCardNetwork(cardNumber())}</span>555                </Show>556              </span>557              <div class="peach-field-value-row">558                <input class="peach-field-value peach-field-value--edit peach-card-number"559                  placeholder="•••• •••• •••• ••••"560                  autocomplete="cc-number" inputmode="numeric" maxLength={19}561                  value={formatCardNumberDisplay(cardNumber(), cardNumberFocused())}562                  onFocus={() => setCardNumberFocused(true)}563                  onBlur={() => setCardNumberFocused(false)}564                  onInput={(e) => {565                    const raw = (e.currentTarget as HTMLInputElement).value.replace(/\s/g, '');566                    setCardNumber(raw);567                  }} />568              </div>569            </div>570            {/* Expiry */}571            <div class="peach-field-card">572              <span class="peach-label">expiry</span>573              <div class="peach-card-expiry-row">574                <input class="peach-field-value peach-field-value--edit" placeholder="MM"575                  autocomplete="cc-exp-month" inputmode="numeric" maxLength={2} style="width:56px"576                  value={expMonth()}577                  onInput={(e) => setExpMonth((e.currentTarget as HTMLInputElement).value)} />578                <span class="peach-card-expiry-sep">/</span>579                <input class="peach-field-value peach-field-value--edit" placeholder="YYYY"580                  autocomplete="cc-exp-year" inputmode="numeric" maxLength={4} style="width:72px"581                  value={expYear()}582                  onInput={(e) => setExpYear((e.currentTarget as HTMLInputElement).value)} />583              </div>584            </div>585            {/* CVC */}586            <div class="peach-field-card">587              <span class="peach-label">CVC</span>588              <div class="peach-field-value-row">589                <input class="peach-field-value peach-field-value--edit"590                  type={showCvc() ? "text" : "password"} placeholder="•••"591                  autocomplete="cc-csc" inputmode="numeric" maxLength={4}592                  value={cvc()}593                  onInput={(e) => setCvc((e.currentTarget as HTMLInputElement).value)} />594                <button class="peach-inline-action peach-password-toggle-btn" type="button"595                  onClick={() => setShowCvc(!showCvc())} aria-label={showCvc() ? "Hide CVC" : "Show CVC"}>596                  <Show when={showCvc()} fallback={597                    <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">598                      <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" />599                      <circle cx="7" cy="5" r="1.75" />600                    </svg>601                  }>602                    <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">603                      <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />604                    </svg>605                  </Show>606                </button>607              </div>608            </div>609          </section>610        </Show>611612        {/* ── Note content ── */}613        <Show when={selectedType() === "note"}>614          <div class="peach-note-body">615            <NoteEditorInline616              initialContent={notes()}617              onContentChange={setNotes}618              onReady={(el) => setNoteEditorEl(el)}619            />620          </div>621        </Show>622623        {/* ── Login notes ── */}624        <Show when={selectedType() === "login" && (showNotes() || notes())}>625          <div class="peach-field-card" style="margin:0 12px">626            <span class="peach-label">notes</span>627            <textarea628              class="peach-new-textarea"629              placeholder="anything worth remembering"630              value={notes()}631              onInput={(e) => setNotes((e.currentTarget as HTMLTextAreaElement).value)}632            />633          </div>634        </Show>635636        {/* ── "+ add" optional field links ── */}637        <div class="peach-entry-add-links">638          <Show when={selectedType() === "login" && !showTotp() && !totp()}>639            <button class="peach-add-field-link" type="button" onClick={() => setShowTotp(true)}>640              + add 2FA641            </button>642          </Show>643          <Show when={selectedType() === "login" && !showNotes() && !notes()}>644            <button class="peach-add-field-link" type="button" onClick={() => setShowNotes(true)}>645              + add notes646            </button>647          </Show>648        </div>649650        {/* ── Tags ── */}651        <div class="peach-field-card" style="margin:4px 12px 12px">652          <span class="peach-label">tags</span>653          <div class="peach-tag-input-wrap">654            <For each={tags()}>655              {(tag) => (656                <span class="peach-tag-chip">657                  {tag}658                  <button class="peach-tag-chip-remove" onClick={() => removeTag(tag)} aria-label={`Remove ${tag}`}>×</button>659                </span>660              )}661            </For>662            <input663              class="peach-tag-input"664              placeholder={tags().length === 0 ? "add tags…" : ""}665              value={tagInput()}666              onInput={(e) => { setTagInput((e.currentTarget as HTMLInputElement).value); setShowTagSuggestions(true); }}667              onKeyDown={handleTagKeyDown}668              onFocus={() => setShowTagSuggestions(true)}669              onBlur={() => setTimeout(() => setShowTagSuggestions(false), 150)}670              style="flex:1;min-width:80px;background:transparent;border:none;outline:none;color:var(--t1);font-size:12px;padding:2px;"671            />672          </div>673          <Show when={showTagSuggestions() && tagSuggestions().length > 0}>674            <div class="peach-tag-suggestions" style="margin-top:4px;">675              <For each={tagSuggestions()}>676                {(s) => (677                  <button678                    class="peach-tag-suggestion"679                    onMouseDown={() => { addTag(s); setTagInput(""); setShowTagSuggestions(false); }}680                  >{s}</button>681                )}682              </For>683            </div>684          </Show>685        </div>686687        <Show when={error()}>688          <p class="peach-hint peach-hint-error" style="margin:0 16px 12px">{error()}</p>689        </Show>690691      </div>692    </div>693  );694}695696interface IdentitySuggestListProps {697  suggestions: Array<{ value: string; count: number; type: "email" | "username" }>;698  onPick: (value: string) => void;699  onClose?: () => void;700  listRef?: (el: HTMLDivElement) => void;701  inputRef?: () => HTMLInputElement | undefined;702}703704function IdentitySuggestList(props: IdentitySuggestListProps) {705  const [collapsed, setCollapsed] = createSignal(false);706  const ChevronIcon = () => (707    <svg width="10" height="6" viewBox="0 0 10 6" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">708      <path d="M1 1L5 5L9 1" />709    </svg>710  );711712  function handleRowKeyDown(e: KeyboardEvent, value: string) {713    const row = e.currentTarget as HTMLElement;714    if (e.key === "ArrowDown") {715      e.preventDefault();716      (row.nextElementSibling as HTMLElement | null)?.focus();717    } else if (e.key === "ArrowUp") {718      e.preventDefault();719      const prev = row.previousElementSibling as HTMLElement | null;720      if (prev) prev.focus();721      else props.inputRef?.()?.focus();722    } else if (e.key === "Enter" || e.key === " ") {723      e.preventDefault();724      props.onPick(value);725    } else if (e.key === "Escape") {726      e.preventDefault();727      props.onClose?.();728    }729  }730731  return (732    <div class="peach-identity-suggestions" ref={props.listRef}>733      <div class="peach-identity-filter-row" style="justify-content:flex-end">734        <button735          class={`peach-identity-collapse-btn${collapsed() ? " is-collapsed" : ""}`}736          type="button"737          onMouseDown={(e) => { e.preventDefault(); setCollapsed(c => !c); }}738          aria-label={collapsed() ? "Show suggestions" : "Hide suggestions"}739        >740          <ChevronIcon />741        </button>742      </div>743      <div class={`peach-identity-rows${collapsed() ? " is-collapsed" : ""}`}>744        <div class="peach-identity-rows-inner">745          <For each={props.suggestions}>746            {(s) => (747              <button748                class="peach-identity-suggestion"749                type="button"750                onMouseDown={(e) => { e.preventDefault(); props.onPick(s.value); }}751                onKeyDown={(e) => handleRowKeyDown(e, s.value)}752              >753                <span class="peach-identity-suggestion-value">{s.value}</span>754                <span class="peach-identity-suggestion-meta">{s.count}×</span>755              </button>756            )}757          </For>758        </div>759      </div>760    </div>761  );762}