Entry.tsx

54.1 KB · raw

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 { popupCopy } from "../copy";8import { GeneratorDrawer } from "../components/GeneratorDrawer";9import { NoteEditorInline, Toolbar } from "./NoteEditor";1011type EditableField = "title" | "url" | "username" | "password" | "notes" | "totp" | null;1213interface EntryProps {14  entry: VaultEntry | null;15  onDelete: () => void;16  /** Called after any inline save so App can reload entries */17  onFieldSaved: () => void | Promise<void>;18  onEscape?: () => void;19}2021export function EntryView(props: EntryProps) {22  const [editingField, setEditingField] = createSignal<EditableField>(null);23  const [draft, setDraft] = createSignal("");24  const [saving, setSaving] = createSignal(false);25  const [showPassword, setShowPassword] = createSignal(false);26  const [showGenerator, setShowGenerator] = createSignal(false);27  const [copied, setCopied] = createSignal<string | null>(null);28  // Local draft for the additional URLs list, edited only while the URL field29  // is in inline-edit mode. Saved alongside `url` when the user confirms.30  const [additionalUrlsDraft, setAdditionalUrlsDraft] = createSignal<string[]>([]);31  const [editingTags, setEditingTags] = createSignal(false);32  const [tagsDraft, setTagsDraft] = createSignal<string[]>([]);33  const [tagInput, setTagInput] = createSignal("");34  const [showCardNumber, setShowCardNumber] = createSignal(false);35  const [showCardCvc, setShowCardCvc] = createSignal(false);36  const [totpTick, setTotpTick] = createSignal(0);37  const [confirmDelete, setConfirmDelete] = createSignal(false);38  const [totpHot, setTotpHot] = createSignal(false);39  const [faviconSrc, setFaviconSrc] = createSignal<string | null>(null);40  const [noteEditorEl, setNoteEditorEl] = createSignal<HTMLDivElement | null>(null);41  let lastSavedNote: string | null = null;4243  function noteExec(cmd: string, val?: string) {44    document.execCommand(cmd, false, val);45    noteEditorEl()?.focus();46  }4748  async function saveNoteInline(html: string) {49    const entry = props.entry;50    if (!entry) return;51    const currentStored = entry.notes ?? "";52    if (html === currentStored) return;53    if (lastSavedNote !== null && html === lastSavedNote) return;54    lastSavedNote = html;55    setSaving(true);56    try {57      await sendMessage({58        type: "UPDATE_ENTRY",59        payload: { id: entry.id, patch: { notes: html } },60      });61      triggerRing();62      await props.onFieldSaved();63    } catch {64      lastSavedNote = null;65    } finally {66      setSaving(false);67    }68  }6970  const interval = window.setInterval(() => setTotpTick((n) => n + 1), 1000);71  onCleanup(() => window.clearInterval(interval));7273  const displayDomain = createMemo(() =>74    props.entry?.url ? getDisplayDomain(props.entry.url) : ""75  );7677  createEffect(() => {78    const domain = displayDomain();79    setFaviconSrc(domain ? faviconUrl(domain) : null);80  });8182  // Reset transient prompts (trash confirmation, inline edits) when the83  // selected entry changes so they don't carry over between entries.84  createEffect(() => {85    void props.entry?.id;86    setConfirmDelete(false);87    setEditingField(null);88    setEditingTags(false);89  });9091  const totpState = createMemo(() => {92    void totpTick();93    if (!props.entry?.totp) return null;94    return generateTOTP(props.entry.totp);95  });9697  let prevTotpCode: string | undefined;98  createEffect(() => {99    const state = totpState();100    if (!state) { prevTotpCode = undefined; return; }101    if (prevTotpCode !== undefined && state.code !== prevTotpCode) {102      setTotpHot(true);103      window.setTimeout(() => setTotpHot(false), 3000);104    }105    prevTotpCode = state.code;106  });107108  const totpProgress = createMemo(() => {109    const state = totpState();110    if (!state) return 0;111    return ((state.remaining - 1) / 29) * 100;112  });113114  createEffect(() => {115    const shell = document.getElementById("hd-shell");116    if (!shell) return;117118    const state = totpState();119    if (!state) {120      delete shell.dataset.totpActive;121      shell.style.removeProperty("--peach-totp-progress");122      return;123    }124125    shell.dataset.totpActive = "true";126    shell.style.setProperty(127      "--peach-totp-progress",128      `${Math.max(0, totpProgress())}%`129    );130131    onCleanup(() => {132      delete shell.dataset.totpActive;133      shell.style.removeProperty("--peach-totp-progress");134    });135  });136137  const titleSizeClass = createMemo(() => {138    const length = props.entry?.title.length ?? 0;139    if (length >= 28) return "peach-entry-name--xxl";140    if (length >= 20) return "peach-entry-name--xl";141    if (length >= 14) return "peach-entry-name--lg";142    return "";143  });144  const useAnimatedTitle = createMemo(() => (props.entry?.title.length ?? 0) <= 13);145146  // ── Inline editing ────────────────────────────────────────────────147148  function startEdit(field: EditableField, value: string) {149    setEditingField(field);150    setDraft(value);151    setShowGenerator(false);152    setConfirmDelete(false);153    if (field === "url") {154      setAdditionalUrlsDraft([...(props.entry?.additionalUrls ?? [])]);155    }156  }157158  async function saveField() {159    const field = editingField();160    const entry = props.entry;161    if (!field || !entry) return;162    // Don't trim passwords — spaces may be intentional163    let value = field === "password" ? draft() : draft().trim();164    if (field === "totp" && value.startsWith("otpauth://")) {165      const parsed = parseOtpauthURI(value);166      value = parsed?.secret ?? value;167    }168    if (field === "password" && entry.type !== "note" && !value) {169      return;170    }171    setSaving(true);172    try {173      const patch: Partial<VaultEntry> = { [field]: value };174      if (field === "url") {175        // Sanitization layer in vault.ts trims, dedupes, and drops empties.176        patch.additionalUrls = additionalUrlsDraft();177      }178      await sendMessage({179        type: "UPDATE_ENTRY",180        payload: { id: entry.id, patch },181      });182      triggerRing();183      setEditingField(null);184      setShowGenerator(false);185      await props.onFieldSaved();186    } catch {187      // silent — leave editing open188    } finally {189      setSaving(false);190    }191  }192193  function startEditTags() {194    setTagsDraft([...(props.entry?.tags ?? [])]);195    setTagInput("");196    setEditingTags(true);197  }198199  function addTag(value: string) {200    const n = value.toLowerCase().trim();201    if (!n || n.length > 32 || tagsDraft().includes(n) || tagsDraft().length >= 10) return;202    setTagsDraft([...tagsDraft(), n]);203    setTagInput("");204  }205206  async function saveTags() {207    if (tagInput()) addTag(tagInput());208    const entry = props.entry;209    if (!entry) return;210    const next = tagsDraft().length > 0 ? tagsDraft() : undefined;211    setEditingTags(false);212    setSaving(true);213    try {214      await sendMessage({215        type: "UPDATE_ENTRY",216        payload: { id: entry.id, patch: { tags: next } },217      });218      triggerRing();219      await props.onFieldSaved();220    } catch {221      // silent222    } finally {223      setSaving(false);224    }225  }226227  function cancelEdit() {228    setEditingField(null);229    setDraft("");230    setShowGenerator(false);231  }232233  function fieldValue(entry: VaultEntry, field: Exclude<EditableField, null>): string {234    switch (field) {235      case "title":236        return entry.title;237      case "url":238        return entry.url ?? "";239      case "username":240        return entry.username ?? "";241      case "password":242        return entry.password ?? "";243      case "notes":244        return entry.notes ?? "";245      case "totp":246        return entry.totp ?? "";247    }248  }249250  function handleEditorFocusOut(251    field: Exclude<EditableField, null>,252    event: FocusEvent & { currentTarget: HTMLElement; relatedTarget: EventTarget | null }253  ) {254    const nextTarget = event.relatedTarget;255    if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return;256257    const entry = props.entry;258    if (!entry) return;259260    const currentValue = fieldValue(entry, field);261    const nextValue = field === "password" ? draft() : draft().trim();262    if (nextValue === currentValue) {263      cancelEdit();264    }265  }266267  async function savePassword(password: string) {268    const entry = props.entry;269    if (!entry) return;270    setSaving(true);271    try {272      await sendMessage({273        type: "UPDATE_ENTRY",274        payload: { id: entry.id, patch: { password } },275      });276      triggerRing();277      setShowGenerator(false);278      await props.onFieldSaved();279    } catch {280      // silent281    } finally {282      setSaving(false);283    }284  }285286  // ── Copy ─────────────────────────────────────────────────────────287288  async function copyField(text: string, label: string, button?: HTMLButtonElement) {289    const ok = await popupCopy(text, label);290    if (ok) {291      const entry = props.entry;292      if (entry?.canary?.webhookUrl) {293        const trigger =294          label === "password"295            ? "password_copied"296            : label === "username"297              ? "username_copied"298              : undefined;299        if (trigger) {300          void sendMessage({301            type: "FIRE_CANARY_PING",302            payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger },303          });304        }305      }306      triggerRing();307      triggerPipPulse();308      setCopied(null);309      requestAnimationFrame(() => setCopied(label));310      if (button) {311        button.classList.remove("is-copied", "is-retreat");312        void button.offsetWidth;313        const drain = document.createElement("span");314        drain.className = "peach-copy-drain";315        const bar = document.createElement("span"); bar.className = "peach-copy-drain-bar";316        drain.appendChild(bar);317        button.appendChild(drain);318        window.setTimeout(() => {319          button.classList.remove("is-copied");320          button.classList.add("is-retreat");321          drain.remove();322          window.setTimeout(() => button.classList.remove("is-retreat"), 160);323        }, 1800);324      }325      window.setTimeout(() => setCopied(null), 1800);326    }327  }328329  // ── Keyboard shortcuts ────────────────────────────────────────────330331  createEffect(() => {332    if (!props.entry) return;333    const handler = (e: KeyboardEvent) => {334      const inInput = e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement;335      if (inInput) return;336      switch (e.key) {337        case "c": e.preventDefault();338          if (props.entry?.password) void copyField(props.entry.password, "password"); break;339        case "u": e.preventDefault();340          if (props.entry?.username) void copyField(props.entry.username, "username"); break;341        case "Escape": e.preventDefault(); props.onEscape?.(); break;342      }343    };344    window.addEventListener("keydown", handler);345    onCleanup(() => window.removeEventListener("keydown", handler));346  });347348  // ── Input ref focus helper ────────────────────────────────────────349350  function autoFocus(el: HTMLInputElement | HTMLTextAreaElement) {351    requestAnimationFrame(() => { el.focus(); el.select?.(); });352  }353354  function onEditKeyDown(e: KeyboardEvent) {355    if (e.key === "Enter" && !(e.target instanceof HTMLTextAreaElement)) {356      e.preventDefault();357      void saveField();358    }359    if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }360  }361362  return (363    <>364      <div365        class="peach-ghost"366        aria-hidden="true"367        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;"368      >369        {props.entry?.title || "empty"}370      </div>371372      <div class="peach-stage" style="position:relative;">373        <Show when={props.entry} fallback={<EmptyVaultState />}>374          {(entry) => (375            <>376              {/* ── Breach strip ─────────────────────────────────── */}377              <Show when={entry().type !== "note" && entry().type !== "card" && (entry().breach ?? 0) > 0}>378                <div class="peach-breach-badge">379                  <svg width="10" height="10" viewBox="0 0 12 12" fill="none" stroke="var(--breach)" stroke-width="1.5" stroke-linecap="round">380                    <path d="M6 1L11 10H1L6 1z" />381                    <line x1="6" y1="5" x2="6" y2="7.5" />382                    <circle cx="6" cy="9" r="0.5" fill="var(--breach)" stroke="none" />383                  </svg>384                  <span style="flex:1">found in a known data breach</span>385                  <button386                    class="peach-btn peach-btn--sm peach-btn--accent peach-breach-action"387                    onClick={() => { startEdit("password", entry().password ?? ""); setShowGenerator(true); }}388                  >389                    Change password390                  </button>391                </div>392              </Show>393394              {/* ── Title + trash ─────────────────────────────────── */}395              <div class="peach-entry-header">396                <div class="peach-entry-header-name">397                  <Show398                    when={editingField() === "title"}399                    fallback={400                      <button401                        class="peach-entry-name-button"402                        title="Click to edit title"403                        onClick={() => startEdit("title", entry().title)}404                      >405                        <h1 class={`peach-entry-name ${titleSizeClass()}`} aria-label={entry().title}>406                          <span class="peach-entry-name-text">407                            <Show408                              when={useAnimatedTitle()}409                              fallback={entry().title}410                            >411                              <For each={entry().title.split("")}>412                                {(ch, i) => (413                                  <span class="peach-entry-name-char" style={`--char-index:${i()}`}>414                                    {ch === " " ? "\u00A0" : ch}415                                  </span>416                                )}417                              </For>418                            </Show>419                          </span>420                        </h1>421                      </button>422                    }423                  >424                    <div425                      class="peach-inline-title-wrap"426                      onFocusOut={(event) => handleEditorFocusOut("title", event)}427                    >428                      <input429                        class="peach-inline-title-input"430                        value={draft()}431                        onInput={(e) => setDraft(e.currentTarget.value)}432                        onKeyDown={onEditKeyDown}433                        ref={autoFocus}434                        disabled={saving()}435                      />436                      <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />437                    </div>438                  </Show>439                </div>440441                {/* Subtle trash icon — hidden while editing title */}442                <Show when={editingField() !== "title"}>443                  <Show444                    when={confirmDelete()}445                    fallback={446                      <button447                        class="peach-entry-trash-btn"448                        onClick={() => setConfirmDelete(true)}449                        aria-label="Move to trash"450                        title="Delete entry"451                      >452                        <svg width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">453                          <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />454                          <path d="M4.5 6l.3 3.5M7.5 6l-.3 3.5" />455                        </svg>456                      </button>457                    }458                  >459                    <div class="peach-entry-trash-confirm">460                      <span class="peach-entry-trash-confirm-label">trash?</span>461                      <button462                        class="peach-btn peach-btn--sm peach-btn--danger"463                        style="font-size:10px;padding:2px 8px;min-height:22px;"464                        onClick={() => { setConfirmDelete(false); props.onDelete(); }}465                      >466                        yes467                      </button>468                      <button469                        class="peach-btn peach-btn--ghost peach-btn--sm"470                        style="font-size:10px;padding:2px 8px;min-height:22px;"471                        onClick={() => setConfirmDelete(false)}472                      >473                        no474                      </button>475                    </div>476                  </Show>477                </Show>478              </div>479480              {/* ── Domain / URL ─────────────────────────────────── */}481              <Show when={entry().type !== "note" && entry().type !== "card"}>482                <div class="peach-domain" style="position:relative;z-index:1;">483                  <Show484                    when={editingField() === "url"}485                    fallback={486                      <div487                        class="peach-domain-row peach-inline-clickable"488                        title="Click to edit URL"489                        onClick={() => startEdit("url", entry().url ?? "")}490                      >491                        <Show when={faviconSrc()}>492                          <img493                            src={faviconSrc()!}494                            width="16"495                            height="16"496                            alt=""497                            style="border-radius:3px;margin-right:5px;flex-shrink:0;"498                            onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = "none"; }}499                          />500                        </Show>501                        <span class="peach-domain-value">502                          {displayDomain() || (entry().url ? entry().url : "add a website url")}503                        </span>504                        <span class="peach-inline-edit-hint">edit</span>505                      </div>506                    }507                  >508                    <div509                      class="peach-inline-field-wrap peach-inline-field-wrap--url"510                      onFocusOut={(event) => handleEditorFocusOut("url", event)}511                    >512                      <div class="peach-inline-field-row">513                        <input514                          class="peach-inline-input"515                          type="url"516                          placeholder="https://"517                          value={draft()}518                          onInput={(e) => setDraft(e.currentTarget.value)}519                          onKeyDown={onEditKeyDown}520                          ref={autoFocus}521                          disabled={saving()}522                        />523                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />524                      </div>525                      <div class="peach-additional-urls">526                        <Show when={additionalUrlsDraft().length > 0}>527                          <span class="peach-label peach-additional-urls-label">also matches</span>528                        </Show>529                        <For each={additionalUrlsDraft()}>530                          {(value, i) => (531                            <div class="peach-additional-url-row">532                              <input533                                class="peach-inline-input"534                                type="url"535                                placeholder="https://"536                                value={value}537                                onInput={(e) => setAdditionalUrlsDraft((arr) => {538                                  const next = [...arr];539                                  next[i()] = e.currentTarget.value;540                                  return next;541                                })}542                                disabled={saving()}543                              />544                              <button545                                class="peach-additional-url-remove"546                                type="button"547                                aria-label="Remove URL"548                                onMouseDown={(e) => e.preventDefault()}549                                onClick={() => setAdditionalUrlsDraft((arr) => arr.filter((_, idx) => idx !== i()))}550                                disabled={saving()}551                              >×</button>552                            </div>553                          )}554                        </For>555                        <button556                          class="peach-add-field-link peach-additional-urls-add"557                          type="button"558                          onMouseDown={(e) => e.preventDefault()}559                          onClick={() => setAdditionalUrlsDraft((arr) => [...arr, ""])}560                          disabled={saving()}561                        >562                          + add another url563                        </button>564                      </div>565                    </div>566                  </Show>567                </div>568              </Show>569570              {/* ── Tags — read-only chips (editing state lives at bottom) */}571              <Show when={!editingTags() && entry().tags && entry().tags!.length > 0}>572                <div573                  style="display:flex;flex-wrap:wrap;gap:5px;margin:2px 0;position:relative;z-index:1;cursor:pointer;"574                  onClick={startEditTags}575                  title="Click to edit tags"576                >577                  <For each={entry().tags!}>578                    {(tag) => <span class="peach-tag-chip" style="font-size:10px;padding:2px 8px;">{tag}</span>}579                  </For>580                </div>581              </Show>582583              {/* ── Note toolbar + inline editor ─────────────────── */}584              <Show when={entry().type === "note"}>585                <div class="peach-entry-note-toolbar-row">586                  <Toolbar exec={noteExec} class="peach-note-toolbar-inline" />587                </div>588                <section class="peach-fields peach-note-body" style="position:relative;z-index:1;">589                  <NoteEditorInline590                    entryKey={entry().id}591                    initialContent={entry().notes ?? ""}592                    onReady={(el) => { setNoteEditorEl(el); lastSavedNote = null; }}593                    onBlur={saveNoteInline}594                    autoFocus={false}595                  />596                </section>597              </Show>598599              {/* ── Card detail view ─────────────────────────────── */}600              <Show when={entry().type === "card"}>601                <section class="peach-fields" style="position:relative;z-index:1;">602                  {/* Card subtitle: •••• last4 */}603                  <Show when={entry().last4}>604                    <div class="peach-field-card" style="padding-bottom:4px;">605                      <span class="peach-label">606                        <Show when={entry().network && entry().network !== "unknown"}>607                          <span class="peach-card-network-badge" style="margin-left:0;margin-right:6px;">{entry().network}</span>608                        </Show>609                        {entry().cardNickname || `•••• ${entry().last4}`}610                      </span>611                    </div>612                  </Show>613614                  {/* Cardholder name */}615                  <div class="peach-field-card">616                    <span class="peach-label">cardholder name</span>617                    <div class="peach-field-value-row">618                      <span class="peach-field-value">{entry().cardholderName || "—"}</span>619                      <Show when={entry().cardholderName}>620                        <button621                          class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cardholderName" ? "is-copied" : ""}`}622                          onClick={(e) => void copyField(entry().cardholderName!, "cardholderName", e.currentTarget as HTMLButtonElement)}623                          aria-label="Copy cardholder name"624                        >625                          <CopyGlyph /><span class="peach-copy-sheen" />626                        </button>627                      </Show>628                    </div>629                  </div>630631                  {/* Card number */}632                  <div class="peach-field-card">633                    <span class="peach-label">card number</span>634                    <div class="peach-field-value-row">635                      <span class={`peach-field-value peach-card-number`}>636                        <Show when={showCardNumber()} fallback={637                          <span>{`•••• •••• •••• ${entry().last4 ?? "••••"}`}</span>638                        }>639                          <span>{entry().cardNumber ?? ""}</span>640                        </Show>641                      </span>642                      <div class="peach-field-actions">643                        <button644                          class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"645                          onClick={() => setShowCardNumber(!showCardNumber())}646                          aria-label={showCardNumber() ? "Hide card number" : "Reveal card number"}647                        >648                          <Show when={showCardNumber()} fallback={<EyeOpenGlyph />}>649                            <EyeClosedGlyph />650                          </Show>651                        </button>652                        <Show when={entry().cardNumber}>653                          <button654                            class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cardNumber" ? "is-copied" : ""}`}655                            onClick={(e) => {656                              const cardNum = entry().cardNumber!;657                              if (entry().canary?.webhookUrl) {658                                void sendMessage({659                                  type: "FIRE_CANARY_PING",660                                  payload: { webhookUrl: entry().canary!.webhookUrl, entryTitle: entry().title, trigger: "password_copied" },661                                });662                              }663                              void copyField(cardNum, "cardNumber", e.currentTarget as HTMLButtonElement);664                            }}665                            aria-label="Copy card number"666                          >667                            <CopyGlyph /><span class="peach-copy-sheen" />668                          </button>669                        </Show>670                      </div>671                    </div>672                  </div>673674                  {/* Expiry */}675                  <Show when={entry().expMonth || entry().expYear}>676                    <div class="peach-field-card">677                      <span class="peach-label">expiry</span>678                      <div class="peach-field-value-row">679                        <span class="peach-field-value">{entry().expMonth} / {entry().expYear}</span>680                        <button681                          class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "expiry" ? "is-copied" : ""}`}682                          onClick={(e) => void copyField(`${entry().expMonth}/${entry().expYear}`, "expiry", e.currentTarget as HTMLButtonElement)}683                          aria-label="Copy expiry"684                        >685                          <CopyGlyph /><span class="peach-copy-sheen" />686                        </button>687                      </div>688                    </div>689                  </Show>690691                  {/* CVC */}692                  <Show when={entry().cvc}>693                    <div class="peach-field-card">694                      <span class="peach-label">CVC</span>695                      <div class="peach-field-value-row">696                        <span class="peach-field-value">697                          <Show when={showCardCvc()} fallback={<span class="peach-password-mask">•••</span>}>698                            <span>{entry().cvc}</span>699                          </Show>700                        </span>701                        <div class="peach-field-actions">702                          <button703                            class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"704                            onClick={() => setShowCardCvc(!showCardCvc())}705                            aria-label={showCardCvc() ? "Hide CVC" : "Show CVC"}706                          >707                            <Show when={showCardCvc()} fallback={<EyeOpenGlyph />}>708                              <EyeClosedGlyph />709                            </Show>710                          </button>711                          <button712                            class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cvc" ? "is-copied" : ""}`}713                            onClick={(e) => void copyField(entry().cvc!, "cvc", e.currentTarget as HTMLButtonElement)}714                            aria-label="Copy CVC"715                          >716                            <CopyGlyph /><span class="peach-copy-sheen" />717                          </button>718                        </div>719                      </div>720                    </div>721                  </Show>722                </section>723              </Show>724725              {/* ── Login fields ─────────────────────────────────── */}726              <Show when={entry().type !== "note" && entry().type !== "card"}>727                <section class="peach-fields" style="position:relative;z-index:1;">728729                  {/* Username */}730                  <div class="peach-field-card" classList={{ "is-editing": editingField() === "username" }}>731                    <span class="peach-label">username</span>732                    <Show733                      when={editingField() === "username"}734                      fallback={735                        <div class="peach-field-value-row">736                          <span737                            class="peach-field-value peach-inline-clickable"738                            title="Click to edit"739                            onClick={() => startEdit("username", entry().username ?? "")}740                          >741                            {entry().username || <span style="color:var(--t2);font-style:italic;">click to add username</span>}742                          </span>743                          <Show when={entry().username}>744                            <button745                              class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "username" ? "is-copied" : ""}`}746                              onClick={(e) => void copyField(entry().username!, "username", e.currentTarget as HTMLButtonElement)}747                              aria-label="Copy username"748                            >749                              <CopyGlyph /><span class="peach-copy-sheen" />750                            </button>751                          </Show>752                        </div>753                      }754                    >755                      <div756                        class="peach-inline-field-wrap"757                        onFocusOut={(event) => handleEditorFocusOut("username", event)}758                      >759                        <input760                          class="peach-inline-input"761                          value={draft()}762                          onInput={(e) => setDraft(e.currentTarget.value)}763                          onKeyDown={onEditKeyDown}764                          ref={autoFocus}765                          disabled={saving()}766                          autocomplete="off"767                        />768                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />769                      </div>770                    </Show>771                  </div>772773                  {/* Password */}774                  <div class="peach-field-card" classList={{ "is-editing": editingField() === "password" }}>775                    <span class="peach-label">password</span>776                    <Show777                      when={editingField() === "password"}778                      fallback={779                        <div class="peach-field-value-row">780                          <span781                            class="peach-password-value"782                            classList={{ "is-revealed": showPassword(), "is-breached": (entry().breach ?? 0) > 0 }}783                            style="cursor:pointer;"784                            title="Click to edit password"785                            onClick={() => startEdit("password", entry().password ?? "")}786                          >787                            <Show788                              when={showPassword()}789                              fallback={<span class="peach-password-mask" aria-label="password hidden">••••••••••••••</span>}790                            >791                              <span class="peach-password-text">{entry().password ?? ""}</span>792                            </Show>793                          </span>794                          <div class="peach-field-actions">795                            <button796                              class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"797                              onClick={() => setShowPassword(!showPassword())}798                              aria-label="Reveal password"799                            >800                              <Show when={showPassword()} fallback={<EyeOpenGlyph />}>801                                <EyeClosedGlyph />802                              </Show>803                            </button>804                            <button805                              class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "password" ? "is-copied" : ""}`}806                              onClick={(e) => { if (entry().password) void copyField(entry().password!, "password", e.currentTarget as HTMLButtonElement); }}807                              aria-label="Copy password"808                            >809                              <CopyGlyph /><span class="peach-copy-sheen" />810                            </button>811                          </div>812                        </div>813                      }814                    >815                      {/* ── Edit mode: type / paste / generate ── */}816                      <div817                        class="peach-inline-field-wrap"818                        onFocusOut={(event) => handleEditorFocusOut("password", event)}819                      >820                        <div class="peach-password-edit-row">821                          <input822                            class="peach-inline-input"823                            type={showPassword() ? "text" : "password"}824                            placeholder="paste or type a password"825                            value={draft()}826                            onInput={(e) => setDraft(e.currentTarget.value)}827                            onKeyDown={(e) => {828                              if (e.key === "Enter") { e.preventDefault(); void saveField(); }829                              if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }830                            }}831                            ref={autoFocus}832                            disabled={saving()}833                            autocomplete="new-password"834                          />835                          <button836                            class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"837                            type="button"838                            onClick={() => setShowPassword(!showPassword())}839                            aria-label={showPassword() ? "Hide" : "Show"}840                          >841                            <Show when={showPassword()} fallback={<EyeOpenGlyph />}>842                              <EyeClosedGlyph />843                            </Show>844                          </button>845                        </div>846                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />847                        <button848                          class="peach-generate-toggle-link"849                          type="button"850                          onClick={() => setShowGenerator(!showGenerator())}851                        >852                          <svg width="10" height="10" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">853                            <path d="M11 2.5A5.5 5.5 0 1 0 12.5 7" />854                            <path d="M9.5 2.5H11V1" />855                          </svg>856                          {showGenerator() ? "hide generator" : "generate"}857                        </button>858                        <Show when={showGenerator()}>859                          <GeneratorDrawer860                            open={true}861                            compact={true}862                            onUse={(pw) => { setDraft(pw); setShowGenerator(false); }}863                          />864                        </Show>865                      </div>866                    </Show>867                  </div>868869                </section>870              </Show>871872              {/* ── Notes (on login entries) ──────────────────────── */}873              <Show when={entry().type !== "note" && entry().type !== "card"}>874                <Show875                  when={entry().notes || editingField() === "notes"}876                  fallback={877                    <button878                      class="peach-add-field-link peach-entry-add-link"879                      onClick={() => startEdit("notes", "")}880                    >881                      + notes882                    </button>883                  }884                >885                  <div style="position:relative;z-index:1;margin-top:6px;">886                    <span class="peach-label" style="display:block;margin-bottom:4px;">notes</span>887                    <Show888                      when={editingField() === "notes"}889                      fallback={890                        <div891                          class="peach-note-content peach-inline-clickable"892                          style="cursor:text;white-space:pre-wrap;"893                          onClick={() => startEdit("notes", entry().notes ?? "")}894                        >895                          {entry().notes}896                        </div>897                      }898                    >899                      <div900                        class="peach-inline-field-wrap"901                        onFocusOut={(event) => handleEditorFocusOut("notes", event)}902                      >903                        <textarea904                          class="peach-inline-textarea"905                          value={draft()}906                          onInput={(e) => setDraft(e.currentTarget.value)}907                          onKeyDown={(e) => { if (e.key === "Escape") { e.preventDefault(); cancelEdit(); } }}908                          ref={(el) => requestAnimationFrame(() => el?.focus())}909                          rows={4}910                          disabled={saving()}911                        />912                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />913                      </div>914                    </Show>915                  </div>916                </Show>917              </Show>918919              {/* ── TOTP ─────────────────────────────────────────── */}920              <Show when={entry().type === "login"}>921                <Show922                  when={totpState() || editingField() === "totp"}923                  fallback={924                    <button925                      class="peach-add-field-link peach-entry-add-link"926                      onClick={() => startEdit("totp", "")}927                    >928                      + 2FA929                    </button>930                  }931                >932                  <section class="peach-totp-band">933                    <Show934                      when={editingField() === "totp"}935                      fallback={936                        <Show when={totpState()}>937                          {(state) => (938                            <>939                              <div940                                class="peach-totp-main"941                                onClick={() => void copyField(state().code.replace(" ", ""), "totp")}942                                onDblClick={() => startEdit("totp", entry().totp ?? "")}943                                title="Click to copy · double-click to edit"944                              >945                                <span class="peach-label">2-factor</span>946                                <div class="peach-totp-code-row">947                                  <p class={`peach-totp-code${totpHot() ? " is-hot" : ""}${copied() === "totp" ? " is-copied" : ""}`}>948                                    <span class="peach-totp-code-text">949                                      {state().code.slice(0, 3)} {state().code.slice(3)}950                                    </span>951                                  </p>952                                </div>953                              </div>954                              <div class="peach-totp-meta">955                                <span class="peach-label">expires</span>956                                <div class="peach-totp-countdown">{state().remaining}s</div>957                                <svg class="peach-totp-ring" width="44" height="44" viewBox="0 0 44 44">958                                  <circle cx="22" cy="22" r="17" class="peach-totp-ring-track" />959                                  <circle cx="22" cy="22" r="17" class="peach-totp-ring-progress"960                                    stroke-dasharray="106.8"961                                    stroke-dashoffset={(1 - state().remaining / 30) * 106.8}962                                  />963                                </svg>964                              </div>965                            </>966                          )}967                        </Show>968                      }969                    >970                      <div971                        class="peach-inline-field-wrap peach-inline-field-wrap--totp"972                        onFocusOut={(event) => handleEditorFocusOut("totp", event)}973                      >974                        <span class="peach-label">2-factor</span>975                        <input976                          class="peach-inline-input"977                          value={draft()}978                          onInput={(e) => setDraft(e.currentTarget.value)}979                          onKeyDown={onEditKeyDown}980                          ref={autoFocus}981                          disabled={saving()}982                          placeholder="otpauth://... or base32 secret"983                          autocomplete="off"984                        />985                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />986                      </div>987                    </Show>988                  </section>989                </Show>990              </Show>991992              {/* ── Tags editor / add-tags link ──────────────────── */}993              <Show when={entry().type !== "passkey"}>994                <Show995                  when={editingTags()}996                  fallback={997                    <Show when={!entry().tags || entry().tags!.length === 0}>998                      <button999                        class="peach-add-field-link peach-entry-add-link"1000                        onClick={startEditTags}1001                      >1002                        + tags1003                      </button>1004                    </Show>1005                  }1006                >1007                  <div1008                    class="peach-inline-tag-editor"1009                    onFocusOut={(e) => {1010                      const next = e.relatedTarget;1011                      if (next instanceof Node && e.currentTarget.contains(next)) return;1012                      void saveTags();1013                    }}1014                  >1015                    <span class="peach-label">tags</span>1016                    <div class="peach-tag-input-wrap">1017                      <For each={tagsDraft()}>1018                        {(tag) => (1019                          <span class="peach-tag-chip">1020                            {tag}1021                            <button1022                              class="peach-tag-chip-remove"1023                              onMouseDown={(e) => e.preventDefault()}1024                              onClick={() => setTagsDraft(tagsDraft().filter(t => t !== tag))}1025                              aria-label={`Remove ${tag}`}1026                            >×</button>1027                          </span>1028                        )}1029                      </For>1030                      <input1031                        class="peach-tag-input"1032                        placeholder={tagsDraft().length === 0 ? "type and press Enter…" : ""}1033                        value={tagInput()}1034                        onInput={(e) => setTagInput(e.currentTarget.value)}1035                        onKeyDown={(e) => {1036                          if (e.key === "Enter" || e.key === ",") {1037                            e.preventDefault();1038                            addTag(tagInput());1039                          } else if (e.key === "Backspace" && !tagInput() && tagsDraft().length > 0) {1040                            setTagsDraft(tagsDraft().slice(0, -1));1041                          } else if (e.key === "Escape") {1042                            e.preventDefault();1043                            setEditingTags(false);1044                            setTagInput("");1045                          }1046                        }}1047                        ref={(el) => requestAnimationFrame(() => el?.focus())}1048                        style="flex:1;min-width:80px;background:transparent;border:none;outline:none;color:var(--t1);font-size:12px;padding:2px;"1049                      />1050                    </div>1051                  </div>1052                </Show>1053              </Show>10541055              {/* ── Bottom spacer ─────────────────────────────────── */}1056              <div style="height:4px;" />1057            </>1058          )}1059        </Show>1060      </div>10611062    </>1063  );1064}10651066// ── Sub-components ────────────────────────────────────────────────────10671068function InlineSaveCancelRow(props: { onSave: () => void; onCancel: () => void; saving: boolean }) {1069  return (1070    <div class="peach-inline-actions">1071      <button1072        class="peach-inline-save-btn"1073        onClick={props.onSave}1074        disabled={props.saving}1075        aria-label="Save"1076        title="Save (Enter)"1077      >1078        <svg width="11" height="9" viewBox="0 0 11 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">1079          <path d="M1 4.5L4 7.5L10 1.5" />1080        </svg>1081      </button>1082      <button1083        class="peach-inline-cancel-btn"1084        onClick={props.onCancel}1085        disabled={props.saving}1086        aria-label="Cancel"1087        title="Cancel (Esc)"1088      >1089        <svg width="9" height="9" viewBox="0 0 9 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round">1090          <path d="M1 1L8 8M8 1L1 8" />1091        </svg>1092      </button>1093    </div>1094  );1095}10961097function EmptyVaultState() {1098  return (1099    <>1100      <div class="peach-entry-name-wrap" style="position:relative;z-index:1;">1101        <div class="peach-entry-name">1102          <span class="peach-entry-name-text">1103            <span class="peach-entry-name-char" style="--char-index:0">e</span>1104            <span class="peach-entry-name-char" style="--char-index:1">m</span>1105            <span class="peach-entry-name-char" style="--char-index:2">p</span>1106            <span class="peach-entry-name-char" style="--char-index:3">t</span>1107            <span class="peach-entry-name-char" style="--char-index:4">y</span>1108          </span>1109        </div>1110      </div>1111      <div class="peach-empty-copy">1112        <span class="peach-label">vault</span>1113        <p>Your vault is ready for its first entry.</p>1114      </div>1115    </>1116  );1117}11181119function CopyGlyph() {1120  return (1121    <span class="peach-copy-icon">1122      <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">1123        <rect x="4" y="4" width="8" height="9" rx="1.5" />1124        <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" />1125      </svg>1126    </span>1127  );1128}11291130function EyeClosedGlyph() {1131  return (1132    <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">1133      <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />1134      <path d="M3.5 5.5L2.5 7.5" />1135      <path d="M7 6.5V8.5" />1136      <path d="M10.5 5.5L11.5 7.5" />1137    </svg>1138  );1139}11401141function EyeOpenGlyph() {1142  return (1143    <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">1144      <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" />1145      <circle cx="7" cy="5" r="1.75" />1146    </svg>1147  );1148}11491150function ChevronGlyph(props: { open: boolean }) {1151  return (1152    <svg1153      width="8"1154      height="8"1155      viewBox="0 0 8 8"1156      fill="none"1157      stroke="currentColor"1158      stroke-width="1.3"1159      stroke-linecap="round"1160      stroke-linejoin="round"1161      style={`transform: rotate(${props.open ? 90 : 0}deg); transition: transform 160ms ease;`}1162    >1163      <path d="M2 1L6 4L2 7" />1164    </svg>1165  );1166}11671168function CanarySection(props: { entry: VaultEntry; onSaved: () => void | Promise<void> }) {1169  const [enabled, setEnabled] = createSignal(Boolean(props.entry.canary));1170  const [webhookUrl, setWebhookUrl] = createSignal(props.entry.canary?.webhookUrl ?? "");1171  const [label, setLabel] = createSignal(props.entry.canary?.label ?? "");1172  const [testResult, setTestResult] = createSignal<string | null>(null);1173  const [testing, setTesting] = createSignal(false);11741175  async function save() {1176    const url = webhookUrl().trim();1177    const nextLabel = label().trim() || undefined;1178    const canary = enabled() && url1179      ? {1180          webhookUrl: url,1181          label: nextLabel,1182          lastPinged: props.entry.canary?.lastPinged,1183          pingCount: props.entry.canary?.pingCount,1184        }1185      : undefined;1186    await sendMessage({1187      type: "UPDATE_ENTRY",1188      payload: { id: props.entry.id, patch: { canary } },1189    });1190    await props.onSaved();1191  }11921193  async function testPing() {1194    const url = webhookUrl().trim();1195    if (!url) return;1196    setTesting(true);1197    setTestResult(null);1198    const res = await sendMessage({1199      type: "FIRE_CANARY_PING",1200      payload: { webhookUrl: url, entryTitle: props.entry.title, trigger: "test" },1201    }) as { ok: boolean; error?: string };1202    setTestResult(res.ok ? "sent ✓" : `failed — ${res.error ?? "check URL"}`);1203    setTesting(false);1204  }12051206  return (1207    <div class="peach-canary-section">1208      <div class="peach-field-value-row">1209        <span class="peach-field-value" style="font-size:12px;">canary alert</span>1210        <button1211          class={`peach-toggle ${enabled() ? "is-on" : ""}`}1212          onClick={() => { setEnabled(!enabled()); void save(); }}1213          aria-label="Toggle canary"1214        >1215          <span class="peach-toggle-knob" />1216        </button>1217      </div>1218      <p style="font-size:10px;color:var(--peach-t2);margin:4px 0 0;line-height:1.5;">1219        Fires a silent webhook when this entry's password or username is copied. Great for honeypot credentials.1220      </p>1221      <Show when={enabled()}>1222        <div class="peach-inline-field-wrap" style="margin-top:8px;">1223          <input1224            class="peach-inline-input"1225            placeholder="https://hooks.zapier.com/..."1226            value={webhookUrl()}1227            onInput={(e) => setWebhookUrl(e.currentTarget.value)}1228            onBlur={() => void save()}1229          />1230          <input1231            class="peach-inline-input"1232            placeholder="label (optional, e.g. AWS decoy)"1233            value={label()}1234            onInput={(e) => setLabel(e.currentTarget.value)}1235            onBlur={() => void save()}1236          />1237          <div style="display:flex;align-items:center;gap:8px;margin-top:4px;">1238            <button1239              class="peach-btn peach-btn--ghost peach-btn--sm"1240              onClick={() => void testPing()}1241              disabled={testing() || !webhookUrl().trim()}1242            >1243              {testing() ? "sending..." : "test ping"}1244            </button>1245            <Show when={testResult()}>1246              <span style="font-size:10px;color:var(--peach-t2);font-family:var(--font-mono);">{testResult()}</span>1247            </Show>1248          </div>1249          <Show when={props.entry.canary?.lastPinged}>1250            <p style="font-size:10px;color:var(--peach-t2);margin-top:4px;">1251              Last triggered: {new Date(props.entry.canary!.lastPinged!).toLocaleDateString()}1252              {" · "}{props.entry.canary?.pingCount ?? 0} total alerts1253            </p>1254          </Show>1255        </div>1256      </Show>1257    </div>1258  );1259}