Entry.tsx

61.6 KB · raw

1import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js";2import type { VaultEntry } from "../../shared/types";3import { isPasskeyOnly } from "../../shared/types";4import { detectBrowser } from "../../shared/browser";5import { getDisplayDomain } from "../../shared/domain";6import { faviconUrl } from "../../shared/favicon";7import { generateTOTP, parseOtpauthURI } from "../../crypto/totp";8import { sendMessage, triggerPipPulse, triggerRing } from "../App";9import { popupCopy } from "../copy";10import { GeneratorDrawer } from "../components/GeneratorDrawer";11import { NoteEditorInline, Toolbar } from "./NoteEditor";1213type EditableField = "title" | "url" | "username" | "password" | "notes" | "totp" | null;1415interface EntryProps {16  entry: VaultEntry | null;17  /**18   * Current active tab's full URL (https only). Empty string when unavailable19   * — Capacitor, an extension page, or a chrome:///about: URL. The URL20   * editor uses this to render an "add current site" shortcut.21   */22  currentTabUrl?: string;23  onDelete: () => void;24  /** Called after any inline save so App can reload entries */25  onFieldSaved: () => void | Promise<void>;26  onEscape?: () => void;27}2829export function EntryView(props: EntryProps) {30  const [editingField, setEditingField] = createSignal<EditableField>(null);31  const [draft, setDraft] = createSignal("");32  const [saving, setSaving] = createSignal(false);33  const [showPassword, setShowPassword] = createSignal(false);34  const [showGenerator, setShowGenerator] = createSignal(false);35  const [copied, setCopied] = createSignal<string | null>(null);36  // Local draft for the additional URLs list, edited only while the URL field37  // is in inline-edit mode. Saved alongside `url` when the user confirms.38  const [additionalUrlsDraft, setAdditionalUrlsDraft] = createSignal<string[]>([]);39  const [editingTags, setEditingTags] = createSignal(false);40  const [tagsDraft, setTagsDraft] = createSignal<string[]>([]);41  const [tagInput, setTagInput] = createSignal("");42  const [showCardNumber, setShowCardNumber] = createSignal(false);43  const [showCardCvc, setShowCardCvc] = createSignal(false);44  const [totpTick, setTotpTick] = createSignal(0);45  const [confirmDelete, setConfirmDelete] = createSignal(false);46  const [totpHot, setTotpHot] = createSignal(false);47  const [faviconSrc, setFaviconSrc] = createSignal<string | null>(null);48  const [noteEditorEl, setNoteEditorEl] = createSignal<HTMLDivElement | null>(null);49  // Auto-open advanced when entry already has passkey or canary configured50  const [showAdvanced, setShowAdvanced] = createSignal(false);51  let lastSavedNote: string | null = null;5253  function noteExec(cmd: string, val?: string) {54    document.execCommand(cmd, false, val);55    noteEditorEl()?.focus();56  }5758  async function saveNoteInline(html: string) {59    const entry = props.entry;60    if (!entry) return;61    const currentStored = entry.notes ?? "";62    if (html === currentStored) return;63    if (lastSavedNote !== null && html === lastSavedNote) return;64    lastSavedNote = html;65    setSaving(true);66    try {67      await sendMessage({68        type: "UPDATE_ENTRY",69        payload: { id: entry.id, patch: { notes: html } },70      });71      triggerRing();72      await props.onFieldSaved();73    } catch {74      lastSavedNote = null;75    } finally {76      setSaving(false);77    }78  }7980  const interval = window.setInterval(() => setTotpTick((n) => n + 1), 1000);81  onCleanup(() => window.clearInterval(interval));8283  const displayDomain = createMemo(() =>84    props.entry?.url ? getDisplayDomain(props.entry.url) : ""85  );8687  createEffect(() => {88    const domain = displayDomain();89    setFaviconSrc(domain ? faviconUrl(domain) : null);90  });9192  // Reset transient prompts (trash confirmation, inline edits, advanced panel)93  // when the selected entry changes so they don't carry over between entries.94  createEffect(() => {95    void props.entry?.id;96    setConfirmDelete(false);97    setEditingField(null);98    setEditingTags(false);99    setShowAdvanced(false);100  });101102  const totpState = createMemo(() => {103    void totpTick();104    if (!props.entry?.totp) return null;105    return generateTOTP(props.entry.totp);106  });107108  let prevTotpCode: string | undefined;109  createEffect(() => {110    const state = totpState();111    if (!state) { prevTotpCode = undefined; return; }112    if (prevTotpCode !== undefined && state.code !== prevTotpCode) {113      setTotpHot(true);114      window.setTimeout(() => setTotpHot(false), 3000);115    }116    prevTotpCode = state.code;117  });118119  const totpProgress = createMemo(() => {120    const state = totpState();121    if (!state) return 0;122    return ((state.remaining - 1) / 29) * 100;123  });124125  createEffect(() => {126    const shell = document.getElementById("hd-shell");127    if (!shell) return;128129    const state = totpState();130    if (!state) {131      delete shell.dataset.totpActive;132      shell.style.removeProperty("--peach-totp-progress");133      return;134    }135136    shell.dataset.totpActive = "true";137    shell.style.setProperty(138      "--peach-totp-progress",139      `${Math.max(0, totpProgress())}%`140    );141142    onCleanup(() => {143      delete shell.dataset.totpActive;144      shell.style.removeProperty("--peach-totp-progress");145    });146  });147148  const titleSizeClass = createMemo(() => {149    const length = props.entry?.title.length ?? 0;150    if (length >= 28) return "peach-entry-name--xxl";151    if (length >= 20) return "peach-entry-name--xl";152    if (length >= 14) return "peach-entry-name--lg";153    return "";154  });155  const useAnimatedTitle = createMemo(() => (props.entry?.title.length ?? 0) <= 13);156157  // ── Inline editing ────────────────────────────────────────────────158159  function startEdit(field: EditableField, value: string) {160    setEditingField(field);161    setDraft(value);162    setShowGenerator(false);163    setConfirmDelete(false);164    if (field === "url") {165      setAdditionalUrlsDraft([...(props.entry?.additionalUrls ?? [])]);166    }167  }168169  // Compare URLs by string after trim — vault.ts has its own canonicalisation170  // when saving, so the dedupe here is a UX-level "don't show the button if171  // we'd just add a duplicate" rather than a security check.172  function urlMatchesAny(candidate: string, primary: string, others: string[]): boolean {173    const c = candidate.trim();174    if (!c) return true;175    if (primary.trim() === c) return true;176    return others.some((u) => u.trim() === c);177  }178179  function addCurrentSite(url: string) {180    if (!url) return;181    if (!draft().trim()) {182      setDraft(url);183      return;184    }185    if (urlMatchesAny(url, draft(), additionalUrlsDraft())) return;186    setAdditionalUrlsDraft((arr) => [...arr, url]);187  }188189  async function saveField() {190    const field = editingField();191    const entry = props.entry;192    if (!field || !entry) return;193    // Don't trim passwords — spaces may be intentional194    let value = field === "password" ? draft() : draft().trim();195    if (field === "totp" && value.startsWith("otpauth://")) {196      const parsed = parseOtpauthURI(value);197      value = parsed?.secret ?? value;198    }199    if (field === "password" && entry.type !== "note" && !value) {200      return;201    }202    setSaving(true);203    try {204      const patch: Partial<VaultEntry> = { [field]: value };205      if (field === "url") {206        // Sanitization layer in vault.ts trims, dedupes, and drops empties.207        patch.additionalUrls = additionalUrlsDraft();208      }209      await sendMessage({210        type: "UPDATE_ENTRY",211        payload: { id: entry.id, patch },212      });213      triggerRing();214      setEditingField(null);215      setShowGenerator(false);216      await props.onFieldSaved();217    } catch {218      // silent — leave editing open219    } finally {220      setSaving(false);221    }222  }223224  function startEditTags() {225    setTagsDraft([...(props.entry?.tags ?? [])]);226    setTagInput("");227    setEditingTags(true);228  }229230  function addTag(value: string) {231    const n = value.toLowerCase().trim();232    if (!n || n.length > 32 || tagsDraft().includes(n) || tagsDraft().length >= 10) return;233    setTagsDraft([...tagsDraft(), n]);234    setTagInput("");235  }236237  async function saveTags() {238    if (tagInput()) addTag(tagInput());239    const entry = props.entry;240    if (!entry) return;241    const next = tagsDraft().length > 0 ? tagsDraft() : undefined;242    setEditingTags(false);243    setSaving(true);244    try {245      await sendMessage({246        type: "UPDATE_ENTRY",247        payload: { id: entry.id, patch: { tags: next } },248      });249      triggerRing();250      await props.onFieldSaved();251    } catch {252      // silent253    } finally {254      setSaving(false);255    }256  }257258  function cancelEdit() {259    setEditingField(null);260    setDraft("");261    setShowGenerator(false);262  }263264  function fieldValue(entry: VaultEntry, field: Exclude<EditableField, null>): string {265    switch (field) {266      case "title":267        return entry.title;268      case "url":269        return entry.url ?? "";270      case "username":271        return entry.username ?? "";272      case "password":273        return entry.password ?? "";274      case "notes":275        return entry.notes ?? "";276      case "totp":277        return entry.totp ?? "";278    }279  }280281  function handleEditorFocusOut(282    field: Exclude<EditableField, null>,283    event: FocusEvent & { currentTarget: HTMLElement; relatedTarget: EventTarget | null }284  ) {285    const nextTarget = event.relatedTarget;286    if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return;287288    const entry = props.entry;289    if (!entry) return;290291    const currentValue = fieldValue(entry, field);292    const nextValue = field === "password" ? draft() : draft().trim();293    if (nextValue === currentValue) {294      cancelEdit();295    }296  }297298  async function savePassword(password: string) {299    const entry = props.entry;300    if (!entry) return;301    setSaving(true);302    try {303      await sendMessage({304        type: "UPDATE_ENTRY",305        payload: { id: entry.id, patch: { password } },306      });307      triggerRing();308      setShowGenerator(false);309      await props.onFieldSaved();310    } catch {311      // silent312    } finally {313      setSaving(false);314    }315  }316317  // ── Copy ─────────────────────────────────────────────────────────318319  async function copyField(text: string, label: string, button?: HTMLButtonElement) {320    const ok = await popupCopy(text, label);321    if (ok) {322      const entry = props.entry;323      if (entry?.canary?.webhookUrl) {324        const trigger =325          label === "password"326            ? "password_copied"327            : label === "username"328              ? "username_copied"329              : undefined;330        if (trigger) {331          void sendMessage({332            type: "FIRE_CANARY_PING",333            payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger },334          });335        }336      }337      triggerRing();338      triggerPipPulse();339      setCopied(null);340      requestAnimationFrame(() => setCopied(label));341      if (button) {342        button.classList.remove("is-copied", "is-retreat");343        void button.offsetWidth;344        const drain = document.createElement("span");345        drain.className = "peach-copy-drain";346        const bar = document.createElement("span"); bar.className = "peach-copy-drain-bar";347        drain.appendChild(bar);348        button.appendChild(drain);349        window.setTimeout(() => {350          button.classList.remove("is-copied");351          button.classList.add("is-retreat");352          drain.remove();353          window.setTimeout(() => button.classList.remove("is-retreat"), 160);354        }, 1800);355      }356      window.setTimeout(() => setCopied(null), 1800);357    }358  }359360  // ── Keyboard shortcuts ────────────────────────────────────────────361362  createEffect(() => {363    if (!props.entry) return;364    const handler = (e: KeyboardEvent) => {365      const inInput = e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement || e.target instanceof HTMLSelectElement || (e.target as HTMLElement).isContentEditable;366      if (inInput) return;367      switch (e.key) {368        case "c": e.preventDefault();369          if (props.entry?.password) void copyField(props.entry.password, "password"); break;370        case "u": e.preventDefault();371          if (props.entry?.username) void copyField(props.entry.username, "username"); break;372        case "Escape": e.preventDefault(); props.onEscape?.(); break;373      }374    };375    window.addEventListener("keydown", handler);376    onCleanup(() => window.removeEventListener("keydown", handler));377  });378379  // ── Input ref focus helper ────────────────────────────────────────380381  function autoFocus(el: HTMLInputElement | HTMLTextAreaElement) {382    requestAnimationFrame(() => { el.focus(); el.select?.(); });383  }384385  function onEditKeyDown(e: KeyboardEvent) {386    if (e.key === "Enter" && !(e.target instanceof HTMLTextAreaElement)) {387      e.preventDefault();388      void saveField();389    }390    if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }391  }392393  return (394    <>395      <div396        class="peach-ghost"397        aria-hidden="true"398        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;"399      >400        {props.entry?.title || "empty"}401      </div>402403      <div class="peach-stage" style="position:relative;">404        <Show when={props.entry} fallback={<EmptyVaultState />}>405          {(entry) => (406            <>407              {/* ── Breach strip ─────────────────────────────────── */}408              <Show when={entry().type !== "note" && entry().type !== "card" && (entry().breach ?? 0) > 0}>409                <div class="peach-breach-badge">410                  <svg width="10" height="10" viewBox="0 0 12 12" fill="none" stroke="var(--breach)" stroke-width="1.5" stroke-linecap="round">411                    <path d="M6 1L11 10H1L6 1z" />412                    <line x1="6" y1="5" x2="6" y2="7.5" />413                    <circle cx="6" cy="9" r="0.5" fill="var(--breach)" stroke="none" />414                  </svg>415                  <span style="flex:1">found in a known data breach</span>416                  <button417                    class="peach-btn peach-btn--sm peach-btn--accent peach-breach-action"418                    onClick={() => { startEdit("password", entry().password ?? ""); setShowGenerator(true); }}419                  >420                    Change password421                  </button>422                </div>423              </Show>424425              {/* ── Title + trash ─────────────────────────────────── */}426              <div class="peach-entry-header">427                <div class="peach-entry-header-name">428                  <Show429                    when={editingField() === "title"}430                    fallback={431                      <button432                        class="peach-entry-name-button"433                        title="Click to edit title"434                        onClick={() => startEdit("title", entry().title)}435                      >436                        <h1 class={`peach-entry-name ${titleSizeClass()}`} aria-label={entry().title}>437                          <span class="peach-entry-name-text">438                            <Show439                              when={useAnimatedTitle()}440                              fallback={entry().title}441                            >442                              <For each={entry().title.split("")}>443                                {(ch, i) => (444                                  <span class="peach-entry-name-char" style={`--char-index:${i()}`}>445                                    {ch === " " ? "\u00A0" : ch}446                                  </span>447                                )}448                              </For>449                            </Show>450                          </span>451                        </h1>452                      </button>453                    }454                  >455                    <div456                      class="peach-inline-title-wrap"457                      onFocusOut={(event) => handleEditorFocusOut("title", event)}458                    >459                      <input460                        class="peach-inline-title-input"461                        value={draft()}462                        onInput={(e) => setDraft(e.currentTarget.value)}463                        onKeyDown={onEditKeyDown}464                        ref={autoFocus}465                        disabled={saving()}466                      />467                      <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />468                    </div>469                  </Show>470                </div>471472                {/* Subtle trash icon — hidden while editing title */}473                <Show when={editingField() !== "title"}>474                  <Show475                    when={confirmDelete()}476                    fallback={477                      <button478                        class="peach-entry-trash-btn"479                        onClick={() => setConfirmDelete(true)}480                        aria-label="Move to trash"481                        title="Delete entry"482                      >483                        <svg width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">484                          <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />485                          <path d="M4.5 6l.3 3.5M7.5 6l-.3 3.5" />486                        </svg>487                      </button>488                    }489                  >490                    <div class="peach-entry-trash-confirm">491                      <span class="peach-entry-trash-confirm-label">trash?</span>492                      <button493                        class="peach-btn peach-btn--sm peach-btn--danger"494                        style="font-size:10px;padding:2px 8px;min-height:22px;"495                        onClick={() => { setConfirmDelete(false); props.onDelete(); }}496                      >497                        yes498                      </button>499                      <button500                        class="peach-btn peach-btn--ghost peach-btn--sm"501                        style="font-size:10px;padding:2px 8px;min-height:22px;"502                        onClick={() => setConfirmDelete(false)}503                      >504                        no505                      </button>506                    </div>507                  </Show>508                </Show>509              </div>510511              {/* ── Domain / URL ─────────────────────────────────── */}512              <Show when={entry().type !== "note" && entry().type !== "card"}>513                <div class="peach-domain" style="position:relative;z-index:1;">514                  <Show515                    when={editingField() === "url"}516                    fallback={517                      <div518                        class="peach-domain-row peach-inline-clickable"519                        title="Click to edit URL"520                        onClick={() => startEdit("url", entry().url ?? "")}521                      >522                        <Show when={faviconSrc()}>523                          <img524                            src={faviconSrc()!}525                            width="16"526                            height="16"527                            alt=""528                            style="border-radius:3px;margin-right:5px;flex-shrink:0;"529                            onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = "none"; }}530                          />531                        </Show>532                        <span class="peach-domain-value">533                          {displayDomain() || (entry().url ? entry().url : "add a website url")}534                        </span>535                        <span class="peach-inline-edit-hint">edit</span>536                      </div>537                    }538                  >539                    <div540                      class="peach-inline-field-wrap peach-inline-field-wrap--url"541                      onFocusOut={(event) => handleEditorFocusOut("url", event)}542                    >543                      <div class="peach-inline-field-row">544                        <input545                          class="peach-inline-input"546                          type="url"547                          placeholder="https://"548                          value={draft()}549                          onInput={(e) => setDraft(e.currentTarget.value)}550                          onKeyDown={onEditKeyDown}551                          ref={autoFocus}552                          disabled={saving()}553                        />554                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />555                      </div>556                      <div class="peach-additional-urls">557                        <Show when={additionalUrlsDraft().length > 0}>558                          <span class="peach-label peach-additional-urls-label">also matches</span>559                        </Show>560                        <For each={additionalUrlsDraft()}>561                          {(value, i) => (562                            <div class="peach-additional-url-row">563                              <input564                                class="peach-inline-input"565                                type="url"566                                placeholder="https://"567                                value={value}568                                onInput={(e) => setAdditionalUrlsDraft((arr) => {569                                  const next = [...arr];570                                  next[i()] = e.currentTarget.value;571                                  return next;572                                })}573                                disabled={saving()}574                              />575                              <button576                                class="peach-additional-url-remove"577                                type="button"578                                aria-label="Remove URL"579                                onMouseDown={(e) => e.preventDefault()}580                                onClick={() => setAdditionalUrlsDraft((arr) => arr.filter((_, idx) => idx !== i()))}581                                disabled={saving()}582                              >×</button>583                            </div>584                          )}585                        </For>586                        <button587                          class="peach-add-field-link peach-additional-urls-add"588                          type="button"589                          onMouseDown={(e) => e.preventDefault()}590                          onClick={() => setAdditionalUrlsDraft((arr) => [...arr, ""])}591                          disabled={saving()}592                        >593                          + add another url594                        </button>595                        <Show when={props.currentTabUrl && !urlMatchesAny(props.currentTabUrl!, draft(), additionalUrlsDraft())}>596                          <button597                            class="peach-add-field-link peach-additional-urls-add peach-additional-urls-add-current"598                            type="button"599                            title={props.currentTabUrl}600                            onMouseDown={(e) => e.preventDefault()}601                            onClick={() => addCurrentSite(props.currentTabUrl!)}602                            disabled={saving()}603                          >604                            + add current site605                          </button>606                        </Show>607                      </div>608                    </div>609                  </Show>610                </div>611              </Show>612613              {/* ── Tags — read-only chips (editing state lives at bottom) */}614              <Show when={!editingTags() && entry().tags && entry().tags!.length > 0}>615                <div616                  style="display:flex;flex-wrap:wrap;gap:5px;margin:2px 0;position:relative;z-index:1;cursor:pointer;"617                  onClick={startEditTags}618                  title="Click to edit tags"619                >620                  <For each={entry().tags!}>621                    {(tag) => <span class="peach-tag-chip" style="font-size:10px;padding:2px 8px;">{tag}</span>}622                  </For>623                </div>624              </Show>625626              {/* ── Note toolbar + inline editor ─────────────────── */}627              <Show when={entry().type === "note"}>628                <div class="peach-entry-note-toolbar-row">629                  <Toolbar exec={noteExec} class="peach-note-toolbar-inline" />630                </div>631                <section class="peach-fields peach-note-body" style="position:relative;z-index:1;">632                  <NoteEditorInline633                    entryKey={entry().id}634                    initialContent={entry().notes ?? ""}635                    onReady={(el) => { setNoteEditorEl(el); lastSavedNote = null; }}636                    onBlur={saveNoteInline}637                    autoFocus={false}638                  />639                </section>640              </Show>641642              {/* ── Card detail view ─────────────────────────────── */}643              <Show when={entry().type === "card"}>644                <section class="peach-fields" style="position:relative;z-index:1;">645                  {/* Card subtitle: •••• last4 */}646                  <Show when={entry().last4}>647                    <div class="peach-field-card" style="padding-bottom:4px;">648                      <span class="peach-label">649                        <Show when={entry().network && entry().network !== "unknown"}>650                          <span class="peach-card-network-badge" style="margin-left:0;margin-right:6px;">{entry().network}</span>651                        </Show>652                        {entry().cardNickname || `•••• ${entry().last4}`}653                      </span>654                    </div>655                  </Show>656657                  {/* Cardholder name */}658                  <div class="peach-field-card">659                    <span class="peach-label">cardholder name</span>660                    <div class="peach-field-value-row">661                      <span class="peach-field-value">{entry().cardholderName || "—"}</span>662                      <Show when={entry().cardholderName}>663                        <button664                          class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cardholderName" ? "is-copied" : ""}`}665                          onClick={(e) => void copyField(entry().cardholderName!, "cardholderName", e.currentTarget as HTMLButtonElement)}666                          aria-label="Copy cardholder name"667                        >668                          <CopyGlyph /><span class="peach-copy-sheen" />669                        </button>670                      </Show>671                    </div>672                  </div>673674                  {/* Card number */}675                  <div class="peach-field-card">676                    <span class="peach-label">card number</span>677                    <div class="peach-field-value-row">678                      <span class={`peach-field-value peach-card-number`}>679                        <Show when={showCardNumber()} fallback={680                          <span>{`•••• •••• •••• ${entry().last4 ?? "••••"}`}</span>681                        }>682                          <span>{entry().cardNumber ?? ""}</span>683                        </Show>684                      </span>685                      <div class="peach-field-actions">686                        <button687                          class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"688                          onClick={() => setShowCardNumber(!showCardNumber())}689                          aria-label={showCardNumber() ? "Hide card number" : "Reveal card number"}690                        >691                          <Show when={showCardNumber()} fallback={<EyeOpenGlyph />}>692                            <EyeClosedGlyph />693                          </Show>694                        </button>695                        <Show when={entry().cardNumber}>696                          <button697                            class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cardNumber" ? "is-copied" : ""}`}698                            onClick={(e) => {699                              const cardNum = entry().cardNumber!;700                              if (entry().canary?.webhookUrl) {701                                void sendMessage({702                                  type: "FIRE_CANARY_PING",703                                  payload: { webhookUrl: entry().canary!.webhookUrl, entryTitle: entry().title, trigger: "password_copied" },704                                });705                              }706                              void copyField(cardNum, "cardNumber", e.currentTarget as HTMLButtonElement);707                            }}708                            aria-label="Copy card number"709                          >710                            <CopyGlyph /><span class="peach-copy-sheen" />711                          </button>712                        </Show>713                      </div>714                    </div>715                  </div>716717                  {/* Expiry */}718                  <Show when={entry().expMonth || entry().expYear}>719                    <div class="peach-field-card">720                      <span class="peach-label">expiry</span>721                      <div class="peach-field-value-row">722                        <span class="peach-field-value">{entry().expMonth} / {entry().expYear}</span>723                        <button724                          class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "expiry" ? "is-copied" : ""}`}725                          onClick={(e) => void copyField(`${entry().expMonth}/${entry().expYear}`, "expiry", e.currentTarget as HTMLButtonElement)}726                          aria-label="Copy expiry"727                        >728                          <CopyGlyph /><span class="peach-copy-sheen" />729                        </button>730                      </div>731                    </div>732                  </Show>733734                  {/* CVC */}735                  <Show when={entry().cvc}>736                    <div class="peach-field-card">737                      <span class="peach-label">CVC</span>738                      <div class="peach-field-value-row">739                        <span class="peach-field-value">740                          <Show when={showCardCvc()} fallback={<span class="peach-password-mask">•••</span>}>741                            <span>{entry().cvc}</span>742                          </Show>743                        </span>744                        <div class="peach-field-actions">745                          <button746                            class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"747                            onClick={() => setShowCardCvc(!showCardCvc())}748                            aria-label={showCardCvc() ? "Hide CVC" : "Show CVC"}749                          >750                            <Show when={showCardCvc()} fallback={<EyeOpenGlyph />}>751                              <EyeClosedGlyph />752                            </Show>753                          </button>754                          <button755                            class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cvc" ? "is-copied" : ""}`}756                            onClick={(e) => void copyField(entry().cvc!, "cvc", e.currentTarget as HTMLButtonElement)}757                            aria-label="Copy CVC"758                          >759                            <CopyGlyph /><span class="peach-copy-sheen" />760                          </button>761                        </div>762                      </div>763                    </div>764                  </Show>765                </section>766              </Show>767768              {/* ── Login fields ─────────────────────────────────── */}769              <Show when={entry().type !== "note" && entry().type !== "card" && !isPasskeyOnly(entry())}>770                <section class="peach-fields" style="position:relative;z-index:1;">771772                  {/* Username — shown when set, or as the only identity field when neither is set */}773                  <Show when={entry().username || (!entry().username && !entry().email) || editingField() === "username"}>774                    <div class="peach-field-card" classList={{ "is-editing": editingField() === "username" }}>775                      <span class="peach-label">{entry().username?.includes("@") ? "email" : "username"}</span>776                      <Show777                        when={editingField() === "username"}778                        fallback={779                          <div class="peach-field-value-row">780                            <span781                              class="peach-field-value peach-inline-clickable"782                              title="Click to edit"783                              onClick={() => startEdit("username", entry().username ?? "")}784                            >785                              {entry().username || <span style="color:var(--t2);font-style:italic;">click to add username</span>}786                            </span>787                            <Show when={entry().username}>788                              <button789                                class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "username" ? "is-copied" : ""}`}790                                onClick={(e) => void copyField(entry().username!, "username", e.currentTarget as HTMLButtonElement)}791                                aria-label="Copy username"792                              >793                                <CopyGlyph /><span class="peach-copy-sheen" />794                              </button>795                            </Show>796                          </div>797                        }798                      >799                        <div800                          class="peach-inline-field-wrap"801                          onFocusOut={(event) => handleEditorFocusOut("username", event)}802                        >803                          <input804                            class="peach-inline-input"805                            value={draft()}806                            onInput={(e) => setDraft(e.currentTarget.value)}807                            onKeyDown={onEditKeyDown}808                            ref={autoFocus}809                            disabled={saving()}810                            autocomplete="off"811                          />812                          <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />813                          <Show when={!entry().email}>814                            <button815                              class="peach-add-field-btn"816                              type="button"817                              onMouseDown={(e) => e.preventDefault()}818                              onClick={() => startEdit("email", "")}819                            >820                              + add email821                            </button>822                          </Show>823                        </div>824                      </Show>825                    </div>826                  </Show>827828                  {/* Password */}829                  <div class="peach-field-card" classList={{ "is-editing": editingField() === "password" }}>830                    <span class="peach-label">password</span>831                    <Show832                      when={editingField() === "password"}833                      fallback={834                        <div class="peach-field-value-row">835                          <span836                            class="peach-password-value"837                            classList={{ "is-revealed": showPassword(), "is-breached": (entry().breach ?? 0) > 0 }}838                            style="cursor:pointer;"839                            title="Click to edit password"840                            onClick={() => startEdit("password", entry().password ?? "")}841                          >842                            <Show843                              when={showPassword()}844                              fallback={<span class="peach-password-mask" aria-label="password hidden">••••••••••••••</span>}845                            >846                              <span class="peach-password-text">{entry().password ?? ""}</span>847                            </Show>848                          </span>849                          <div class="peach-field-actions">850                            <button851                              class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"852                              onClick={() => setShowPassword(!showPassword())}853                              aria-label="Reveal password"854                            >855                              <Show when={showPassword()} fallback={<EyeOpenGlyph />}>856                                <EyeClosedGlyph />857                              </Show>858                            </button>859                            <button860                              class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "password" ? "is-copied" : ""}`}861                              onClick={(e) => { if (entry().password) void copyField(entry().password!, "password", e.currentTarget as HTMLButtonElement); }}862                              aria-label="Copy password"863                            >864                              <CopyGlyph /><span class="peach-copy-sheen" />865                            </button>866                          </div>867                        </div>868                      }869                    >870                      {/* ── Edit mode: type / paste / generate ── */}871                      <div872                        class="peach-inline-field-wrap"873                        onFocusOut={(event) => handleEditorFocusOut("password", event)}874                      >875                        <div class="peach-password-edit-row">876                          <input877                            class="peach-inline-input"878                            type={showPassword() ? "text" : "password"}879                            placeholder="paste or type a password"880                            value={draft()}881                            onInput={(e) => setDraft(e.currentTarget.value)}882                            onKeyDown={(e) => {883                              if (e.key === "Enter") { e.preventDefault(); void saveField(); }884                              if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }885                            }}886                            ref={autoFocus}887                            disabled={saving()}888                            autocomplete="new-password"889                          />890                          <button891                            class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"892                            type="button"893                            onClick={() => setShowPassword(!showPassword())}894                            aria-label={showPassword() ? "Hide" : "Show"}895                          >896                            <Show when={showPassword()} fallback={<EyeOpenGlyph />}>897                              <EyeClosedGlyph />898                            </Show>899                          </button>900                        </div>901                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />902                        <button903                          class="peach-generate-toggle-link"904                          type="button"905                          onClick={() => setShowGenerator(!showGenerator())}906                        >907                          <svg width="10" height="10" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">908                            <path d="M11 2.5A5.5 5.5 0 1 0 12.5 7" />909                            <path d="M9.5 2.5H11V1" />910                          </svg>911                          {showGenerator() ? "hide generator" : "generate"}912                        </button>913                        <Show when={showGenerator()}>914                          <GeneratorDrawer915                            open={true}916                            compact={true}917                            onUse={(pw) => { setDraft(pw); setShowGenerator(false); }}918                          />919                        </Show>920                      </div>921                    </Show>922                  </div>923924                </section>925              </Show>926927              {/* ── Notes (on login entries) ──────────────────────── */}928              <Show when={entry().type !== "note" && entry().type !== "card"}>929                <Show930                  when={entry().notes || editingField() === "notes"}931                  fallback={932                    <button933                      class="peach-add-field-link peach-entry-add-link"934                      onClick={() => startEdit("notes", "")}935                    >936                      + notes937                    </button>938                  }939                >940                  <div style="position:relative;z-index:1;margin-top:6px;">941                    <span class="peach-label" style="display:block;margin-bottom:4px;">notes</span>942                    <Show943                      when={editingField() === "notes"}944                      fallback={945                        <div946                          class="peach-note-content peach-inline-clickable"947                          style="cursor:text;white-space:pre-wrap;"948                          onClick={() => startEdit("notes", entry().notes ?? "")}949                        >950                          {entry().notes}951                        </div>952                      }953                    >954                      <div955                        class="peach-inline-field-wrap"956                        onFocusOut={(event) => handleEditorFocusOut("notes", event)}957                      >958                        <textarea959                          class="peach-inline-textarea"960                          value={draft()}961                          onInput={(e) => setDraft(e.currentTarget.value)}962                          onKeyDown={(e) => { if (e.key === "Escape") { e.preventDefault(); cancelEdit(); } }}963                          ref={(el) => requestAnimationFrame(() => el?.focus())}964                          rows={4}965                          disabled={saving()}966                        />967                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />968                      </div>969                    </Show>970                  </div>971                </Show>972              </Show>973974              {/* ── Tags editor / add-tags link (between notes and TOTP) ─── */}975              <Show976                when={editingTags()}977                fallback={978                  <Show when={!entry().tags || entry().tags!.length === 0}>979                    <button980                      class="peach-add-field-link peach-entry-add-link"981                      onClick={startEditTags}982                    >983                      + tags984                    </button>985                  </Show>986                }987              >988                <div989                  class="peach-inline-tag-editor"990                  onFocusOut={(e) => {991                    const next = e.relatedTarget;992                    if (next instanceof Node && e.currentTarget.contains(next)) return;993                    void saveTags();994                  }}995                >996                  <span class="peach-label">tags</span>997                  <div class="peach-tag-input-wrap">998                    <For each={tagsDraft()}>999                      {(tag) => (1000                        <span class="peach-tag-chip">1001                          {tag}1002                          <button1003                            class="peach-tag-chip-remove"1004                            onMouseDown={(e) => e.preventDefault()}1005                            onClick={() => setTagsDraft(tagsDraft().filter(t => t !== tag))}1006                            aria-label={`Remove ${tag}`}1007                          >×</button>1008                        </span>1009                      )}1010                    </For>1011                    <input1012                      class="peach-tag-input"1013                      placeholder={tagsDraft().length === 0 ? "type and press Enter…" : ""}1014                      value={tagInput()}1015                      onInput={(e) => setTagInput(e.currentTarget.value)}1016                      onKeyDown={(e) => {1017                        if (e.key === "Enter" || e.key === ",") {1018                          e.preventDefault();1019                          addTag(tagInput());1020                        } else if (e.key === "Backspace" && !tagInput() && tagsDraft().length > 0) {1021                          setTagsDraft(tagsDraft().slice(0, -1));1022                        } else if (e.key === "Escape") {1023                          e.preventDefault();1024                          setEditingTags(false);1025                          setTagInput("");1026                        }1027                      }}1028                      ref={(el) => requestAnimationFrame(() => el?.focus())}1029                      style="flex:1;min-width:80px;background:transparent;border:none;outline:none;color:var(--t1);font-size:12px;padding:2px;"1030                    />1031                  </div>1032                </div>1033              </Show>10341035              {/* ── TOTP ─────────────────────────────────────────── */}1036              <Show when={entry().type === "login" && !isPasskeyOnly(entry())}>1037                <Show1038                  when={totpState() || editingField() === "totp"}1039                  fallback={1040                    <button1041                      class="peach-add-field-link peach-entry-add-link"1042                      onClick={() => startEdit("totp", "")}1043                    >1044                      + 2FA1045                    </button>1046                  }1047                >1048                  <section class="peach-totp-band">1049                    <Show1050                      when={editingField() === "totp"}1051                      fallback={1052                        <Show when={totpState()}>1053                          {(state) => (1054                            <>1055                              <div1056                                class="peach-totp-main"1057                                onClick={() => void copyField(state().code.replace(" ", ""), "totp")}1058                                onDblClick={() => startEdit("totp", entry().totp ?? "")}1059                                title="Click to copy · double-click to edit"1060                              >1061                                <span class="peach-label">2-factor</span>1062                                <div class="peach-totp-code-row">1063                                  <p class={`peach-totp-code${totpHot() ? " is-hot" : ""}${copied() === "totp" ? " is-copied" : ""}`}>1064                                    <span class="peach-totp-code-text">1065                                      {state().code.slice(0, 3)} {state().code.slice(3)}1066                                    </span>1067                                  </p>1068                                </div>1069                              </div>1070                              <div class="peach-totp-meta">1071                                <span class="peach-label">expires</span>1072                                <div class="peach-totp-countdown">{state().remaining}s</div>1073                                <svg class="peach-totp-ring" width="44" height="44" viewBox="0 0 44 44">1074                                  <circle cx="22" cy="22" r="17" class="peach-totp-ring-track" />1075                                  <circle cx="22" cy="22" r="17" class="peach-totp-ring-progress"1076                                    stroke-dasharray="106.8"1077                                    stroke-dashoffset={(1 - state().remaining / 30) * 106.8}1078                                  />1079                                </svg>1080                              </div>1081                            </>1082                          )}1083                        </Show>1084                      }1085                    >1086                      <div1087                        class="peach-inline-field-wrap peach-inline-field-wrap--totp"1088                        onFocusOut={(event) => handleEditorFocusOut("totp", event)}1089                      >1090                        <span class="peach-label">2-factor</span>1091                        <input1092                          class="peach-inline-input"1093                          value={draft()}1094                          onInput={(e) => setDraft(e.currentTarget.value)}1095                          onKeyDown={onEditKeyDown}1096                          ref={autoFocus}1097                          disabled={saving()}1098                          placeholder="otpauth://... or base32 secret"1099                          autocomplete="off"1100                        />1101                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1102                      </div>1103                    </Show>1104                  </section>1105                </Show>1106              </Show>11071108              {/* ── Advanced (canary + passkey) ───────────────────── */}1109              <Show when={entry().type === "login"}>1110                <button1111                  class="peach-add-field-link peach-entry-add-link"1112                  onClick={() => setShowAdvanced((v) => !v)}1113                  aria-expanded={showAdvanced()}1114                >1115                  {showAdvanced() ? "− Advanced" : "+ Advanced"}1116                </button>1117                <Show when={showAdvanced()}>1118                  <Show when={!isPasskeyOnly(entry())}>1119                    <CanarySection entry={entry()} onSaved={props.onFieldSaved} />1120                  </Show>11211122                  <Show when={entry().passkey}>1123                    {(passkey) => {1124                      const currentBrowser = detectBrowser();1125                      const crossBrowser =1126                        passkey().browser &&1127                        currentBrowser !== "unknown" &&1128                        passkey().browser !== currentBrowser;1129                      const algorithmLabel =1130                        passkey().algorithm === -8 ? "EdDSA (Ed25519)" : "ES256 (P-256)";1131                      const formatDate = (ts?: number) =>1132                        ts ? new Date(ts).toLocaleDateString() : "Never";1133                      return (1134                        <section1135                          class="peach-fields peach-passkey-section"1136                          style="position:relative;z-index:1;margin-top:6px;"1137                        >1138                          <div class="peach-field-card">1139                            <span class="peach-label">passkey · {passkey().rpId}</span>1140                            <div class="peach-field-value-row">1141                              <span class="peach-field-value">{passkey().userName}</span>1142                              <span class="peach-passkey-badge">passkey</span>1143                            </div>1144                          </div>11451146                          <div class="peach-field-card">1147                            <span class="peach-label">algorithm</span>1148                            <div class="peach-field-value-row">1149                              <span class="peach-field-value">{algorithmLabel}</span>1150                            </div>1151                          </div>11521153                          <div class="peach-field-card">1154                            <span class="peach-label">activity</span>1155                            <div class="peach-field-value-row peach-passkey-activity">1156                              <span class="peach-field-value">1157                                used {passkey().signCount} time{passkey().signCount === 1 ? "" : "s"}1158                              </span>1159                              <span class="peach-passkey-meta">1160                                created {formatDate(passkey().createdAt)}1161                              </span>1162                              <span class="peach-passkey-meta">1163                                last used {formatDate(passkey().lastUsedAt)}1164                              </span>1165                            </div>1166                          </div>11671168                          <Show when={crossBrowser}>1169                            <div class="peach-info-notice peach-info-notice--warn">1170                              {`This passkey was created on ${1171                                passkey().browser === "chrome" ? "Chrome" : "Firefox"1172                              }. To use it on ${1173                                currentBrowser === "chrome" ? "Chrome" : "Firefox"1174                              }, re-register it on this device.`}1175                            </div>1176                          </Show>11771178                          <div class="peach-info-notice">1179                            {passkey().browser === "firefox"1180                              ? "This passkey is held by your platform authenticator. Peach stores only the metadata."1181                              : "The private key is stored encrypted in your Peach vault and is never shown in the UI."}1182                          </div>11831184                          <button1185                            class="peach-add-field-link peach-entry-add-link"1186                            style="color:rgba(220,38,38,0.7);"1187                            onClick={async () => {1188                              const id = entry().id;1189                              await sendMessage({1190                                type: "UPDATE_ENTRY",1191                                payload: { id, patch: { passkey: undefined } },1192                              });1193                              await props.onFieldSaved();1194                            }}1195                          >1196                            − remove passkey1197                          </button>1198                        </section>1199                      );1200                    }}1201                  </Show>1202                </Show>1203              </Show>12041205              {/* ── Bottom spacer ─────────────────────────────────── */}1206              <div style="height:4px;" />1207            </>1208          )}1209        </Show>1210      </div>12111212    </>1213  );1214}12151216// ── Sub-components ────────────────────────────────────────────────────12171218function InlineSaveCancelRow(props: { onSave: () => void; onCancel: () => void; saving: boolean }) {1219  return (1220    <div class="peach-inline-actions">1221      <button1222        class="peach-inline-save-btn"1223        onClick={props.onSave}1224        disabled={props.saving}1225        aria-label="Save"1226        title="Save (Enter)"1227      >1228        <svg width="11" height="9" viewBox="0 0 11 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">1229          <path d="M1 4.5L4 7.5L10 1.5" />1230        </svg>1231      </button>1232      <button1233        class="peach-inline-cancel-btn"1234        onClick={props.onCancel}1235        disabled={props.saving}1236        aria-label="Cancel"1237        title="Cancel (Esc)"1238      >1239        <svg width="9" height="9" viewBox="0 0 9 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round">1240          <path d="M1 1L8 8M8 1L1 8" />1241        </svg>1242      </button>1243    </div>1244  );1245}12461247function EmptyVaultState() {1248  return (1249    <>1250      <div class="peach-entry-name-wrap" style="position:relative;z-index:1;">1251        <div class="peach-entry-name">1252          <span class="peach-entry-name-text">1253            <span class="peach-entry-name-char" style="--char-index:0">e</span>1254            <span class="peach-entry-name-char" style="--char-index:1">m</span>1255            <span class="peach-entry-name-char" style="--char-index:2">p</span>1256            <span class="peach-entry-name-char" style="--char-index:3">t</span>1257            <span class="peach-entry-name-char" style="--char-index:4">y</span>1258          </span>1259        </div>1260      </div>1261      <div class="peach-empty-copy">1262        <span class="peach-label">vault</span>1263        <p>Your vault is ready for its first entry.</p>1264      </div>1265    </>1266  );1267}12681269function CopyGlyph() {1270  return (1271    <span class="peach-copy-icon">1272      <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">1273        <rect x="4" y="4" width="8" height="9" rx="1.5" />1274        <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" />1275      </svg>1276    </span>1277  );1278}12791280function EyeClosedGlyph() {1281  return (1282    <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">1283      <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />1284      <path d="M3.5 5.5L2.5 7.5" />1285      <path d="M7 6.5V8.5" />1286      <path d="M10.5 5.5L11.5 7.5" />1287    </svg>1288  );1289}12901291function EyeOpenGlyph() {1292  return (1293    <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">1294      <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" />1295      <circle cx="7" cy="5" r="1.75" />1296    </svg>1297  );1298}12991300function ChevronGlyph(props: { open: boolean }) {1301  return (1302    <svg1303      width="8"1304      height="8"1305      viewBox="0 0 8 8"1306      fill="none"1307      stroke="currentColor"1308      stroke-width="1.3"1309      stroke-linecap="round"1310      stroke-linejoin="round"1311      style={`transform: rotate(${props.open ? 90 : 0}deg); transition: transform 160ms ease;`}1312    >1313      <path d="M2 1L6 4L2 7" />1314    </svg>1315  );1316}13171318function CanarySection(props: { entry: VaultEntry; onSaved: () => void | Promise<void> }) {1319  const [enabled, setEnabled] = createSignal(Boolean(props.entry.canary));1320  const [webhookUrl, setWebhookUrl] = createSignal(props.entry.canary?.webhookUrl ?? "");1321  const [label, setLabel] = createSignal(props.entry.canary?.label ?? "");1322  const [testResult, setTestResult] = createSignal<string | null>(null);1323  const [testing, setTesting] = createSignal(false);13241325  async function save() {1326    const url = webhookUrl().trim();1327    const nextLabel = label().trim() || undefined;1328    const canary = enabled() && url1329      ? {1330          webhookUrl: url,1331          label: nextLabel,1332          lastPinged: props.entry.canary?.lastPinged,1333          pingCount: props.entry.canary?.pingCount,1334        }1335      : undefined;1336    await sendMessage({1337      type: "UPDATE_ENTRY",1338      payload: { id: props.entry.id, patch: { canary } },1339    });1340    await props.onSaved();1341  }13421343  async function testPing() {1344    const url = webhookUrl().trim();1345    if (!url) return;1346    setTesting(true);1347    setTestResult(null);1348    const res = await sendMessage({1349      type: "FIRE_CANARY_PING",1350      payload: { webhookUrl: url, entryTitle: props.entry.title, trigger: "test" },1351    }) as { ok: boolean; error?: string };1352    setTestResult(res.ok ? "sent ✓" : `failed — ${res.error ?? "check URL"}`);1353    setTesting(false);1354  }13551356  return (1357    <div class="peach-canary-section">1358      <div class="peach-field-value-row">1359        <span class="peach-field-value" style="font-size:12px;">canary alert</span>1360        <button1361          class={`peach-toggle ${enabled() ? "is-on" : ""}`}1362          onClick={() => { setEnabled(!enabled()); void save(); }}1363          aria-label="Toggle canary"1364        >1365          <span class="peach-toggle-knob" />1366        </button>1367      </div>1368      <p style="font-size:10px;color:var(--peach-t2);margin:4px 0 0;line-height:1.5;">1369        Fires a silent webhook when this entry's password or username is copied. Great for honeypot credentials.1370      </p>1371      <Show when={enabled()}>1372        <div class="peach-inline-field-wrap" style="margin-top:8px;">1373          <input1374            class="peach-inline-input"1375            placeholder="https://hooks.zapier.com/..."1376            value={webhookUrl()}1377            onInput={(e) => setWebhookUrl(e.currentTarget.value)}1378            onBlur={() => void save()}1379          />1380          <input1381            class="peach-inline-input"1382            placeholder="label (optional, e.g. AWS decoy)"1383            value={label()}1384            onInput={(e) => setLabel(e.currentTarget.value)}1385            onBlur={() => void save()}1386          />1387          <div style="display:flex;align-items:center;gap:8px;margin-top:4px;">1388            <button1389              class="peach-btn peach-btn--ghost peach-btn--sm"1390              onClick={() => void testPing()}1391              disabled={testing() || !webhookUrl().trim()}1392            >1393              {testing() ? "sending..." : "test ping"}1394            </button>1395            <Show when={testResult()}>1396              <span style="font-size:10px;color:var(--peach-t2);font-family:var(--font-mono);">{testResult()}</span>1397            </Show>1398          </div>1399          <Show when={props.entry.canary?.lastPinged}>1400            <p style="font-size:10px;color:var(--peach-t2);margin-top:4px;">1401              Last triggered: {new Date(props.entry.canary!.lastPinged!).toLocaleDateString()}1402              {" · "}{props.entry.canary?.pingCount ?? 0} total alerts1403            </p>1404          </Show>1405        </div>1406      </Show>1407    </div>1408  );1409}