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";89type EditableField = "title" | "url" | "username" | "password" | "notes" | "totp" | null;1011interface EntryProps {12 entry: VaultEntry | null;13 onDelete: () => void;14 15 onFieldSaved: () => void | Promise<void>;16 onEscape?: () => void;17}1819export function EntryView(props: EntryProps) {20 const [editingField, setEditingField] = createSignal<EditableField>(null);21 const [draft, setDraft] = createSignal("");22 const [saving, setSaving] = createSignal(false);23 const [showPassword, setShowPassword] = createSignal(false);24 const [showGenerator, setShowGenerator] = createSignal(false);25 const [copied, setCopied] = createSignal<string | null>(null);26 const [totpTick, setTotpTick] = createSignal(0);27 const [confirmDelete, setConfirmDelete] = createSignal(false);28 const [totpHot, setTotpHot] = createSignal(false);29 const [faviconSrc, setFaviconSrc] = createSignal<string | null>(null);3031 const interval = window.setInterval(() => setTotpTick((n) => n + 1), 1000);32 onCleanup(() => window.clearInterval(interval));3334 const displayDomain = createMemo(() =>35 props.entry?.url ? getDisplayDomain(props.entry.url) : ""36 );3738 createEffect(() => {39 const domain = displayDomain();40 setFaviconSrc(domain ? faviconUrl(domain) : null);41 });4243 const totpState = createMemo(() => {44 void totpTick();45 if (!props.entry?.totp) return null;46 return generateTOTP(props.entry.totp);47 });4849 let prevTotpCode: string | undefined;50 createEffect(() => {51 const state = totpState();52 if (!state) { prevTotpCode = undefined; return; }53 if (prevTotpCode !== undefined && state.code !== prevTotpCode) {54 setTotpHot(true);55 window.setTimeout(() => setTotpHot(false), 3000);56 }57 prevTotpCode = state.code;58 });5960 const totpProgress = createMemo(() => {61 const state = totpState();62 if (!state) return 0;63 return ((state.remaining - 1) / 29) * 100;64 });6566 createEffect(() => {67 const shell = document.getElementById("hd-shell");68 if (!shell) return;6970 const state = totpState();71 if (!state) {72 delete shell.dataset.totpActive;73 shell.style.removeProperty("--peach-totp-progress");74 return;75 }7677 shell.dataset.totpActive = "true";78 shell.style.setProperty(79 "--peach-totp-progress",80 `${Math.max(0, totpProgress())}%`81 );8283 onCleanup(() => {84 delete shell.dataset.totpActive;85 shell.style.removeProperty("--peach-totp-progress");86 });87 });8889 const titleSizeClass = createMemo(() => {90 const length = props.entry?.title.length ?? 0;91 if (length >= 28) return "peach-entry-name--xxl";92 if (length >= 20) return "peach-entry-name--xl";93 if (length >= 14) return "peach-entry-name--lg";94 return "";95 });96 const useAnimatedTitle = createMemo(() => (props.entry?.title.length ?? 0) <= 13);9798 99100 function startEdit(field: EditableField, value: string) {101 setEditingField(field);102 setDraft(value);103 setShowGenerator(false);104 setConfirmDelete(false);105 }106107 async function saveField() {108 const field = editingField();109 const entry = props.entry;110 if (!field || !entry) return;111 112 let value = field === "password" ? draft() : draft().trim();113 if (field === "totp" && value.startsWith("otpauth://")) {114 const parsed = parseOtpauthURI(value);115 value = parsed?.secret ?? value;116 }117 if (field === "password" && entry.type !== "note" && !value) {118 return;119 }120 setSaving(true);121 try {122 await sendMessage({123 type: "UPDATE_ENTRY",124 payload: { id: entry.id, patch: { [field]: value } },125 });126 triggerRing();127 setEditingField(null);128 setShowGenerator(false);129 await props.onFieldSaved();130 } catch {131 132 } finally {133 setSaving(false);134 }135 }136137 function cancelEdit() {138 setEditingField(null);139 setDraft("");140 setShowGenerator(false);141 }142143 function fieldValue(entry: VaultEntry, field: Exclude<EditableField, null>): string {144 switch (field) {145 case "title":146 return entry.title;147 case "url":148 return entry.url ?? "";149 case "username":150 return entry.username ?? "";151 case "password":152 return entry.password ?? "";153 case "notes":154 return entry.notes ?? "";155 case "totp":156 return entry.totp ?? "";157 }158 }159160 function handleEditorFocusOut(161 field: Exclude<EditableField, null>,162 event: FocusEvent & { currentTarget: HTMLElement; relatedTarget: EventTarget | null }163 ) {164 const nextTarget = event.relatedTarget;165 if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return;166167 const entry = props.entry;168 if (!entry) return;169170 const currentValue = fieldValue(entry, field);171 const nextValue = field === "password" ? draft() : draft().trim();172 if (nextValue === currentValue) {173 cancelEdit();174 }175 }176177 async function savePassword(password: string) {178 const entry = props.entry;179 if (!entry) return;180 setSaving(true);181 try {182 await sendMessage({183 type: "UPDATE_ENTRY",184 payload: { id: entry.id, patch: { password } },185 });186 triggerRing();187 setShowGenerator(false);188 await props.onFieldSaved();189 } catch {190 191 } finally {192 setSaving(false);193 }194 }195196 197198 async function copyField(text: string, label: string, button?: HTMLButtonElement) {199 const response = await sendMessage({ type: "COPY", payload: { text, label } });200 if ((response as { ok: boolean }).ok) {201 const entry = props.entry;202 if (entry?.canary?.webhookUrl) {203 const trigger =204 label === "password"205 ? "password_copied"206 : label === "username"207 ? "username_copied"208 : undefined;209 if (trigger) {210 void sendMessage({211 type: "FIRE_CANARY_PING",212 payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger },213 });214 }215 }216 triggerRing();217 triggerPipPulse();218 setCopied(null);219 requestAnimationFrame(() => setCopied(label));220 if (button) {221 button.classList.remove("is-copied", "is-retreat");222 void button.offsetWidth;223 const drain = document.createElement("span");224 drain.className = "peach-copy-drain";225 const bar = document.createElement("span"); bar.className = "peach-copy-drain-bar";226 drain.appendChild(bar);227 button.appendChild(drain);228 window.setTimeout(() => {229 button.classList.remove("is-copied");230 button.classList.add("is-retreat");231 drain.remove();232 window.setTimeout(() => button.classList.remove("is-retreat"), 160);233 }, 1800);234 }235 window.setTimeout(() => setCopied(null), 1800);236 }237 }238239 240241 createEffect(() => {242 if (!props.entry) return;243 const handler = (e: KeyboardEvent) => {244 const inInput = e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement;245 if (inInput) return;246 switch (e.key) {247 case "c": e.preventDefault();248 if (props.entry?.password) void copyField(props.entry.password, "password"); break;249 case "u": e.preventDefault();250 if (props.entry?.username) void copyField(props.entry.username, "username"); break;251 case "Escape": e.preventDefault(); props.onEscape?.(); break;252 }253 };254 window.addEventListener("keydown", handler);255 onCleanup(() => window.removeEventListener("keydown", handler));256 });257258 259260 function autoFocus(el: HTMLInputElement | HTMLTextAreaElement) {261 requestAnimationFrame(() => { el.focus(); el.select?.(); });262 }263264 function onEditKeyDown(e: KeyboardEvent) {265 if (e.key === "Enter" && !(e.target instanceof HTMLTextAreaElement)) {266 e.preventDefault();267 void saveField();268 }269 if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }270 }271272 return (273 <>274 <div275 class="peach-ghost"276 aria-hidden="true"277 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;"278 >279 {props.entry?.title || "empty"}280 </div>281282 <div class="peach-stage" style="position:relative;">283 <Show when={props.entry} fallback={<EmptyVaultState />}>284 {(entry) => (285 <>286 {}287 <Show when={entry().type !== "note" && (entry().breach ?? 0) > 0}>288 <div class="peach-breach-badge">289 <svg width="10" height="10" viewBox="0 0 12 12" fill="none" stroke="var(--breach)" stroke-width="1.5" stroke-linecap="round">290 <path d="M6 1L11 10H1L6 1z" />291 <line x1="6" y1="5" x2="6" y2="7.5" />292 <circle cx="6" cy="9" r="0.5" fill="var(--breach)" stroke="none" />293 </svg>294 <span style="flex:1">found in a known data breach</span>295 <button296 class="peach-btn peach-btn--sm peach-btn--accent peach-breach-action"297 onClick={() => { startEdit("password", entry().password ?? ""); setShowGenerator(true); }}298 >299 Change password300 </button>301 </div>302 </Show>303304 {}305 <div class="peach-entry-header">306 <div class="peach-entry-header-name">307 <Show308 when={editingField() === "title"}309 fallback={310 <button311 class="peach-entry-name-button"312 title="Click to edit title"313 onClick={() => startEdit("title", entry().title)}314 >315 <h1 class={`peach-entry-name ${titleSizeClass()}`} aria-label={entry().title}>316 <span class="peach-entry-name-text">317 <Show318 when={useAnimatedTitle()}319 fallback={entry().title}320 >321 <For each={entry().title.split("")}>322 {(ch, i) => (323 <span class="peach-entry-name-char" style={`--char-index:${i()}`}>324 {ch === " " ? "\u00A0" : ch}325 </span>326 )}327 </For>328 </Show>329 </span>330 </h1>331 </button>332 }333 >334 <div335 class="peach-inline-title-wrap"336 onFocusOut={(event) => handleEditorFocusOut("title", event)}337 >338 <input339 class="peach-inline-title-input"340 value={draft()}341 onInput={(e) => setDraft(e.currentTarget.value)}342 onKeyDown={onEditKeyDown}343 ref={autoFocus}344 disabled={saving()}345 />346 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />347 </div>348 </Show>349 </div>350351 {}352 <Show when={editingField() !== "title"}>353 <Show354 when={confirmDelete()}355 fallback={356 <button357 class="peach-entry-trash-btn"358 onClick={() => setConfirmDelete(true)}359 aria-label="Move to trash"360 title="Delete entry"361 >362 <svg width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">363 <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />364 <path d="M4.5 6l.3 3.5M7.5 6l-.3 3.5" />365 </svg>366 </button>367 }368 >369 <div class="peach-entry-trash-confirm">370 <span class="peach-entry-trash-confirm-label">trash?</span>371 <button372 class="peach-btn peach-btn--sm peach-btn--danger"373 style="font-size:10px;padding:2px 8px;min-height:22px;"374 onClick={() => { setConfirmDelete(false); props.onDelete(); }}375 >376 yes377 </button>378 <button379 class="peach-btn peach-btn--ghost peach-btn--sm"380 style="font-size:10px;padding:2px 8px;min-height:22px;"381 onClick={() => setConfirmDelete(false)}382 >383 no384 </button>385 </div>386 </Show>387 </Show>388 </div>389390 {}391 <Show when={entry().type !== "note"}>392 <div class="peach-domain" style="position:relative;z-index:1;">393 <Show394 when={editingField() === "url"}395 fallback={396 <div397 class="peach-domain-row peach-inline-clickable"398 title="Click to edit URL"399 onClick={() => startEdit("url", entry().url ?? "")}400 >401 <Show when={faviconSrc()}>402 <img src={faviconSrc()!} width="16" height="16" alt="" style="border-radius:3px;margin-right:5px;flex-shrink:0;" />403 </Show>404 <span class="peach-domain-value">405 {displayDomain() || (entry().url ? entry().url : "add a website url")}406 </span>407 <span class="peach-inline-edit-hint">edit</span>408 </div>409 }410 >411 <div412 class="peach-inline-field-wrap"413 onFocusOut={(event) => handleEditorFocusOut("url", event)}414 >415 <input416 class="peach-inline-input"417 type="url"418 placeholder="https://"419 value={draft()}420 onInput={(e) => setDraft(e.currentTarget.value)}421 onKeyDown={onEditKeyDown}422 ref={autoFocus}423 disabled={saving()}424 />425 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />426 </div>427 </Show>428 </div>429 </Show>430431 {}432 <Show when={entry().tags && entry().tags!.length > 0}>433 <div style="display:flex;flex-wrap:wrap;gap:5px;margin:8px 0;position:relative;z-index:1;">434 <For each={entry().tags!}>435 {(tag) => <span class="peach-tag-chip" style="font-size:10px;padding:2px 8px;">{tag}</span>}436 </For>437 </div>438 </Show>439440 {}441 <Show when={entry().type === "note"}>442 <section class="peach-fields" style="position:relative;z-index:1;">443 <Show444 when={editingField() === "notes"}445 fallback={446 <div447 class="peach-note-content peach-inline-clickable"448 style="display:block;white-space:pre-wrap;overflow:auto;max-height:340px;cursor:text;"449 title="Click to edit"450 onClick={() => startEdit("notes", entry().notes ?? "")}451 >452 {entry().notes || <span style="color:var(--t2);font-style:italic;">click to add notes</span>}453 </div>454 }455 >456 <div457 class="peach-inline-field-wrap"458 onFocusOut={(event) => handleEditorFocusOut("notes", event)}459 >460 <textarea461 class="peach-inline-textarea"462 value={draft()}463 onInput={(e) => setDraft(e.currentTarget.value)}464 onKeyDown={(e) => { if (e.key === "Escape") { e.preventDefault(); cancelEdit(); } }}465 ref={(el) => requestAnimationFrame(() => el?.focus())}466 rows={6}467 disabled={saving()}468 />469 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />470 </div>471 </Show>472 </section>473 </Show>474475 {}476 <Show when={entry().type !== "note"}>477 <section class="peach-fields" style="position:relative;z-index:1;">478479 {}480 <div class="peach-field-card" classList={{ "is-editing": editingField() === "username" }}>481 <span class="peach-label">username</span>482 <Show483 when={editingField() === "username"}484 fallback={485 <div class="peach-field-value-row">486 <span487 class="peach-field-value peach-inline-clickable"488 title="Click to edit"489 onClick={() => startEdit("username", entry().username ?? "")}490 >491 {entry().username || <span style="color:var(--t2);font-style:italic;">click to add username</span>}492 </span>493 <Show when={entry().username}>494 <button495 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "username" ? "is-copied" : ""}`}496 onClick={(e) => void copyField(entry().username!, "username", e.currentTarget as HTMLButtonElement)}497 aria-label="Copy username"498 >499 <CopyGlyph /><span class="peach-copy-sheen" />500 </button>501 </Show>502 </div>503 }504 >505 <div506 class="peach-inline-field-wrap"507 onFocusOut={(event) => handleEditorFocusOut("username", event)}508 >509 <input510 class="peach-inline-input"511 value={draft()}512 onInput={(e) => setDraft(e.currentTarget.value)}513 onKeyDown={onEditKeyDown}514 ref={autoFocus}515 disabled={saving()}516 autocomplete="off"517 />518 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />519 </div>520 </Show>521 </div>522523 {}524 <div class="peach-field-card" classList={{ "is-editing": editingField() === "password" }}>525 <span class="peach-label">password</span>526 <Show527 when={editingField() === "password"}528 fallback={529 <div class="peach-field-value-row">530 <span531 class="peach-password-value"532 classList={{ "is-revealed": showPassword(), "is-breached": (entry().breach ?? 0) > 0 }}533 style="cursor:pointer;"534 title="Click to edit password"535 onClick={() => startEdit("password", entry().password ?? "")}536 >537 <Show538 when={showPassword()}539 fallback={<span class="peach-password-mask">...............</span>}540 >541 <span class="peach-password-text">{entry().password ?? ""}</span>542 </Show>543 </span>544 <div class="peach-field-actions">545 <button546 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"547 onClick={() => setShowPassword(!showPassword())}548 aria-label="Reveal password"549 >550 <Show when={showPassword()} fallback={<EyeOpenGlyph />}>551 <EyeClosedGlyph />552 </Show>553 </button>554 <button555 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "password" ? "is-copied" : ""}`}556 onClick={(e) => { if (entry().password) void copyField(entry().password!, "password", e.currentTarget as HTMLButtonElement); }}557 aria-label="Copy password"558 >559 <CopyGlyph /><span class="peach-copy-sheen" />560 </button>561 </div>562 </div>563 }564 >565 {}566 <div567 class="peach-inline-field-wrap"568 onFocusOut={(event) => handleEditorFocusOut("password", event)}569 >570 <div class="peach-password-edit-row">571 <input572 class="peach-inline-input"573 type={showPassword() ? "text" : "password"}574 placeholder="paste or type a password"575 value={draft()}576 onInput={(e) => setDraft(e.currentTarget.value)}577 onKeyDown={(e) => {578 if (e.key === "Enter") { e.preventDefault(); void saveField(); }579 if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }580 }}581 ref={autoFocus}582 disabled={saving()}583 autocomplete="new-password"584 />585 <button586 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"587 type="button"588 onClick={() => setShowPassword(!showPassword())}589 aria-label={showPassword() ? "Hide" : "Show"}590 >591 <Show when={showPassword()} fallback={<EyeOpenGlyph />}>592 <EyeClosedGlyph />593 </Show>594 </button>595 </div>596 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />597 <button598 class="peach-generate-toggle-link"599 type="button"600 onClick={() => setShowGenerator(!showGenerator())}601 >602 <svg width="10" height="10" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">603 <path d="M11 2.5A5.5 5.5 0 1 0 12.5 7" />604 <path d="M9.5 2.5H11V1" />605 </svg>606 {showGenerator() ? "hide generator" : "generate"}607 </button>608 <Show when={showGenerator()}>609 <GeneratorDrawer610 open={true}611 compact={true}612 onUse={(pw) => { setDraft(pw); setShowGenerator(false); }}613 />614 </Show>615 </div>616 </Show>617 </div>618619 </section>620 </Show>621622 {}623 <Show when={entry().type !== "note"}>624 <Show625 when={entry().notes || editingField() === "notes"}626 fallback={627 <button628 class="peach-btn peach-btn--ghost peach-btn--sm peach-add-notes-btn"629 onClick={() => startEdit("notes", "")}630 >631 + add notes632 </button>633 }634 >635 <div style="position:relative;z-index:1;margin-top:6px;">636 <span class="peach-label" style="display:block;margin-bottom:4px;">notes</span>637 <Show638 when={editingField() === "notes"}639 fallback={640 <div641 class="peach-note-content peach-inline-clickable"642 style="cursor:text;white-space:pre-wrap;"643 onClick={() => startEdit("notes", entry().notes ?? "")}644 >645 {entry().notes}646 </div>647 }648 >649 <div650 class="peach-inline-field-wrap"651 onFocusOut={(event) => handleEditorFocusOut("notes", event)}652 >653 <textarea654 class="peach-inline-textarea"655 value={draft()}656 onInput={(e) => setDraft(e.currentTarget.value)}657 onKeyDown={(e) => { if (e.key === "Escape") { e.preventDefault(); cancelEdit(); } }}658 ref={(el) => requestAnimationFrame(() => el?.focus())}659 rows={4}660 disabled={saving()}661 />662 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />663 </div>664 </Show>665 </div>666 </Show>667 </Show>668669 {}670 <Show when={totpState()}>671 {(state) => (672 <section class="peach-totp-band">673 <Show674 when={editingField() === "totp"}675 fallback={676 <>677 <div678 class="peach-totp-main peach-inline-clickable"679 onClick={() => startEdit("totp", entry().totp ?? "")}680 >681 <span class="peach-label">2-factor</span>682 <div class="peach-totp-code-row">683 <p class={`peach-totp-code${totpHot() ? " is-hot" : ""}`}>684 <span class="peach-totp-code-text">685 {state().code.slice(0, 3)} {state().code.slice(3)}686 </span>687 </p>688 <button689 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn peach-totp-copy-btn ${copied() === "totp" ? "is-copied" : ""}`}690 onClick={(e) => void copyField(state().code.replace(" ", ""), "totp", e.currentTarget as HTMLButtonElement)}691 aria-label="Copy TOTP code"692 >693 <CopyGlyph /><span class="peach-copy-sheen" />694 </button>695 </div>696 </div>697 <div class="peach-totp-meta">698 <span class="peach-label">expires</span>699 <div class="peach-totp-countdown">{state().remaining}s</div>700 <svg class="peach-totp-ring" width="44" height="44" viewBox="0 0 44 44">701 <circle cx="22" cy="22" r="17" class="peach-totp-ring-track" />702 <circle cx="22" cy="22" r="17" class="peach-totp-ring-progress"703 stroke-dasharray="106.8"704 stroke-dashoffset={(1 - state().remaining / 30) * 106.8}705 />706 </svg>707 </div>708 </>709 }710 >711 <div712 class="peach-inline-field-wrap peach-inline-field-wrap--totp"713 onFocusOut={(event) => handleEditorFocusOut("totp", event)}714 >715 <span class="peach-label">2-factor</span>716 <input717 class="peach-inline-input"718 value={draft()}719 onInput={(e) => setDraft(e.currentTarget.value)}720 onKeyDown={onEditKeyDown}721 ref={autoFocus}722 disabled={saving()}723 placeholder="otpauth://... or base32 secret"724 autocomplete="off"725 />726 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />727 </div>728 </Show>729 </section>730 )}731 </Show>732733 {}734 <div style="height:4px;" />735 </>736 )}737 </Show>738 </div>739 </>740 );741}742743744745function InlineSaveCancelRow(props: { onSave: () => void; onCancel: () => void; saving: boolean }) {746 return (747 <div class="peach-inline-actions">748 <button749 class="peach-inline-save-btn"750 onClick={props.onSave}751 disabled={props.saving}752 aria-label="Save"753 title="Save (Enter)"754 >755 <svg width="11" height="9" viewBox="0 0 11 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">756 <path d="M1 4.5L4 7.5L10 1.5" />757 </svg>758 </button>759 <button760 class="peach-inline-cancel-btn"761 onClick={props.onCancel}762 disabled={props.saving}763 aria-label="Cancel"764 title="Cancel (Esc)"765 >766 <svg width="9" height="9" viewBox="0 0 9 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round">767 <path d="M1 1L8 8M8 1L1 8" />768 </svg>769 </button>770 </div>771 );772}773774function EmptyVaultState() {775 return (776 <>777 <div class="peach-entry-name-wrap" style="position:relative;z-index:1;">778 <div class="peach-entry-name">779 <span class="peach-entry-name-text">780 <span class="peach-entry-name-char" style="--char-index:0">e</span>781 <span class="peach-entry-name-char" style="--char-index:1">m</span>782 <span class="peach-entry-name-char" style="--char-index:2">p</span>783 <span class="peach-entry-name-char" style="--char-index:3">t</span>784 <span class="peach-entry-name-char" style="--char-index:4">y</span>785 </span>786 </div>787 </div>788 <div class="peach-empty-copy">789 <span class="peach-label">vault</span>790 <p>Your vault is ready for its first entry.</p>791 </div>792 </>793 );794}795796function CopyGlyph() {797 return (798 <span class="peach-copy-icon">799 <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">800 <rect x="4" y="4" width="8" height="9" rx="1.5" />801 <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" />802 </svg>803 </span>804 );805}806807function EyeClosedGlyph() {808 return (809 <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">810 <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />811 <path d="M3.5 5.5L2.5 7.5" />812 <path d="M7 6.5V8.5" />813 <path d="M10.5 5.5L11.5 7.5" />814 </svg>815 );816}817818function EyeOpenGlyph() {819 return (820 <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">821 <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" />822 <circle cx="7" cy="5" r="1.75" />823 </svg>824 );825}826827function ChevronGlyph(props: { open: boolean }) {828 return (829 <svg830 width="8"831 height="8"832 viewBox="0 0 8 8"833 fill="none"834 stroke="currentColor"835 stroke-width="1.3"836 stroke-linecap="round"837 stroke-linejoin="round"838 style={`transform: rotate(${props.open ? 90 : 0}deg); transition: transform 160ms ease;`}839 >840 <path d="M2 1L6 4L2 7" />841 </svg>842 );843}844845function CanarySection(props: { entry: VaultEntry; onSaved: () => void | Promise<void> }) {846 const [enabled, setEnabled] = createSignal(Boolean(props.entry.canary));847 const [webhookUrl, setWebhookUrl] = createSignal(props.entry.canary?.webhookUrl ?? "");848 const [label, setLabel] = createSignal(props.entry.canary?.label ?? "");849 const [testResult, setTestResult] = createSignal<string | null>(null);850 const [testing, setTesting] = createSignal(false);851852 async function save() {853 const url = webhookUrl().trim();854 const nextLabel = label().trim() || undefined;855 const canary = enabled() && url856 ? {857 webhookUrl: url,858 label: nextLabel,859 lastPinged: props.entry.canary?.lastPinged,860 pingCount: props.entry.canary?.pingCount,861 }862 : undefined;863 await sendMessage({864 type: "UPDATE_ENTRY",865 payload: { id: props.entry.id, patch: { canary } },866 });867 await props.onSaved();868 }869870 async function testPing() {871 const url = webhookUrl().trim();872 if (!url) return;873 setTesting(true);874 setTestResult(null);875 const res = await sendMessage({876 type: "FIRE_CANARY_PING",877 payload: { webhookUrl: url, entryTitle: props.entry.title, trigger: "test" },878 }) as { ok: boolean; error?: string };879 setTestResult(res.ok ? "sent ✓" : `failed — ${res.error ?? "check URL"}`);880 setTesting(false);881 }882883 return (884 <div class="peach-canary-section">885 <div class="peach-field-value-row">886 <span class="peach-field-value" style="font-size:12px;">canary alert</span>887 <button888 class={`peach-toggle ${enabled() ? "is-on" : ""}`}889 onClick={() => { setEnabled(!enabled()); void save(); }}890 aria-label="Toggle canary"891 >892 <span class="peach-toggle-knob" />893 </button>894 </div>895 <p style="font-size:10px;color:var(--peach-t2);margin:4px 0 0;line-height:1.5;">896 Fires a silent webhook when this entry's password or username is copied. Great for honeypot credentials.897 </p>898 <Show when={enabled()}>899 <div class="peach-inline-field-wrap" style="margin-top:8px;">900 <input901 class="peach-inline-input"902 placeholder="https://hooks.zapier.com/..."903 value={webhookUrl()}904 onInput={(e) => setWebhookUrl(e.currentTarget.value)}905 onBlur={() => void save()}906 />907 <input908 class="peach-inline-input"909 placeholder="label (optional, e.g. AWS decoy)"910 value={label()}911 onInput={(e) => setLabel(e.currentTarget.value)}912 onBlur={() => void save()}913 />914 <div style="display:flex;align-items:center;gap:8px;margin-top:4px;">915 <button916 class="peach-btn peach-btn--ghost peach-btn--sm"917 onClick={() => void testPing()}918 disabled={testing() || !webhookUrl().trim()}919 >920 {testing() ? "sending..." : "test ping"}921 </button>922 <Show when={testResult()}>923 <span style="font-size:10px;color:var(--peach-t2);font-family:var(--font-mono);">{testResult()}</span>924 </Show>925 </div>926 <Show when={props.entry.canary?.lastPinged}>927 <p style="font-size:10px;color:var(--peach-t2);margin-top:4px;">928 Last triggered: {new Date(props.entry.canary!.lastPinged!).toLocaleDateString()}929 {" · "}{props.entry.canary?.pingCount ?? 0} total alerts930 </p>931 </Show>932 </div>933 </Show>934 </div>935 );936}