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 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 88 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 162 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 185 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 {}295 <div class="peach-entry-body peach-entry-body--new">296297 {}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 {}316 <Show when={selectedType() === "note"}>317 <Toolbar exec={noteExec} class="peach-note-toolbar-body" />318 </Show>319320 {}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 {}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 {}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(() => { if (activeIdentityField() === "username") 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 {}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(() => { if (activeIdentityField() === "email") 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 {}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 {}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 {}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 {}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 {}529 <Show when={selectedType() === "card"}>530 <section class="peach-fields">531 {}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 {}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 {}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 {}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 {}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 {}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 {}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 {}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 {}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}703704export function 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}