1import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js";2import type { VaultEntry } from "../../shared/types";3import { getDisplayDomain } from "../../shared/domain";4import { faviconUrl } from "../../shared/favicon";5import { generateTOTP, parseOtpauthURI } from "../../crypto/totp";6import { sendMessage, triggerPipPulse, triggerRing } from "../App";7import { GeneratorDrawer } from "../components/GeneratorDrawer";8import { NoteEditorInline, Toolbar } from "./NoteEditor";910type EditableField = "title" | "url" | "username" | "password" | "notes" | "totp" | null;1112interface EntryProps {13 entry: VaultEntry | null;14 onDelete: () => void;15 16 onFieldSaved: () => void | Promise<void>;17 onEscape?: () => void;18}1920export function EntryView(props: EntryProps) {21 const [editingField, setEditingField] = createSignal<EditableField>(null);22 const [draft, setDraft] = createSignal("");23 const [saving, setSaving] = createSignal(false);24 const [showPassword, setShowPassword] = createSignal(false);25 const [showGenerator, setShowGenerator] = createSignal(false);26 const [copied, setCopied] = createSignal<string | null>(null);27 const [showCardNumber, setShowCardNumber] = createSignal(false);28 const [showCardCvc, setShowCardCvc] = createSignal(false);29 const [totpTick, setTotpTick] = createSignal(0);30 const [confirmDelete, setConfirmDelete] = createSignal(false);31 const [totpHot, setTotpHot] = createSignal(false);32 const [faviconSrc, setFaviconSrc] = createSignal<string | null>(null);33 const [noteEditorEl, setNoteEditorEl] = createSignal<HTMLDivElement | null>(null);34 let lastSavedNote: string | null = null;3536 function noteExec(cmd: string, val?: string) {37 document.execCommand(cmd, false, val);38 noteEditorEl()?.focus();39 }4041 async function saveNoteInline(html: string) {42 const entry = props.entry;43 if (!entry) return;44 const currentStored = entry.notes ?? "";45 if (html === currentStored) return;46 if (lastSavedNote !== null && html === lastSavedNote) return;47 lastSavedNote = html;48 setSaving(true);49 try {50 await sendMessage({51 type: "UPDATE_ENTRY",52 payload: { id: entry.id, patch: { notes: html } },53 });54 triggerRing();55 await props.onFieldSaved();56 } catch {57 lastSavedNote = null;58 } finally {59 setSaving(false);60 }61 }6263 const interval = window.setInterval(() => setTotpTick((n) => n + 1), 1000);64 onCleanup(() => window.clearInterval(interval));6566 const displayDomain = createMemo(() =>67 props.entry?.url ? getDisplayDomain(props.entry.url) : ""68 );6970 createEffect(() => {71 const domain = displayDomain();72 setFaviconSrc(domain ? faviconUrl(domain) : null);73 });7475 const totpState = createMemo(() => {76 void totpTick();77 if (!props.entry?.totp) return null;78 return generateTOTP(props.entry.totp);79 });8081 let prevTotpCode: string | undefined;82 createEffect(() => {83 const state = totpState();84 if (!state) { prevTotpCode = undefined; return; }85 if (prevTotpCode !== undefined && state.code !== prevTotpCode) {86 setTotpHot(true);87 window.setTimeout(() => setTotpHot(false), 3000);88 }89 prevTotpCode = state.code;90 });9192 const totpProgress = createMemo(() => {93 const state = totpState();94 if (!state) return 0;95 return ((state.remaining - 1) / 29) * 100;96 });9798 createEffect(() => {99 const shell = document.getElementById("hd-shell");100 if (!shell) return;101102 const state = totpState();103 if (!state) {104 delete shell.dataset.totpActive;105 shell.style.removeProperty("--peach-totp-progress");106 return;107 }108109 shell.dataset.totpActive = "true";110 shell.style.setProperty(111 "--peach-totp-progress",112 `${Math.max(0, totpProgress())}%`113 );114115 onCleanup(() => {116 delete shell.dataset.totpActive;117 shell.style.removeProperty("--peach-totp-progress");118 });119 });120121 const titleSizeClass = createMemo(() => {122 const length = props.entry?.title.length ?? 0;123 if (length >= 28) return "peach-entry-name--xxl";124 if (length >= 20) return "peach-entry-name--xl";125 if (length >= 14) return "peach-entry-name--lg";126 return "";127 });128 const useAnimatedTitle = createMemo(() => (props.entry?.title.length ?? 0) <= 13);129130 131132 function startEdit(field: EditableField, value: string) {133 setEditingField(field);134 setDraft(value);135 setShowGenerator(false);136 setConfirmDelete(false);137 }138139 async function saveField() {140 const field = editingField();141 const entry = props.entry;142 if (!field || !entry) return;143 144 let value = field === "password" ? draft() : draft().trim();145 if (field === "totp" && value.startsWith("otpauth://")) {146 const parsed = parseOtpauthURI(value);147 value = parsed?.secret ?? value;148 }149 if (field === "password" && entry.type !== "note" && !value) {150 return;151 }152 setSaving(true);153 try {154 await sendMessage({155 type: "UPDATE_ENTRY",156 payload: { id: entry.id, patch: { [field]: value } },157 });158 triggerRing();159 setEditingField(null);160 setShowGenerator(false);161 await props.onFieldSaved();162 } catch {163 164 } finally {165 setSaving(false);166 }167 }168169 function cancelEdit() {170 setEditingField(null);171 setDraft("");172 setShowGenerator(false);173 }174175 function fieldValue(entry: VaultEntry, field: Exclude<EditableField, null>): string {176 switch (field) {177 case "title":178 return entry.title;179 case "url":180 return entry.url ?? "";181 case "username":182 return entry.username ?? "";183 case "password":184 return entry.password ?? "";185 case "notes":186 return entry.notes ?? "";187 case "totp":188 return entry.totp ?? "";189 }190 }191192 function handleEditorFocusOut(193 field: Exclude<EditableField, null>,194 event: FocusEvent & { currentTarget: HTMLElement; relatedTarget: EventTarget | null }195 ) {196 const nextTarget = event.relatedTarget;197 if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return;198199 const entry = props.entry;200 if (!entry) return;201202 const currentValue = fieldValue(entry, field);203 const nextValue = field === "password" ? draft() : draft().trim();204 if (nextValue === currentValue) {205 cancelEdit();206 }207 }208209 async function savePassword(password: string) {210 const entry = props.entry;211 if (!entry) return;212 setSaving(true);213 try {214 await sendMessage({215 type: "UPDATE_ENTRY",216 payload: { id: entry.id, patch: { password } },217 });218 triggerRing();219 setShowGenerator(false);220 await props.onFieldSaved();221 } catch {222 223 } finally {224 setSaving(false);225 }226 }227228 229230 async function copyField(text: string, label: string, button?: HTMLButtonElement) {231 const response = await sendMessage({ type: "COPY", payload: { text, label } });232 if ((response as { ok: boolean }).ok) {233 const entry = props.entry;234 if (entry?.canary?.webhookUrl) {235 const trigger =236 label === "password"237 ? "password_copied"238 : label === "username"239 ? "username_copied"240 : undefined;241 if (trigger) {242 void sendMessage({243 type: "FIRE_CANARY_PING",244 payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger },245 });246 }247 }248 triggerRing();249 triggerPipPulse();250 setCopied(null);251 requestAnimationFrame(() => setCopied(label));252 if (button) {253 button.classList.remove("is-copied", "is-retreat");254 void button.offsetWidth;255 const drain = document.createElement("span");256 drain.className = "peach-copy-drain";257 const bar = document.createElement("span"); bar.className = "peach-copy-drain-bar";258 drain.appendChild(bar);259 button.appendChild(drain);260 window.setTimeout(() => {261 button.classList.remove("is-copied");262 button.classList.add("is-retreat");263 drain.remove();264 window.setTimeout(() => button.classList.remove("is-retreat"), 160);265 }, 1800);266 }267 window.setTimeout(() => setCopied(null), 1800);268 }269 }270271 272273 createEffect(() => {274 if (!props.entry) return;275 const handler = (e: KeyboardEvent) => {276 const inInput = e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement;277 if (inInput) return;278 switch (e.key) {279 case "c": e.preventDefault();280 if (props.entry?.password) void copyField(props.entry.password, "password"); break;281 case "u": e.preventDefault();282 if (props.entry?.username) void copyField(props.entry.username, "username"); break;283 case "Escape": e.preventDefault(); props.onEscape?.(); break;284 }285 };286 window.addEventListener("keydown", handler);287 onCleanup(() => window.removeEventListener("keydown", handler));288 });289290 291292 function autoFocus(el: HTMLInputElement | HTMLTextAreaElement) {293 requestAnimationFrame(() => { el.focus(); el.select?.(); });294 }295296 function onEditKeyDown(e: KeyboardEvent) {297 if (e.key === "Enter" && !(e.target instanceof HTMLTextAreaElement)) {298 e.preventDefault();299 void saveField();300 }301 if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }302 }303304 return (305 <>306 <div307 class="peach-ghost"308 aria-hidden="true"309 style="position:absolute;top:30px;left:12px;right:0;font-family:'Cormorant Garamond',Georgia,serif;font-size:150px;font-style:italic;font-weight:400;color:var(--t0);opacity:0.03;pointer-events:none;line-height:1;overflow:hidden;white-space:nowrap;z-index:0;"310 >311 {props.entry?.title || "empty"}312 </div>313314 <div class="peach-stage" style="position:relative;">315 <Show when={props.entry} fallback={<EmptyVaultState />}>316 {(entry) => (317 <>318 {}319 <Show when={entry().type !== "note" && entry().type !== "card" && (entry().breach ?? 0) > 0}>320 <div class="peach-breach-badge">321 <svg width="10" height="10" viewBox="0 0 12 12" fill="none" stroke="var(--breach)" stroke-width="1.5" stroke-linecap="round">322 <path d="M6 1L11 10H1L6 1z" />323 <line x1="6" y1="5" x2="6" y2="7.5" />324 <circle cx="6" cy="9" r="0.5" fill="var(--breach)" stroke="none" />325 </svg>326 <span style="flex:1">found in a known data breach</span>327 <button328 class="peach-btn peach-btn--sm peach-btn--accent peach-breach-action"329 onClick={() => { startEdit("password", entry().password ?? ""); setShowGenerator(true); }}330 >331 Change password332 </button>333 </div>334 </Show>335336 {}337 <div class="peach-entry-header">338 <div class="peach-entry-header-name">339 <Show340 when={editingField() === "title"}341 fallback={342 <button343 class="peach-entry-name-button"344 title="Click to edit title"345 onClick={() => startEdit("title", entry().title)}346 >347 <h1 class={`peach-entry-name ${titleSizeClass()}`} aria-label={entry().title}>348 <span class="peach-entry-name-text">349 <Show350 when={useAnimatedTitle()}351 fallback={entry().title}352 >353 <For each={entry().title.split("")}>354 {(ch, i) => (355 <span class="peach-entry-name-char" style={`--char-index:${i()}`}>356 {ch === " " ? "\u00A0" : ch}357 </span>358 )}359 </For>360 </Show>361 </span>362 </h1>363 </button>364 }365 >366 <div367 class="peach-inline-title-wrap"368 onFocusOut={(event) => handleEditorFocusOut("title", event)}369 >370 <input371 class="peach-inline-title-input"372 value={draft()}373 onInput={(e) => setDraft(e.currentTarget.value)}374 onKeyDown={onEditKeyDown}375 ref={autoFocus}376 disabled={saving()}377 />378 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />379 </div>380 </Show>381 </div>382383 {}384 <Show when={editingField() !== "title"}>385 <Show386 when={confirmDelete()}387 fallback={388 <button389 class="peach-entry-trash-btn"390 onClick={() => setConfirmDelete(true)}391 aria-label="Move to trash"392 title="Delete entry"393 >394 <svg width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">395 <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />396 <path d="M4.5 6l.3 3.5M7.5 6l-.3 3.5" />397 </svg>398 </button>399 }400 >401 <div class="peach-entry-trash-confirm">402 <span class="peach-entry-trash-confirm-label">trash?</span>403 <button404 class="peach-btn peach-btn--sm peach-btn--danger"405 style="font-size:10px;padding:2px 8px;min-height:22px;"406 onClick={() => { setConfirmDelete(false); props.onDelete(); }}407 >408 yes409 </button>410 <button411 class="peach-btn peach-btn--ghost peach-btn--sm"412 style="font-size:10px;padding:2px 8px;min-height:22px;"413 onClick={() => setConfirmDelete(false)}414 >415 no416 </button>417 </div>418 </Show>419 </Show>420 </div>421422 {}423 <Show when={entry().type !== "note" && entry().type !== "card"}>424 <div class="peach-domain" style="position:relative;z-index:1;">425 <Show426 when={editingField() === "url"}427 fallback={428 <div429 class="peach-domain-row peach-inline-clickable"430 title="Click to edit URL"431 onClick={() => startEdit("url", entry().url ?? "")}432 >433 <Show when={faviconSrc()}>434 <img src={faviconSrc()!} width="16" height="16" alt="" style="border-radius:3px;margin-right:5px;flex-shrink:0;" />435 </Show>436 <span class="peach-domain-value">437 {displayDomain() || (entry().url ? entry().url : "add a website url")}438 </span>439 <span class="peach-inline-edit-hint">edit</span>440 </div>441 }442 >443 <div444 class="peach-inline-field-wrap"445 onFocusOut={(event) => handleEditorFocusOut("url", event)}446 >447 <input448 class="peach-inline-input"449 type="url"450 placeholder="https://"451 value={draft()}452 onInput={(e) => setDraft(e.currentTarget.value)}453 onKeyDown={onEditKeyDown}454 ref={autoFocus}455 disabled={saving()}456 />457 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />458 </div>459 </Show>460 </div>461 </Show>462463 {}464 <Show when={entry().tags && entry().tags!.length > 0}>465 <div style="display:flex;flex-wrap:wrap;gap:5px;margin:8px 0;position:relative;z-index:1;">466 <For each={entry().tags!}>467 {(tag) => <span class="peach-tag-chip" style="font-size:10px;padding:2px 8px;">{tag}</span>}468 </For>469 </div>470 </Show>471472 {}473 <Show when={entry().type === "note"}>474 <div class="peach-entry-note-toolbar-row">475 <Toolbar exec={noteExec} class="peach-note-toolbar-inline" />476 </div>477 <section class="peach-fields peach-note-body" style="position:relative;z-index:1;">478 <NoteEditorInline479 entryKey={entry().id}480 initialContent={entry().notes ?? ""}481 onReady={(el) => { setNoteEditorEl(el); lastSavedNote = null; }}482 onBlur={saveNoteInline}483 autoFocus={false}484 />485 </section>486 </Show>487488 {}489 <Show when={entry().type === "card"}>490 <section class="peach-fields" style="position:relative;z-index:1;">491 {}492 <Show when={entry().last4}>493 <div class="peach-field-card" style="padding-bottom:4px;">494 <span class="peach-label">495 <Show when={entry().network && entry().network !== "unknown"}>496 <span class="peach-card-network-badge" style="margin-left:0;margin-right:6px;">{entry().network}</span>497 </Show>498 {entry().cardNickname || `•••• ${entry().last4}`}499 </span>500 </div>501 </Show>502503 {}504 <div class="peach-field-card">505 <span class="peach-label">cardholder name</span>506 <div class="peach-field-value-row">507 <span class="peach-field-value">{entry().cardholderName || "—"}</span>508 <Show when={entry().cardholderName}>509 <button510 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cardholderName" ? "is-copied" : ""}`}511 onClick={(e) => void copyField(entry().cardholderName!, "cardholderName", e.currentTarget as HTMLButtonElement)}512 aria-label="Copy cardholder name"513 >514 <CopyGlyph /><span class="peach-copy-sheen" />515 </button>516 </Show>517 </div>518 </div>519520 {}521 <div class="peach-field-card">522 <span class="peach-label">card number</span>523 <div class="peach-field-value-row">524 <span class={`peach-field-value peach-card-number`}>525 <Show when={showCardNumber()} fallback={526 <span>{`•••• •••• •••• ${entry().last4 ?? "••••"}`}</span>527 }>528 <span>{entry().cardNumber ?? ""}</span>529 </Show>530 </span>531 <div class="peach-field-actions">532 <button533 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"534 onClick={() => setShowCardNumber(!showCardNumber())}535 aria-label={showCardNumber() ? "Hide card number" : "Reveal card number"}536 >537 <Show when={showCardNumber()} fallback={<EyeOpenGlyph />}>538 <EyeClosedGlyph />539 </Show>540 </button>541 <Show when={entry().cardNumber}>542 <button543 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cardNumber" ? "is-copied" : ""}`}544 onClick={(e) => {545 const cardNum = entry().cardNumber!;546 if (entry().canary?.webhookUrl) {547 void sendMessage({548 type: "FIRE_CANARY_PING",549 payload: { webhookUrl: entry().canary!.webhookUrl, entryTitle: entry().title, trigger: "password_copied" },550 });551 }552 void copyField(cardNum, "cardNumber", e.currentTarget as HTMLButtonElement);553 }}554 aria-label="Copy card number"555 >556 <CopyGlyph /><span class="peach-copy-sheen" />557 </button>558 </Show>559 </div>560 </div>561 </div>562563 {}564 <Show when={entry().expMonth || entry().expYear}>565 <div class="peach-field-card">566 <span class="peach-label">expiry</span>567 <div class="peach-field-value-row">568 <span class="peach-field-value">{entry().expMonth} / {entry().expYear}</span>569 <button570 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "expiry" ? "is-copied" : ""}`}571 onClick={(e) => void copyField(`${entry().expMonth}/${entry().expYear}`, "expiry", e.currentTarget as HTMLButtonElement)}572 aria-label="Copy expiry"573 >574 <CopyGlyph /><span class="peach-copy-sheen" />575 </button>576 </div>577 </div>578 </Show>579580 {}581 <Show when={entry().cvc}>582 <div class="peach-field-card">583 <span class="peach-label">CVC</span>584 <div class="peach-field-value-row">585 <span class="peach-field-value">586 <Show when={showCardCvc()} fallback={<span class="peach-password-mask">•••</span>}>587 <span>{entry().cvc}</span>588 </Show>589 </span>590 <div class="peach-field-actions">591 <button592 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"593 onClick={() => setShowCardCvc(!showCardCvc())}594 aria-label={showCardCvc() ? "Hide CVC" : "Show CVC"}595 >596 <Show when={showCardCvc()} fallback={<EyeOpenGlyph />}>597 <EyeClosedGlyph />598 </Show>599 </button>600 <button601 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cvc" ? "is-copied" : ""}`}602 onClick={(e) => void copyField(entry().cvc!, "cvc", e.currentTarget as HTMLButtonElement)}603 aria-label="Copy CVC"604 >605 <CopyGlyph /><span class="peach-copy-sheen" />606 </button>607 </div>608 </div>609 </div>610 </Show>611 </section>612 </Show>613614 {}615 <Show when={entry().type !== "note" && entry().type !== "card"}>616 <section class="peach-fields" style="position:relative;z-index:1;">617618 {}619 <div class="peach-field-card" classList={{ "is-editing": editingField() === "username" }}>620 <span class="peach-label">username</span>621 <Show622 when={editingField() === "username"}623 fallback={624 <div class="peach-field-value-row">625 <span626 class="peach-field-value peach-inline-clickable"627 title="Click to edit"628 onClick={() => startEdit("username", entry().username ?? "")}629 >630 {entry().username || <span style="color:var(--t2);font-style:italic;">click to add username</span>}631 </span>632 <Show when={entry().username}>633 <button634 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "username" ? "is-copied" : ""}`}635 onClick={(e) => void copyField(entry().username!, "username", e.currentTarget as HTMLButtonElement)}636 aria-label="Copy username"637 >638 <CopyGlyph /><span class="peach-copy-sheen" />639 </button>640 </Show>641 </div>642 }643 >644 <div645 class="peach-inline-field-wrap"646 onFocusOut={(event) => handleEditorFocusOut("username", event)}647 >648 <input649 class="peach-inline-input"650 value={draft()}651 onInput={(e) => setDraft(e.currentTarget.value)}652 onKeyDown={onEditKeyDown}653 ref={autoFocus}654 disabled={saving()}655 autocomplete="off"656 />657 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />658 </div>659 </Show>660 </div>661662 {}663 <div class="peach-field-card" classList={{ "is-editing": editingField() === "password" }}>664 <span class="peach-label">password</span>665 <Show666 when={editingField() === "password"}667 fallback={668 <div class="peach-field-value-row">669 <span670 class="peach-password-value"671 classList={{ "is-revealed": showPassword(), "is-breached": (entry().breach ?? 0) > 0 }}672 style="cursor:pointer;"673 title="Click to edit password"674 onClick={() => startEdit("password", entry().password ?? "")}675 >676 <Show677 when={showPassword()}678 fallback={<span class="peach-password-mask">...............</span>}679 >680 <span class="peach-password-text">{entry().password ?? ""}</span>681 </Show>682 </span>683 <div class="peach-field-actions">684 <button685 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"686 onClick={() => setShowPassword(!showPassword())}687 aria-label="Reveal password"688 >689 <Show when={showPassword()} fallback={<EyeOpenGlyph />}>690 <EyeClosedGlyph />691 </Show>692 </button>693 <button694 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "password" ? "is-copied" : ""}`}695 onClick={(e) => { if (entry().password) void copyField(entry().password!, "password", e.currentTarget as HTMLButtonElement); }}696 aria-label="Copy password"697 >698 <CopyGlyph /><span class="peach-copy-sheen" />699 </button>700 </div>701 </div>702 }703 >704 {}705 <div706 class="peach-inline-field-wrap"707 onFocusOut={(event) => handleEditorFocusOut("password", event)}708 >709 <div class="peach-password-edit-row">710 <input711 class="peach-inline-input"712 type={showPassword() ? "text" : "password"}713 placeholder="paste or type a password"714 value={draft()}715 onInput={(e) => setDraft(e.currentTarget.value)}716 onKeyDown={(e) => {717 if (e.key === "Enter") { e.preventDefault(); void saveField(); }718 if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }719 }}720 ref={autoFocus}721 disabled={saving()}722 autocomplete="new-password"723 />724 <button725 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"726 type="button"727 onClick={() => setShowPassword(!showPassword())}728 aria-label={showPassword() ? "Hide" : "Show"}729 >730 <Show when={showPassword()} fallback={<EyeOpenGlyph />}>731 <EyeClosedGlyph />732 </Show>733 </button>734 </div>735 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />736 <button737 class="peach-generate-toggle-link"738 type="button"739 onClick={() => setShowGenerator(!showGenerator())}740 >741 <svg width="10" height="10" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">742 <path d="M11 2.5A5.5 5.5 0 1 0 12.5 7" />743 <path d="M9.5 2.5H11V1" />744 </svg>745 {showGenerator() ? "hide generator" : "generate"}746 </button>747 <Show when={showGenerator()}>748 <GeneratorDrawer749 open={true}750 compact={true}751 onUse={(pw) => { setDraft(pw); setShowGenerator(false); }}752 />753 </Show>754 </div>755 </Show>756 </div>757758 </section>759 </Show>760761 {}762 <Show when={entry().type !== "note" && entry().type !== "card"}>763 <Show764 when={entry().notes || editingField() === "notes"}765 fallback={766 <button767 class="peach-btn peach-btn--ghost peach-btn--sm peach-add-notes-btn"768 onClick={() => startEdit("notes", "")}769 >770 + add notes771 </button>772 }773 >774 <div style="position:relative;z-index:1;margin-top:6px;">775 <span class="peach-label" style="display:block;margin-bottom:4px;">notes</span>776 <Show777 when={editingField() === "notes"}778 fallback={779 <div780 class="peach-note-content peach-inline-clickable"781 style="cursor:text;white-space:pre-wrap;"782 onClick={() => startEdit("notes", entry().notes ?? "")}783 >784 {entry().notes}785 </div>786 }787 >788 <div789 class="peach-inline-field-wrap"790 onFocusOut={(event) => handleEditorFocusOut("notes", event)}791 >792 <textarea793 class="peach-inline-textarea"794 value={draft()}795 onInput={(e) => setDraft(e.currentTarget.value)}796 onKeyDown={(e) => { if (e.key === "Escape") { e.preventDefault(); cancelEdit(); } }}797 ref={(el) => requestAnimationFrame(() => el?.focus())}798 rows={4}799 disabled={saving()}800 />801 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />802 </div>803 </Show>804 </div>805 </Show>806 </Show>807808 {}809 <Show when={totpState()}>810 {(state) => (811 <section class="peach-totp-band">812 <Show813 when={editingField() === "totp"}814 fallback={815 <>816 <div817 class="peach-totp-main peach-inline-clickable"818 onClick={() => startEdit("totp", entry().totp ?? "")}819 >820 <span class="peach-label">2-factor</span>821 <div class="peach-totp-code-row">822 <p class={`peach-totp-code${totpHot() ? " is-hot" : ""}`}>823 <span class="peach-totp-code-text">824 {state().code.slice(0, 3)} {state().code.slice(3)}825 </span>826 </p>827 <button828 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn peach-totp-copy-btn ${copied() === "totp" ? "is-copied" : ""}`}829 onClick={(e) => void copyField(state().code.replace(" ", ""), "totp", e.currentTarget as HTMLButtonElement)}830 aria-label="Copy TOTP code"831 >832 <CopyGlyph /><span class="peach-copy-sheen" />833 </button>834 </div>835 </div>836 <div class="peach-totp-meta">837 <span class="peach-label">expires</span>838 <div class="peach-totp-countdown">{state().remaining}s</div>839 <svg class="peach-totp-ring" width="44" height="44" viewBox="0 0 44 44">840 <circle cx="22" cy="22" r="17" class="peach-totp-ring-track" />841 <circle cx="22" cy="22" r="17" class="peach-totp-ring-progress"842 stroke-dasharray="106.8"843 stroke-dashoffset={(1 - state().remaining / 30) * 106.8}844 />845 </svg>846 </div>847 </>848 }849 >850 <div851 class="peach-inline-field-wrap peach-inline-field-wrap--totp"852 onFocusOut={(event) => handleEditorFocusOut("totp", event)}853 >854 <span class="peach-label">2-factor</span>855 <input856 class="peach-inline-input"857 value={draft()}858 onInput={(e) => setDraft(e.currentTarget.value)}859 onKeyDown={onEditKeyDown}860 ref={autoFocus}861 disabled={saving()}862 placeholder="otpauth://... or base32 secret"863 autocomplete="off"864 />865 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />866 </div>867 </Show>868 </section>869 )}870 </Show>871872 {}873 <div style="height:4px;" />874 </>875 )}876 </Show>877 </div>878879 </>880 );881}882883884885function InlineSaveCancelRow(props: { onSave: () => void; onCancel: () => void; saving: boolean }) {886 return (887 <div class="peach-inline-actions">888 <button889 class="peach-inline-save-btn"890 onClick={props.onSave}891 disabled={props.saving}892 aria-label="Save"893 title="Save (Enter)"894 >895 <svg width="11" height="9" viewBox="0 0 11 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">896 <path d="M1 4.5L4 7.5L10 1.5" />897 </svg>898 </button>899 <button900 class="peach-inline-cancel-btn"901 onClick={props.onCancel}902 disabled={props.saving}903 aria-label="Cancel"904 title="Cancel (Esc)"905 >906 <svg width="9" height="9" viewBox="0 0 9 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round">907 <path d="M1 1L8 8M8 1L1 8" />908 </svg>909 </button>910 </div>911 );912}913914function EmptyVaultState() {915 return (916 <>917 <div class="peach-entry-name-wrap" style="position:relative;z-index:1;">918 <div class="peach-entry-name">919 <span class="peach-entry-name-text">920 <span class="peach-entry-name-char" style="--char-index:0">e</span>921 <span class="peach-entry-name-char" style="--char-index:1">m</span>922 <span class="peach-entry-name-char" style="--char-index:2">p</span>923 <span class="peach-entry-name-char" style="--char-index:3">t</span>924 <span class="peach-entry-name-char" style="--char-index:4">y</span>925 </span>926 </div>927 </div>928 <div class="peach-empty-copy">929 <span class="peach-label">vault</span>930 <p>Your vault is ready for its first entry.</p>931 </div>932 </>933 );934}935936function CopyGlyph() {937 return (938 <span class="peach-copy-icon">939 <svg class="peach-copy-icon-svg" width="12" height="12" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">940 <rect x="4" y="4" width="8" height="9" rx="1.5" />941 <path d="M10 4V2.5A1.5 1.5 0 0 0 8.5 1h-6A1.5 1.5 0 0 0 1 2.5v7A1.5 1.5 0 0 0 2.5 11H4" />942 </svg>943 </span>944 );945}946947function EyeClosedGlyph() {948 return (949 <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">950 <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />951 <path d="M3.5 5.5L2.5 7.5" />952 <path d="M7 6.5V8.5" />953 <path d="M10.5 5.5L11.5 7.5" />954 </svg>955 );956}957958function EyeOpenGlyph() {959 return (960 <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">961 <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" />962 <circle cx="7" cy="5" r="1.75" />963 </svg>964 );965}966967function ChevronGlyph(props: { open: boolean }) {968 return (969 <svg970 width="8"971 height="8"972 viewBox="0 0 8 8"973 fill="none"974 stroke="currentColor"975 stroke-width="1.3"976 stroke-linecap="round"977 stroke-linejoin="round"978 style={`transform: rotate(${props.open ? 90 : 0}deg); transition: transform 160ms ease;`}979 >980 <path d="M2 1L6 4L2 7" />981 </svg>982 );983}984985function CanarySection(props: { entry: VaultEntry; onSaved: () => void | Promise<void> }) {986 const [enabled, setEnabled] = createSignal(Boolean(props.entry.canary));987 const [webhookUrl, setWebhookUrl] = createSignal(props.entry.canary?.webhookUrl ?? "");988 const [label, setLabel] = createSignal(props.entry.canary?.label ?? "");989 const [testResult, setTestResult] = createSignal<string | null>(null);990 const [testing, setTesting] = createSignal(false);991992 async function save() {993 const url = webhookUrl().trim();994 const nextLabel = label().trim() || undefined;995 const canary = enabled() && url996 ? {997 webhookUrl: url,998 label: nextLabel,999 lastPinged: props.entry.canary?.lastPinged,1000 pingCount: props.entry.canary?.pingCount,1001 }1002 : undefined;1003 await sendMessage({1004 type: "UPDATE_ENTRY",1005 payload: { id: props.entry.id, patch: { canary } },1006 });1007 await props.onSaved();1008 }10091010 async function testPing() {1011 const url = webhookUrl().trim();1012 if (!url) return;1013 setTesting(true);1014 setTestResult(null);1015 const res = await sendMessage({1016 type: "FIRE_CANARY_PING",1017 payload: { webhookUrl: url, entryTitle: props.entry.title, trigger: "test" },1018 }) as { ok: boolean; error?: string };1019 setTestResult(res.ok ? "sent ✓" : `failed — ${res.error ?? "check URL"}`);1020 setTesting(false);1021 }10221023 return (1024 <div class="peach-canary-section">1025 <div class="peach-field-value-row">1026 <span class="peach-field-value" style="font-size:12px;">canary alert</span>1027 <button1028 class={`peach-toggle ${enabled() ? "is-on" : ""}`}1029 onClick={() => { setEnabled(!enabled()); void save(); }}1030 aria-label="Toggle canary"1031 >1032 <span class="peach-toggle-knob" />1033 </button>1034 </div>1035 <p style="font-size:10px;color:var(--peach-t2);margin:4px 0 0;line-height:1.5;">1036 Fires a silent webhook when this entry's password or username is copied. Great for honeypot credentials.1037 </p>1038 <Show when={enabled()}>1039 <div class="peach-inline-field-wrap" style="margin-top:8px;">1040 <input1041 class="peach-inline-input"1042 placeholder="https://hooks.zapier.com/..."1043 value={webhookUrl()}1044 onInput={(e) => setWebhookUrl(e.currentTarget.value)}1045 onBlur={() => void save()}1046 />1047 <input1048 class="peach-inline-input"1049 placeholder="label (optional, e.g. AWS decoy)"1050 value={label()}1051 onInput={(e) => setLabel(e.currentTarget.value)}1052 onBlur={() => void save()}1053 />1054 <div style="display:flex;align-items:center;gap:8px;margin-top:4px;">1055 <button1056 class="peach-btn peach-btn--ghost peach-btn--sm"1057 onClick={() => void testPing()}1058 disabled={testing() || !webhookUrl().trim()}1059 >1060 {testing() ? "sending..." : "test ping"}1061 </button>1062 <Show when={testResult()}>1063 <span style="font-size:10px;color:var(--peach-t2);font-family:var(--font-mono);">{testResult()}</span>1064 </Show>1065 </div>1066 <Show when={props.entry.canary?.lastPinged}>1067 <p style="font-size:10px;color:var(--peach-t2);margin-top:4px;">1068 Last triggered: {new Date(props.entry.canary!.lastPinged!).toLocaleDateString()}1069 {" · "}{props.entry.canary?.pingCount ?? 0} total alerts1070 </p>1071 </Show>1072 </div>1073 </Show>1074 </div>1075 );1076}