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 39 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.email?.trim() ?? "";57 if (uname) {58 if (uname.includes("@")) emailMap.set(uname, (emailMap.get(uname) ?? 0) + 1);59 else usernameMap.set(uname, (usernameMap.get(uname) ?? 0) + 1);60 }61 if (em) emailMap.set(em, (emailMap.get(em) ?? 0) + 1);62 }63 const result: Array<{ value: string; count: number; type: "email" | "username" }> = [];64 const seen = new Set<string>();65 for (const v of new Set([...emailMap.keys(), ...usernameMap.keys()])) {66 if (seen.has(v)) continue;67 seen.add(v);68 const ec = emailMap.get(v) ?? 0;69 const uc = usernameMap.get(v) ?? 0;70 result.push({ value: v, count: Math.max(ec, uc), type: ec >= uc ? "email" : "username" });71 }72 return result.sort((a, b) => b.count - a.count);73 });7475 const identitySuggestions = createMemo(() => {76 const field = activeIdentityField();77 if (!field) return [];78 const input = (field === "username" ? username() : email()).toLowerCase();79 return allIdentities()80 .filter(s => s.type === field && (!input || s.value.toLowerCase().includes(input)))81 .slice(0, 8);82 });8384 const isNoteEntry = () => props.entry?.type === "note";85 const isCardEntry = () => props.entry?.type === "card";86 const [selectedType, setSelectedType] = createSignal<"login" | "note" | "card">(87 isNoteEntry() ? "note" : isCardEntry() ? "card" : "login"88 );8990 91 const [cardNickname, setCardNickname] = createSignal(props.entry?.cardNickname ?? "");92 const [cardholderName, setCardholderName] = createSignal(props.entry?.cardholderName ?? "");93 const [cardNumber, setCardNumber] = createSignal(props.entry?.cardNumber ?? "");94 const [expMonth, setExpMonth] = createSignal(props.entry?.expMonth ?? "");95 const [expYear, setExpYear] = createSignal(props.entry?.expYear ?? "");96 const [cvc, setCvc] = createSignal(props.entry?.cvc ?? "");97 const [showCvc, setShowCvc] = createSignal(false);98 const [cardNumberFocused, setCardNumberFocused] = createSignal(false);99 const [noteEditorEl, setNoteEditorEl] = createSignal<HTMLDivElement | null>(null);100101 function noteExec(cmd: string, val?: string) {102 document.execCommand(cmd, false, val);103 noteEditorEl()?.focus();104 }105106 function getCardNetwork(num: string): string {107 const n = num.replace(/\s/g, '');108 if (/^4/.test(n)) return 'Visa';109 if (/^5[1-5]|^2[2-7]/.test(n)) return 'Mastercard';110 if (/^3[47]/.test(n)) return 'Amex';111 if (/^6(?:011|5)/.test(n)) return 'Discover';112 return '';113 }114115 function formatCardNumberDisplay(raw: string, focused: boolean): string {116 const n = raw.replace(/\s/g, '');117 if (!focused && n.length >= 4) return '•••• •••• •••• ' + n.slice(-4);118 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(' '));119 return n.replace(/(\d{4})(?=\d)/g, '$1 ').trim();120 }121122 const canSave = () => {123 if (!title().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 170 const [{ zxcvbn, zxcvbnOptions }, common, en] = await Promise.all([171 import("@zxcvbn-ts/core"),172 import("@zxcvbn-ts/language-common"),173 import("@zxcvbn-ts/language-en"),174 ]);175 zxcvbnOptions.setOptions({176 translations: en.translations,177 graphs: common.adjacencyGraphs,178 dictionary: { ...common.dictionary, ...en.dictionary },179 });180 setZxcvbnFn(() => (pwd: string) => zxcvbn(pwd));181 }182 const fn = zxcvbnFn();183 if (fn) setPasswordStrength(fn(pwd).score);184 } catch {185 setPasswordStrength(pwd.length >= 16 ? 3 : pwd.length >= 12 ? 2 : 1);186 }187 });188189 onMount(() => {190 const el = document.querySelector(".peach-new-title-input") as HTMLInputElement | null;191 if (el) el.focus();192 chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {193 const tabUrl = tabs[0]?.url;194 if (tabUrl && tabUrl.startsWith("http")) setCurrentTabUrl(tabUrl);195 });196 197 198 const keepAlive = setInterval(() => {199 chrome.runtime.sendMessage({ type: "TOUCH_AUTO_LOCK" }).catch(() => {});200 }, 30_000);201 onCleanup(() => clearInterval(keepAlive));202 });203204 function getNormalizedTotp(): string | undefined {205 const value = totp().trim();206 if (!value) return undefined;207 if (value.startsWith("otpauth://")) {208 const parsed = parseOtpauthURI(value);209 return parsed?.secret ?? value;210 }211 return value;212 }213214 async function handleSave() {215 if (!canSave()) {216 setError(217 selectedType() === "note"218 ? "title is required"219 : selectedType() === "card"220 ? "cardholder name and card number are required"221 : "title is required"222 );223 return;224 }225226 setSaving(true);227 setError("");228229 const payload: Record<string, unknown> = {230 type: selectedType(),231 title: title().trim(),232 notes: notes().trim() || undefined,233 tags: tags().length > 0 ? tags() : undefined,234 };235236 if (selectedType() === "login") {237 payload.username = username().trim();238 payload.email = email().trim() || undefined;239 payload.password = password();240 payload.url = url().trim();241 payload.totp = getNormalizedTotp();242 }243244 if (selectedType() === "card") {245 const raw = cardNumber().replace(/\s/g, '');246 payload.cardholderName = cardholderName().trim();247 payload.cardNumber = raw;248 payload.expMonth = expMonth().trim();249 payload.expYear = expYear().trim();250 payload.cvc = cvc().trim();251 payload.cardNickname = cardNickname().trim() || undefined;252 const n = raw;253 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';254 payload.last4 = raw.slice(-4);255 }256257 if (props.entry) {258 const res = await sendMessage({ type: "UPDATE_ENTRY", payload: { id: props.entry.id, patch: payload } });259 const result = res as { ok: boolean; error?: string };260 if (!result.ok) { setError(result.error ?? "could not update entry"); setSaving(false); return; }261 await props.onSave({ ...props.entry, ...payload } as VaultEntry);262 return;263 }264265 const res = await sendMessage({ type: "ADD_ENTRY", payload });266 const result = res as { ok: boolean; data?: VaultEntry; error?: string };267 if (!result.ok) {268 if (result.error === "vault_locked") {269 setError("vault is locked — tap the lock icon to re-enter your master password");270 } else {271 setError(result.error ?? "could not save entry");272 }273 setSaving(false);274 return;275 }276 await props.onSave(result.data);277 }278279 const strengthLabel = () => {280 const s = passwordStrength();281 if (s === null) return null;282 return ["weak", "weak", "fair", "good", "strong"][s] as string;283 };284285 return (286 <div class="peach-overlay-view ov-enter-right" style="display:flex">287 <div class="peach-overlay-header">288 <button289 class="peach-btn peach-btn--ghost peach-btn--sm peach-overlay-action"290 onClick={props.onBack}291 >292 cancel293 </button>294 <span class="peach-overlay-title">295 {props.entry ? "edit entry" : "new entry"}296 </span>297 <button298 class="peach-btn peach-btn--accent peach-btn--sm peach-overlay-action"299 onClick={() => void handleSave()}300 disabled={saving() || !canSave()}301 >302 {saving() ? "saving…" : props.entry ? "save" : "create"}303 </button>304 </div>305306 {}307 <div class="peach-entry-body peach-entry-body--new">308309 {}310 <Show when={!props.entry}>311 <div class="peach-new-type-row">312 <button313 class={`peach-new-type-btn ${selectedType() === "login" ? "is-active" : ""}`}314 onClick={() => setSelectedType("login")}315 >login</button>316 <button317 class={`peach-new-type-btn ${selectedType() === "note" ? "is-active" : ""}`}318 onClick={() => setSelectedType("note")}319 >note</button>320 <button321 class={`peach-new-type-btn ${selectedType() === "card" ? "is-active" : ""}`}322 onClick={() => setSelectedType("card")}323 >card</button>324 </div>325 </Show>326327 {}328 <Show when={selectedType() === "note"}>329 <Toolbar exec={noteExec} class="peach-note-toolbar-body" />330 </Show>331332 {}333 <div class="peach-new-title-wrap">334 <input335 class="peach-new-title-input"336 placeholder={selectedType() === "note" ? "note title" : "entry name"}337 autocomplete="off"338 value={title()}339 onInput={(e) => setTitle((e.currentTarget as HTMLInputElement).value)}340 onKeyDown={(e) => { if (e.key === "Enter") void handleSave(); }}341 />342 </div>343344 {}345 <Show when={selectedType() === "login"}>346 <div class="peach-domain">347 <div class="peach-domain-edit-row">348 <input349 class="peach-domain-value peach-domain-value--edit"350 placeholder="domain.com"351 autocomplete="off"352 value={url()}353 onInput={(e) => setUrl((e.currentTarget as HTMLInputElement).value)}354 />355 <Show when={currentTabUrl()}>356 <button357 class="peach-domain-url-btn"358 type="button"359 onClick={() => setUrl(currentTabUrl())}360 title="Insert current page URL"361 >362 <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">363 <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" />364 <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" />365 </svg>366 </button>367 </Show>368 </div>369 </div>370371 <section class="peach-fields">372 {}373 <div class="peach-field-card">374 <span class="peach-label">username</span>375 <div class="peach-field-value-row">376 <input377 ref={(el) => (usernameInputEl = el)}378 class="peach-field-value peach-field-value--edit"379 placeholder="johndoe"380 autocomplete="off"381 value={username()}382 onInput={(e) => setUsername((e.currentTarget as HTMLInputElement).value)}383 onFocus={() => openIdentityField("username")}384 onBlur={(e) => {385 if (usernameSuggestEl?.contains(e.relatedTarget as Node)) return;386 setTimeout(() => { if (activeIdentityField() === "username") setActiveIdentityField(null); }, 150);387 }}388 onKeyDown={(e) => {389 if (activeIdentityField() !== "username") return;390 if (e.key === "ArrowDown") {391 e.preventDefault();392 usernameSuggestEl?.querySelector<HTMLElement>(".peach-identity-suggestion")?.focus();393 } else if (e.key === "Escape") {394 e.preventDefault();395 setActiveIdentityField(null);396 }397 }}398 />399 </div>400 <Show when={activeIdentityField() === "username" && identitySuggestions().length > 0}>401 <IdentitySuggestList402 listRef={(el) => (usernameSuggestEl = el)}403 inputRef={() => usernameInputEl}404 suggestions={identitySuggestions()}405 onPick={(v) => { setUsername(v); setActiveIdentityField(null); suppressIdentityOpen = true; usernameInputEl?.focus(); }}406 onClose={() => { setActiveIdentityField(null); suppressIdentityOpen = true; usernameInputEl?.focus(); }}407 />408 </Show>409 </div>410411 {}412 <div class="peach-field-card">413 <span class="peach-label">email</span>414 <div class="peach-field-value-row">415 <input416 ref={(el) => (emailInputEl = el)}417 class="peach-field-value peach-field-value--edit"418 type="email"419 placeholder="[email protected]"420 autocomplete="off"421 value={email()}422 onInput={(e) => setEmail((e.currentTarget as HTMLInputElement).value)}423 onFocus={() => openIdentityField("email")}424 onBlur={(e) => {425 if (emailSuggestEl?.contains(e.relatedTarget as Node)) return;426 setTimeout(() => { if (activeIdentityField() === "email") setActiveIdentityField(null); }, 150);427 }}428 onKeyDown={(e) => {429 if (activeIdentityField() !== "email") return;430 if (e.key === "ArrowDown") {431 e.preventDefault();432 emailSuggestEl?.querySelector<HTMLElement>(".peach-identity-suggestion")?.focus();433 } else if (e.key === "Escape") {434 e.preventDefault();435 setActiveIdentityField(null);436 }437 }}438 />439 </div>440 <Show when={activeIdentityField() === "email" && identitySuggestions().length > 0}>441 <IdentitySuggestList442 listRef={(el) => (emailSuggestEl = el)}443 inputRef={() => emailInputEl}444 suggestions={identitySuggestions()}445 onPick={(v) => { setEmail(v); setActiveIdentityField(null); suppressIdentityOpen = true; emailInputEl?.focus(); }}446 onClose={() => { setActiveIdentityField(null); suppressIdentityOpen = true; emailInputEl?.focus(); }}447 />448 </Show>449 </div>450451 {}452 <div class="peach-field-card">453 <span class="peach-label">password</span>454 <Show when={!showGenerator()}>455 <div class="peach-password-edit-row">456 <input457 class="peach-field-value peach-field-value--edit"458 type={showPassword() ? "text" : "password"}459 placeholder="password"460 autocomplete="new-password"461 value={password()}462 onInput={(e) => setPassword((e.currentTarget as HTMLInputElement).value)}463 />464 <button465 class="peach-inline-action peach-password-toggle-btn"466 type="button"467 onClick={() => setShowPassword(!showPassword())}468 aria-label={showPassword() ? "Hide password" : "Show password"}469 >470 <Show471 when={showPassword()}472 fallback={473 <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">474 <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" />475 <circle cx="7" cy="5" r="1.75" />476 </svg>477 }478 >479 <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">480 <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />481 <path d="M3.5 5.5L2.5 7.5" />482 <path d="M7 6.5V8.5" />483 <path d="M10.5 5.5L11.5 7.5" />484 </svg>485 </Show>486 </button>487 </div>488 </Show>489490 {}491 <Show when={strengthLabel() !== null}>492 <div class="peach-strength-hairline">493 <div class="peach-strength-hairline-track">494 <div495 class="peach-strength-hairline-fill"496 style={{497 width: ["25%","40%","60%","80%","100%"][passwordStrength()!],498 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()!],499 }}500 />501 </div>502 <span class="peach-strength-label">{strengthLabel()}</span>503 </div>504 </Show>505506 {}507 <button508 class="peach-generate-toggle-link"509 type="button"510 onClick={() => { setShowGenerator(!showGenerator()); }}511 >512 {showGenerator() ? "☰ hide generator" : "⚙ generate"}513 </button>514 <GeneratorDrawer515 compact516 open={showGenerator()}517 onChange={(pw) => setPassword(pw)}518 onUse={(pw) => { setPassword(pw); setShowGenerator(false); }}519 />520 </div>521522 {}523 <Show when={showTotp() || totp()}>524 <div class="peach-field-card">525 <span class="peach-label">2fa secret</span>526 <div class="peach-field-value-row">527 <input528 class="peach-field-value peach-field-value--edit"529 placeholder="BASE32 or otpauth:// URI"530 autocomplete="off"531 value={totp()}532 onInput={(e) => setTotp((e.currentTarget as HTMLInputElement).value)}533 />534 </div>535 </div>536 </Show>537 </section>538 </Show>539540 {}541 <Show when={selectedType() === "card"}>542 <section class="peach-fields">543 {}544 <div class="peach-field-card">545 <span class="peach-label">nickname</span>546 <div class="peach-field-value-row">547 <input class="peach-field-value peach-field-value--edit" placeholder="Personal Visa"548 autocomplete="off" value={cardNickname()}549 onInput={(e) => setCardNickname((e.currentTarget as HTMLInputElement).value)} />550 </div>551 </div>552 {}553 <div class="peach-field-card">554 <span class="peach-label">cardholder name</span>555 <div class="peach-field-value-row">556 <input class="peach-field-value peach-field-value--edit" placeholder="Jane Smith"557 autocomplete="cc-name" value={cardholderName()}558 onInput={(e) => setCardholderName((e.currentTarget as HTMLInputElement).value)} />559 </div>560 </div>561 {}562 <div class="peach-field-card">563 <span class="peach-label">564 card number565 <Show when={getCardNetwork(cardNumber())}>566 <span class="peach-card-network-badge">{getCardNetwork(cardNumber())}</span>567 </Show>568 </span>569 <div class="peach-field-value-row">570 <input class="peach-field-value peach-field-value--edit peach-card-number"571 placeholder="•••• •••• •••• ••••"572 autocomplete="cc-number" inputmode="numeric" maxLength={19}573 value={formatCardNumberDisplay(cardNumber(), cardNumberFocused())}574 onFocus={() => setCardNumberFocused(true)}575 onBlur={() => setCardNumberFocused(false)}576 onInput={(e) => {577 const raw = (e.currentTarget as HTMLInputElement).value.replace(/\s/g, '');578 setCardNumber(raw);579 }} />580 </div>581 </div>582 {}583 <div class="peach-field-card">584 <span class="peach-label">expiry</span>585 <div class="peach-card-expiry-row">586 <input class="peach-field-value peach-field-value--edit" placeholder="MM"587 autocomplete="cc-exp-month" inputmode="numeric" maxLength={2} style="width:56px"588 value={expMonth()}589 onInput={(e) => setExpMonth((e.currentTarget as HTMLInputElement).value)} />590 <span class="peach-card-expiry-sep">/</span>591 <input class="peach-field-value peach-field-value--edit" placeholder="YYYY"592 autocomplete="cc-exp-year" inputmode="numeric" maxLength={4} style="width:72px"593 value={expYear()}594 onInput={(e) => setExpYear((e.currentTarget as HTMLInputElement).value)} />595 </div>596 </div>597 {}598 <div class="peach-field-card">599 <span class="peach-label">CVC</span>600 <div class="peach-field-value-row">601 <input class="peach-field-value peach-field-value--edit"602 type={showCvc() ? "text" : "password"} placeholder="•••"603 autocomplete="cc-csc" inputmode="numeric" maxLength={4}604 value={cvc()}605 onInput={(e) => setCvc((e.currentTarget as HTMLInputElement).value)} />606 <button class="peach-inline-action peach-password-toggle-btn" type="button"607 onClick={() => setShowCvc(!showCvc())} aria-label={showCvc() ? "Hide CVC" : "Show CVC"}>608 <Show when={showCvc()} fallback={609 <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">610 <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" />611 <circle cx="7" cy="5" r="1.75" />612 </svg>613 }>614 <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">615 <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />616 </svg>617 </Show>618 </button>619 </div>620 </div>621 </section>622 </Show>623624 {}625 <Show when={selectedType() === "note"}>626 <div class="peach-note-body">627 <NoteEditorInline628 initialContent={notes()}629 onContentChange={setNotes}630 onReady={(el) => setNoteEditorEl(el)}631 />632 </div>633 </Show>634635 {}636 <Show when={selectedType() === "login" && (showNotes() || notes())}>637 <div class="peach-field-card" style="margin:0 12px">638 <span class="peach-label">notes</span>639 <textarea640 class="peach-new-textarea"641 placeholder="anything worth remembering"642 value={notes()}643 onInput={(e) => setNotes((e.currentTarget as HTMLTextAreaElement).value)}644 />645 </div>646 </Show>647648 {}649 <div class="peach-entry-add-links">650 <Show when={selectedType() === "login" && !showTotp() && !totp()}>651 <button class="peach-add-field-link" type="button" onClick={() => setShowTotp(true)}>652 + add 2FA653 </button>654 </Show>655 <Show when={selectedType() === "login" && !showNotes() && !notes()}>656 <button class="peach-add-field-link" type="button" onClick={() => setShowNotes(true)}>657 + add notes658 </button>659 </Show>660 </div>661662 {}663 <div class="peach-field-card" style="margin:4px 12px 12px">664 <span class="peach-label">tags</span>665 <div class="peach-tag-input-wrap">666 <For each={tags()}>667 {(tag) => (668 <span class="peach-tag-chip">669 {tag}670 <button class="peach-tag-chip-remove" onClick={() => removeTag(tag)} aria-label={`Remove ${tag}`}>×</button>671 </span>672 )}673 </For>674 <input675 class="peach-tag-input"676 placeholder={tags().length === 0 ? "add tags…" : ""}677 value={tagInput()}678 onInput={(e) => { setTagInput((e.currentTarget as HTMLInputElement).value); setShowTagSuggestions(true); }}679 onKeyDown={handleTagKeyDown}680 onFocus={() => setShowTagSuggestions(true)}681 onBlur={() => setTimeout(() => setShowTagSuggestions(false), 150)}682 style="flex:1;min-width:80px;background:transparent;border:none;outline:none;color:var(--t1);font-size:12px;padding:2px;"683 />684 </div>685 <Show when={showTagSuggestions() && tagSuggestions().length > 0}>686 <div class="peach-tag-suggestions" style="margin-top:4px;">687 <For each={tagSuggestions()}>688 {(s) => (689 <button690 class="peach-tag-suggestion"691 onMouseDown={() => { addTag(s); setTagInput(""); setShowTagSuggestions(false); }}692 >{s}</button>693 )}694 </For>695 </div>696 </Show>697 </div>698699 <Show when={error()}>700 <p class="peach-hint peach-hint-error" style="margin:0 16px 12px">{error()}</p>701 </Show>702703 </div>704 </div>705 );706}707708interface IdentitySuggestListProps {709 suggestions: Array<{ value: string; count: number; type: "email" | "username" }>;710 onPick: (value: string) => void;711 onClose?: () => void;712 listRef?: (el: HTMLDivElement) => void;713 inputRef?: () => HTMLInputElement | undefined;714}715716export function IdentitySuggestList(props: IdentitySuggestListProps) {717 const [collapsed, setCollapsed] = createSignal(false);718 const ChevronIcon = () => (719 <svg width="10" height="6" viewBox="0 0 10 6" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">720 <path d="M1 1L5 5L9 1" />721 </svg>722 );723724 function handleRowKeyDown(e: KeyboardEvent, value: string) {725 const row = e.currentTarget as HTMLElement;726 if (e.key === "ArrowDown") {727 e.preventDefault();728 (row.nextElementSibling as HTMLElement | null)?.focus();729 } else if (e.key === "ArrowUp") {730 e.preventDefault();731 const prev = row.previousElementSibling as HTMLElement | null;732 if (prev) prev.focus();733 else props.inputRef?.()?.focus();734 } else if (e.key === "Enter" || e.key === " ") {735 e.preventDefault();736 props.onPick(value);737 } else if (e.key === "Escape") {738 e.preventDefault();739 props.onClose?.();740 }741 }742743 return (744 <div class="peach-identity-suggestions" ref={props.listRef}>745 <div class="peach-identity-filter-row" style="justify-content:flex-end">746 <button747 class={`peach-identity-collapse-btn${collapsed() ? " is-collapsed" : ""}`}748 type="button"749 onMouseDown={(e) => { e.preventDefault(); setCollapsed(c => !c); }}750 aria-label={collapsed() ? "Show suggestions" : "Hide suggestions"}751 >752 <ChevronIcon />753 </button>754 </div>755 <div class={`peach-identity-rows${collapsed() ? " is-collapsed" : ""}`}>756 <div class="peach-identity-rows-inner">757 <For each={props.suggestions}>758 {(s) => (759 <button760 class="peach-identity-suggestion"761 type="button"762 onMouseDown={(e) => { e.preventDefault(); props.onPick(s.value); }}763 onKeyDown={(e) => handleRowKeyDown(e, s.value)}764 >765 <span class="peach-identity-suggestion-value">{s.value}</span>766 <span class="peach-identity-suggestion-meta">{s.count}×</span>767 </button>768 )}769 </For>770 </div>771 </div>772 </div>773 );774}