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 40 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 90 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 164 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 187 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 {}297 <div class="peach-entry-body peach-entry-body--new">298299 {}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 {}318 <Show when={selectedType() === "note"}>319 <Toolbar exec={noteExec} class="peach-note-toolbar-body" />320 </Show>321322 {}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 {}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 {}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 {}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 {}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 {}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 {}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 {}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 {}532 <Show when={selectedType() === "card"}>533 <section class="peach-fields">534 {}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 {}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 {}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 {}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 {}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 {}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 {}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 {}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 {}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(); 727 } 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}