Entry.tsx

59.8 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  let lastSavedNote: string | null = null;5051  function noteExec(cmd: string, val?: string) {52    document.execCommand(cmd, false, val);53    noteEditorEl()?.focus();54  }5556  async function saveNoteInline(html: string) {57    const entry = props.entry;58    if (!entry) return;59    const currentStored = entry.notes ?? "";60    if (html === currentStored) return;61    if (lastSavedNote !== null && html === lastSavedNote) return;62    lastSavedNote = html;63    setSaving(true);64    try {65      await sendMessage({66        type: "UPDATE_ENTRY",67        payload: { id: entry.id, patch: { notes: html } },68      });69      triggerRing();70      await props.onFieldSaved();71    } catch {72      lastSavedNote = null;73    } finally {74      setSaving(false);75    }76  }7778  const interval = window.setInterval(() => setTotpTick((n) => n + 1), 1000);79  onCleanup(() => window.clearInterval(interval));8081  const displayDomain = createMemo(() =>82    props.entry?.url ? getDisplayDomain(props.entry.url) : ""83  );8485  createEffect(() => {86    const domain = displayDomain();87    setFaviconSrc(domain ? faviconUrl(domain) : null);88  });8990  // Reset transient prompts (trash confirmation, inline edits) when the91  // selected entry changes so they don't carry over between entries.92  createEffect(() => {93    void props.entry?.id;94    setConfirmDelete(false);95    setEditingField(null);96    setEditingTags(false);97  });9899  const totpState = createMemo(() => {100    void totpTick();101    if (!props.entry?.totp) return null;102    return generateTOTP(props.entry.totp);103  });104105  let prevTotpCode: string | undefined;106  createEffect(() => {107    const state = totpState();108    if (!state) { prevTotpCode = undefined; return; }109    if (prevTotpCode !== undefined && state.code !== prevTotpCode) {110      setTotpHot(true);111      window.setTimeout(() => setTotpHot(false), 3000);112    }113    prevTotpCode = state.code;114  });115116  const totpProgress = createMemo(() => {117    const state = totpState();118    if (!state) return 0;119    return ((state.remaining - 1) / 29) * 100;120  });121122  createEffect(() => {123    const shell = document.getElementById("hd-shell");124    if (!shell) return;125126    const state = totpState();127    if (!state) {128      delete shell.dataset.totpActive;129      shell.style.removeProperty("--peach-totp-progress");130      return;131    }132133    shell.dataset.totpActive = "true";134    shell.style.setProperty(135      "--peach-totp-progress",136      `${Math.max(0, totpProgress())}%`137    );138139    onCleanup(() => {140      delete shell.dataset.totpActive;141      shell.style.removeProperty("--peach-totp-progress");142    });143  });144145  const titleSizeClass = createMemo(() => {146    const length = props.entry?.title.length ?? 0;147    if (length >= 28) return "peach-entry-name--xxl";148    if (length >= 20) return "peach-entry-name--xl";149    if (length >= 14) return "peach-entry-name--lg";150    return "";151  });152  const useAnimatedTitle = createMemo(() => (props.entry?.title.length ?? 0) <= 13);153154  // ── Inline editing ────────────────────────────────────────────────155156  function startEdit(field: EditableField, value: string) {157    setEditingField(field);158    setDraft(value);159    setShowGenerator(false);160    setConfirmDelete(false);161    if (field === "url") {162      setAdditionalUrlsDraft([...(props.entry?.additionalUrls ?? [])]);163    }164  }165166  // Compare URLs by string after trim — vault.ts has its own canonicalisation167  // when saving, so the dedupe here is a UX-level "don't show the button if168  // we'd just add a duplicate" rather than a security check.169  function urlMatchesAny(candidate: string, primary: string, others: string[]): boolean {170    const c = candidate.trim();171    if (!c) return true;172    if (primary.trim() === c) return true;173    return others.some((u) => u.trim() === c);174  }175176  function addCurrentSite(url: string) {177    if (!url) return;178    if (!draft().trim()) {179      setDraft(url);180      return;181    }182    if (urlMatchesAny(url, draft(), additionalUrlsDraft())) return;183    setAdditionalUrlsDraft((arr) => [...arr, url]);184  }185186  async function saveField() {187    const field = editingField();188    const entry = props.entry;189    if (!field || !entry) return;190    // Don't trim passwords — spaces may be intentional191    let value = field === "password" ? draft() : draft().trim();192    if (field === "totp" && value.startsWith("otpauth://")) {193      const parsed = parseOtpauthURI(value);194      value = parsed?.secret ?? value;195    }196    if (field === "password" && entry.type !== "note" && !value) {197      return;198    }199    setSaving(true);200    try {201      const patch: Partial<VaultEntry> = { [field]: value };202      if (field === "url") {203        // Sanitization layer in vault.ts trims, dedupes, and drops empties.204        patch.additionalUrls = additionalUrlsDraft();205      }206      await sendMessage({207        type: "UPDATE_ENTRY",208        payload: { id: entry.id, patch },209      });210      triggerRing();211      setEditingField(null);212      setShowGenerator(false);213      await props.onFieldSaved();214    } catch {215      // silent — leave editing open216    } finally {217      setSaving(false);218    }219  }220221  function startEditTags() {222    setTagsDraft([...(props.entry?.tags ?? [])]);223    setTagInput("");224    setEditingTags(true);225  }226227  function addTag(value: string) {228    const n = value.toLowerCase().trim();229    if (!n || n.length > 32 || tagsDraft().includes(n) || tagsDraft().length >= 10) return;230    setTagsDraft([...tagsDraft(), n]);231    setTagInput("");232  }233234  async function saveTags() {235    if (tagInput()) addTag(tagInput());236    const entry = props.entry;237    if (!entry) return;238    const next = tagsDraft().length > 0 ? tagsDraft() : undefined;239    setEditingTags(false);240    setSaving(true);241    try {242      await sendMessage({243        type: "UPDATE_ENTRY",244        payload: { id: entry.id, patch: { tags: next } },245      });246      triggerRing();247      await props.onFieldSaved();248    } catch {249      // silent250    } finally {251      setSaving(false);252    }253  }254255  function cancelEdit() {256    setEditingField(null);257    setDraft("");258    setShowGenerator(false);259  }260261  function fieldValue(entry: VaultEntry, field: Exclude<EditableField, null>): string {262    switch (field) {263      case "title":264        return entry.title;265      case "url":266        return entry.url ?? "";267      case "username":268        return entry.username ?? "";269      case "password":270        return entry.password ?? "";271      case "notes":272        return entry.notes ?? "";273      case "totp":274        return entry.totp ?? "";275    }276  }277278  function handleEditorFocusOut(279    field: Exclude<EditableField, null>,280    event: FocusEvent & { currentTarget: HTMLElement; relatedTarget: EventTarget | null }281  ) {282    const nextTarget = event.relatedTarget;283    if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return;284285    const entry = props.entry;286    if (!entry) return;287288    const currentValue = fieldValue(entry, field);289    const nextValue = field === "password" ? draft() : draft().trim();290    if (nextValue === currentValue) {291      cancelEdit();292    }293  }294295  async function savePassword(password: string) {296    const entry = props.entry;297    if (!entry) return;298    setSaving(true);299    try {300      await sendMessage({301        type: "UPDATE_ENTRY",302        payload: { id: entry.id, patch: { password } },303      });304      triggerRing();305      setShowGenerator(false);306      await props.onFieldSaved();307    } catch {308      // silent309    } finally {310      setSaving(false);311    }312  }313314  // ── Copy ─────────────────────────────────────────────────────────315316  async function copyField(text: string, label: string, button?: HTMLButtonElement) {317    const ok = await popupCopy(text, label);318    if (ok) {319      const entry = props.entry;320      if (entry?.canary?.webhookUrl) {321        const trigger =322          label === "password"323            ? "password_copied"324            : label === "username"325              ? "username_copied"326              : undefined;327        if (trigger) {328          void sendMessage({329            type: "FIRE_CANARY_PING",330            payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger },331          });332        }333      }334      triggerRing();335      triggerPipPulse();336      setCopied(null);337      requestAnimationFrame(() => setCopied(label));338      if (button) {339        button.classList.remove("is-copied", "is-retreat");340        void button.offsetWidth;341        const drain = document.createElement("span");342        drain.className = "peach-copy-drain";343        const bar = document.createElement("span"); bar.className = "peach-copy-drain-bar";344        drain.appendChild(bar);345        button.appendChild(drain);346        window.setTimeout(() => {347          button.classList.remove("is-copied");348          button.classList.add("is-retreat");349          drain.remove();350          window.setTimeout(() => button.classList.remove("is-retreat"), 160);351        }, 1800);352      }353      window.setTimeout(() => setCopied(null), 1800);354    }355  }356357  // ── Keyboard shortcuts ────────────────────────────────────────────358359  createEffect(() => {360    if (!props.entry) return;361    const handler = (e: KeyboardEvent) => {362      const inInput = e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement || e.target instanceof HTMLSelectElement || (e.target as HTMLElement).isContentEditable;363      if (inInput) return;364      switch (e.key) {365        case "c": e.preventDefault();366          if (props.entry?.password) void copyField(props.entry.password, "password"); break;367        case "u": e.preventDefault();368          if (props.entry?.username) void copyField(props.entry.username, "username"); break;369        case "Escape": e.preventDefault(); props.onEscape?.(); break;370      }371    };372    window.addEventListener("keydown", handler);373    onCleanup(() => window.removeEventListener("keydown", handler));374  });375376  // ── Input ref focus helper ────────────────────────────────────────377378  function autoFocus(el: HTMLInputElement | HTMLTextAreaElement) {379    requestAnimationFrame(() => { el.focus(); el.select?.(); });380  }381382  function onEditKeyDown(e: KeyboardEvent) {383    if (e.key === "Enter" && !(e.target instanceof HTMLTextAreaElement)) {384      e.preventDefault();385      void saveField();386    }387    if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }388  }389390  return (391    <>392      <div393        class="peach-ghost"394        aria-hidden="true"395        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;"396      >397        {props.entry?.title || "empty"}398      </div>399400      <div class="peach-stage" style="position:relative;">401        <Show when={props.entry} fallback={<EmptyVaultState />}>402          {(entry) => (403            <>404              {/* ── Breach strip ─────────────────────────────────── */}405              <Show when={entry().type !== "note" && entry().type !== "card" && (entry().breach ?? 0) > 0}>406                <div class="peach-breach-badge">407                  <svg width="10" height="10" viewBox="0 0 12 12" fill="none" stroke="var(--breach)" stroke-width="1.5" stroke-linecap="round">408                    <path d="M6 1L11 10H1L6 1z" />409                    <line x1="6" y1="5" x2="6" y2="7.5" />410                    <circle cx="6" cy="9" r="0.5" fill="var(--breach)" stroke="none" />411                  </svg>412                  <span style="flex:1">found in a known data breach</span>413                  <button414                    class="peach-btn peach-btn--sm peach-btn--accent peach-breach-action"415                    onClick={() => { startEdit("password", entry().password ?? ""); setShowGenerator(true); }}416                  >417                    Change password418                  </button>419                </div>420              </Show>421422              {/* ── Title + trash ─────────────────────────────────── */}423              <div class="peach-entry-header">424                <div class="peach-entry-header-name">425                  <Show426                    when={editingField() === "title"}427                    fallback={428                      <button429                        class="peach-entry-name-button"430                        title="Click to edit title"431                        onClick={() => startEdit("title", entry().title)}432                      >433                        <h1 class={`peach-entry-name ${titleSizeClass()}`} aria-label={entry().title}>434                          <span class="peach-entry-name-text">435                            <Show436                              when={useAnimatedTitle()}437                              fallback={entry().title}438                            >439                              <For each={entry().title.split("")}>440                                {(ch, i) => (441                                  <span class="peach-entry-name-char" style={`--char-index:${i()}`}>442                                    {ch === " " ? "\u00A0" : ch}443                                  </span>444                                )}445                              </For>446                            </Show>447                          </span>448                        </h1>449                      </button>450                    }451                  >452                    <div453                      class="peach-inline-title-wrap"454                      onFocusOut={(event) => handleEditorFocusOut("title", event)}455                    >456                      <input457                        class="peach-inline-title-input"458                        value={draft()}459                        onInput={(e) => setDraft(e.currentTarget.value)}460                        onKeyDown={onEditKeyDown}461                        ref={autoFocus}462                        disabled={saving()}463                      />464                      <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />465                    </div>466                  </Show>467                </div>468469                {/* Subtle trash icon — hidden while editing title */}470                <Show when={editingField() !== "title"}>471                  <Show472                    when={confirmDelete()}473                    fallback={474                      <button475                        class="peach-entry-trash-btn"476                        onClick={() => setConfirmDelete(true)}477                        aria-label="Move to trash"478                        title="Delete entry"479                      >480                        <svg width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">481                          <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />482                          <path d="M4.5 6l.3 3.5M7.5 6l-.3 3.5" />483                        </svg>484                      </button>485                    }486                  >487                    <div class="peach-entry-trash-confirm">488                      <span class="peach-entry-trash-confirm-label">trash?</span>489                      <button490                        class="peach-btn peach-btn--sm peach-btn--danger"491                        style="font-size:10px;padding:2px 8px;min-height:22px;"492                        onClick={() => { setConfirmDelete(false); props.onDelete(); }}493                      >494                        yes495                      </button>496                      <button497                        class="peach-btn peach-btn--ghost peach-btn--sm"498                        style="font-size:10px;padding:2px 8px;min-height:22px;"499                        onClick={() => setConfirmDelete(false)}500                      >501                        no502                      </button>503                    </div>504                  </Show>505                </Show>506              </div>507508              {/* ── Domain / URL ─────────────────────────────────── */}509              <Show when={entry().type !== "note" && entry().type !== "card"}>510                <div class="peach-domain" style="position:relative;z-index:1;">511                  <Show512                    when={editingField() === "url"}513                    fallback={514                      <div515                        class="peach-domain-row peach-inline-clickable"516                        title="Click to edit URL"517                        onClick={() => startEdit("url", entry().url ?? "")}518                      >519                        <Show when={faviconSrc()}>520                          <img521                            src={faviconSrc()!}522                            width="16"523                            height="16"524                            alt=""525                            style="border-radius:3px;margin-right:5px;flex-shrink:0;"526                            onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = "none"; }}527                          />528                        </Show>529                        <span class="peach-domain-value">530                          {displayDomain() || (entry().url ? entry().url : "add a website url")}531                        </span>532                        <span class="peach-inline-edit-hint">edit</span>533                      </div>534                    }535                  >536                    <div537                      class="peach-inline-field-wrap peach-inline-field-wrap--url"538                      onFocusOut={(event) => handleEditorFocusOut("url", event)}539                    >540                      <div class="peach-inline-field-row">541                        <input542                          class="peach-inline-input"543                          type="url"544                          placeholder="https://"545                          value={draft()}546                          onInput={(e) => setDraft(e.currentTarget.value)}547                          onKeyDown={onEditKeyDown}548                          ref={autoFocus}549                          disabled={saving()}550                        />551                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />552                      </div>553                      <div class="peach-additional-urls">554                        <Show when={additionalUrlsDraft().length > 0}>555                          <span class="peach-label peach-additional-urls-label">also matches</span>556                        </Show>557                        <For each={additionalUrlsDraft()}>558                          {(value, i) => (559                            <div class="peach-additional-url-row">560                              <input561                                class="peach-inline-input"562                                type="url"563                                placeholder="https://"564                                value={value}565                                onInput={(e) => setAdditionalUrlsDraft((arr) => {566                                  const next = [...arr];567                                  next[i()] = e.currentTarget.value;568                                  return next;569                                })}570                                disabled={saving()}571                              />572                              <button573                                class="peach-additional-url-remove"574                                type="button"575                                aria-label="Remove URL"576                                onMouseDown={(e) => e.preventDefault()}577                                onClick={() => setAdditionalUrlsDraft((arr) => arr.filter((_, idx) => idx !== i()))}578                                disabled={saving()}579                              >×</button>580                            </div>581                          )}582                        </For>583                        <button584                          class="peach-add-field-link peach-additional-urls-add"585                          type="button"586                          onMouseDown={(e) => e.preventDefault()}587                          onClick={() => setAdditionalUrlsDraft((arr) => [...arr, ""])}588                          disabled={saving()}589                        >590                          + add another url591                        </button>592                        <Show when={props.currentTabUrl && !urlMatchesAny(props.currentTabUrl!, draft(), additionalUrlsDraft())}>593                          <button594                            class="peach-add-field-link peach-additional-urls-add peach-additional-urls-add-current"595                            type="button"596                            title={props.currentTabUrl}597                            onMouseDown={(e) => e.preventDefault()}598                            onClick={() => addCurrentSite(props.currentTabUrl!)}599                            disabled={saving()}600                          >601                            + add current site602                          </button>603                        </Show>604                      </div>605                    </div>606                  </Show>607                </div>608              </Show>609610              {/* ── Tags — read-only chips (editing state lives at bottom) */}611              <Show when={!editingTags() && entry().tags && entry().tags!.length > 0}>612                <div613                  style="display:flex;flex-wrap:wrap;gap:5px;margin:2px 0;position:relative;z-index:1;cursor:pointer;"614                  onClick={startEditTags}615                  title="Click to edit tags"616                >617                  <For each={entry().tags!}>618                    {(tag) => <span class="peach-tag-chip" style="font-size:10px;padding:2px 8px;">{tag}</span>}619                  </For>620                </div>621              </Show>622623              {/* ── Note toolbar + inline editor ─────────────────── */}624              <Show when={entry().type === "note"}>625                <div class="peach-entry-note-toolbar-row">626                  <Toolbar exec={noteExec} class="peach-note-toolbar-inline" />627                </div>628                <section class="peach-fields peach-note-body" style="position:relative;z-index:1;">629                  <NoteEditorInline630                    entryKey={entry().id}631                    initialContent={entry().notes ?? ""}632                    onReady={(el) => { setNoteEditorEl(el); lastSavedNote = null; }}633                    onBlur={saveNoteInline}634                    autoFocus={false}635                  />636                </section>637              </Show>638639              {/* ── Card detail view ─────────────────────────────── */}640              <Show when={entry().type === "card"}>641                <section class="peach-fields" style="position:relative;z-index:1;">642                  {/* Card subtitle: •••• last4 */}643                  <Show when={entry().last4}>644                    <div class="peach-field-card" style="padding-bottom:4px;">645                      <span class="peach-label">646                        <Show when={entry().network && entry().network !== "unknown"}>647                          <span class="peach-card-network-badge" style="margin-left:0;margin-right:6px;">{entry().network}</span>648                        </Show>649                        {entry().cardNickname || `•••• ${entry().last4}`}650                      </span>651                    </div>652                  </Show>653654                  {/* Cardholder name */}655                  <div class="peach-field-card">656                    <span class="peach-label">cardholder name</span>657                    <div class="peach-field-value-row">658                      <span class="peach-field-value">{entry().cardholderName || "—"}</span>659                      <Show when={entry().cardholderName}>660                        <button661                          class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cardholderName" ? "is-copied" : ""}`}662                          onClick={(e) => void copyField(entry().cardholderName!, "cardholderName", e.currentTarget as HTMLButtonElement)}663                          aria-label="Copy cardholder name"664                        >665                          <CopyGlyph /><span class="peach-copy-sheen" />666                        </button>667                      </Show>668                    </div>669                  </div>670671                  {/* Card number */}672                  <div class="peach-field-card">673                    <span class="peach-label">card number</span>674                    <div class="peach-field-value-row">675                      <span class={`peach-field-value peach-card-number`}>676                        <Show when={showCardNumber()} fallback={677                          <span>{`•••• •••• •••• ${entry().last4 ?? "••••"}`}</span>678                        }>679                          <span>{entry().cardNumber ?? ""}</span>680                        </Show>681                      </span>682                      <div class="peach-field-actions">683                        <button684                          class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"685                          onClick={() => setShowCardNumber(!showCardNumber())}686                          aria-label={showCardNumber() ? "Hide card number" : "Reveal card number"}687                        >688                          <Show when={showCardNumber()} fallback={<EyeOpenGlyph />}>689                            <EyeClosedGlyph />690                          </Show>691                        </button>692                        <Show when={entry().cardNumber}>693                          <button694                            class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cardNumber" ? "is-copied" : ""}`}695                            onClick={(e) => {696                              const cardNum = entry().cardNumber!;697                              if (entry().canary?.webhookUrl) {698                                void sendMessage({699                                  type: "FIRE_CANARY_PING",700                                  payload: { webhookUrl: entry().canary!.webhookUrl, entryTitle: entry().title, trigger: "password_copied" },701                                });702                              }703                              void copyField(cardNum, "cardNumber", e.currentTarget as HTMLButtonElement);704                            }}705                            aria-label="Copy card number"706                          >707                            <CopyGlyph /><span class="peach-copy-sheen" />708                          </button>709                        </Show>710                      </div>711                    </div>712                  </div>713714                  {/* Expiry */}715                  <Show when={entry().expMonth || entry().expYear}>716                    <div class="peach-field-card">717                      <span class="peach-label">expiry</span>718                      <div class="peach-field-value-row">719                        <span class="peach-field-value">{entry().expMonth} / {entry().expYear}</span>720                        <button721                          class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "expiry" ? "is-copied" : ""}`}722                          onClick={(e) => void copyField(`${entry().expMonth}/${entry().expYear}`, "expiry", e.currentTarget as HTMLButtonElement)}723                          aria-label="Copy expiry"724                        >725                          <CopyGlyph /><span class="peach-copy-sheen" />726                        </button>727                      </div>728                    </div>729                  </Show>730731                  {/* CVC */}732                  <Show when={entry().cvc}>733                    <div class="peach-field-card">734                      <span class="peach-label">CVC</span>735                      <div class="peach-field-value-row">736                        <span class="peach-field-value">737                          <Show when={showCardCvc()} fallback={<span class="peach-password-mask">•••</span>}>738                            <span>{entry().cvc}</span>739                          </Show>740                        </span>741                        <div class="peach-field-actions">742                          <button743                            class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"744                            onClick={() => setShowCardCvc(!showCardCvc())}745                            aria-label={showCardCvc() ? "Hide CVC" : "Show CVC"}746                          >747                            <Show when={showCardCvc()} fallback={<EyeOpenGlyph />}>748                              <EyeClosedGlyph />749                            </Show>750                          </button>751                          <button752                            class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cvc" ? "is-copied" : ""}`}753                            onClick={(e) => void copyField(entry().cvc!, "cvc", e.currentTarget as HTMLButtonElement)}754                            aria-label="Copy CVC"755                          >756                            <CopyGlyph /><span class="peach-copy-sheen" />757                          </button>758                        </div>759                      </div>760                    </div>761                  </Show>762                </section>763              </Show>764765              {/* ── Login fields ─────────────────────────────────── */}766              <Show when={entry().type !== "note" && entry().type !== "card" && !isPasskeyOnly(entry())}>767                <section class="peach-fields" style="position:relative;z-index:1;">768769                  {/* Username */}770                  <div class="peach-field-card" classList={{ "is-editing": editingField() === "username" }}>771                    <span class="peach-label">username</span>772                    <Show773                      when={editingField() === "username"}774                      fallback={775                        <div class="peach-field-value-row">776                          <span777                            class="peach-field-value peach-inline-clickable"778                            title="Click to edit"779                            onClick={() => startEdit("username", entry().username ?? "")}780                          >781                            {entry().username || <span style="color:var(--t2);font-style:italic;">click to add username</span>}782                          </span>783                          <Show when={entry().username}>784                            <button785                              class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "username" ? "is-copied" : ""}`}786                              onClick={(e) => void copyField(entry().username!, "username", e.currentTarget as HTMLButtonElement)}787                              aria-label="Copy username"788                            >789                              <CopyGlyph /><span class="peach-copy-sheen" />790                            </button>791                          </Show>792                        </div>793                      }794                    >795                      <div796                        class="peach-inline-field-wrap"797                        onFocusOut={(event) => handleEditorFocusOut("username", event)}798                      >799                        <input800                          class="peach-inline-input"801                          value={draft()}802                          onInput={(e) => setDraft(e.currentTarget.value)}803                          onKeyDown={onEditKeyDown}804                          ref={autoFocus}805                          disabled={saving()}806                          autocomplete="off"807                        />808                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />809                      </div>810                    </Show>811                  </div>812813                  {/* Password */}814                  <div class="peach-field-card" classList={{ "is-editing": editingField() === "password" }}>815                    <span class="peach-label">password</span>816                    <Show817                      when={editingField() === "password"}818                      fallback={819                        <div class="peach-field-value-row">820                          <span821                            class="peach-password-value"822                            classList={{ "is-revealed": showPassword(), "is-breached": (entry().breach ?? 0) > 0 }}823                            style="cursor:pointer;"824                            title="Click to edit password"825                            onClick={() => startEdit("password", entry().password ?? "")}826                          >827                            <Show828                              when={showPassword()}829                              fallback={<span class="peach-password-mask" aria-label="password hidden">••••••••••••••</span>}830                            >831                              <span class="peach-password-text">{entry().password ?? ""}</span>832                            </Show>833                          </span>834                          <div class="peach-field-actions">835                            <button836                              class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"837                              onClick={() => setShowPassword(!showPassword())}838                              aria-label="Reveal password"839                            >840                              <Show when={showPassword()} fallback={<EyeOpenGlyph />}>841                                <EyeClosedGlyph />842                              </Show>843                            </button>844                            <button845                              class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "password" ? "is-copied" : ""}`}846                              onClick={(e) => { if (entry().password) void copyField(entry().password!, "password", e.currentTarget as HTMLButtonElement); }}847                              aria-label="Copy password"848                            >849                              <CopyGlyph /><span class="peach-copy-sheen" />850                            </button>851                          </div>852                        </div>853                      }854                    >855                      {/* ── Edit mode: type / paste / generate ── */}856                      <div857                        class="peach-inline-field-wrap"858                        onFocusOut={(event) => handleEditorFocusOut("password", event)}859                      >860                        <div class="peach-password-edit-row">861                          <input862                            class="peach-inline-input"863                            type={showPassword() ? "text" : "password"}864                            placeholder="paste or type a password"865                            value={draft()}866                            onInput={(e) => setDraft(e.currentTarget.value)}867                            onKeyDown={(e) => {868                              if (e.key === "Enter") { e.preventDefault(); void saveField(); }869                              if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }870                            }}871                            ref={autoFocus}872                            disabled={saving()}873                            autocomplete="new-password"874                          />875                          <button876                            class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"877                            type="button"878                            onClick={() => setShowPassword(!showPassword())}879                            aria-label={showPassword() ? "Hide" : "Show"}880                          >881                            <Show when={showPassword()} fallback={<EyeOpenGlyph />}>882                              <EyeClosedGlyph />883                            </Show>884                          </button>885                        </div>886                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />887                        <button888                          class="peach-generate-toggle-link"889                          type="button"890                          onClick={() => setShowGenerator(!showGenerator())}891                        >892                          <svg width="10" height="10" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">893                            <path d="M11 2.5A5.5 5.5 0 1 0 12.5 7" />894                            <path d="M9.5 2.5H11V1" />895                          </svg>896                          {showGenerator() ? "hide generator" : "generate"}897                        </button>898                        <Show when={showGenerator()}>899                          <GeneratorDrawer900                            open={true}901                            compact={true}902                            onUse={(pw) => { setDraft(pw); setShowGenerator(false); }}903                          />904                        </Show>905                      </div>906                    </Show>907                  </div>908909                </section>910              </Show>911912              {/* ── Notes (on login entries) ──────────────────────── */}913              <Show when={entry().type !== "note" && entry().type !== "card"}>914                <Show915                  when={entry().notes || editingField() === "notes"}916                  fallback={917                    <button918                      class="peach-add-field-link peach-entry-add-link"919                      onClick={() => startEdit("notes", "")}920                    >921                      + notes922                    </button>923                  }924                >925                  <div style="position:relative;z-index:1;margin-top:6px;">926                    <span class="peach-label" style="display:block;margin-bottom:4px;">notes</span>927                    <Show928                      when={editingField() === "notes"}929                      fallback={930                        <div931                          class="peach-note-content peach-inline-clickable"932                          style="cursor:text;white-space:pre-wrap;"933                          onClick={() => startEdit("notes", entry().notes ?? "")}934                        >935                          {entry().notes}936                        </div>937                      }938                    >939                      <div940                        class="peach-inline-field-wrap"941                        onFocusOut={(event) => handleEditorFocusOut("notes", event)}942                      >943                        <textarea944                          class="peach-inline-textarea"945                          value={draft()}946                          onInput={(e) => setDraft(e.currentTarget.value)}947                          onKeyDown={(e) => { if (e.key === "Escape") { e.preventDefault(); cancelEdit(); } }}948                          ref={(el) => requestAnimationFrame(() => el?.focus())}949                          rows={4}950                          disabled={saving()}951                        />952                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />953                      </div>954                    </Show>955                  </div>956                </Show>957              </Show>958959              {/* ── Tags editor / add-tags link (between notes and TOTP) ─── */}960              <Show961                when={editingTags()}962                fallback={963                  <Show when={!entry().tags || entry().tags!.length === 0}>964                    <button965                      class="peach-add-field-link peach-entry-add-link"966                      onClick={startEditTags}967                    >968                      + tags969                    </button>970                  </Show>971                }972              >973                <div974                  class="peach-inline-tag-editor"975                  onFocusOut={(e) => {976                    const next = e.relatedTarget;977                    if (next instanceof Node && e.currentTarget.contains(next)) return;978                    void saveTags();979                  }}980                >981                  <span class="peach-label">tags</span>982                  <div class="peach-tag-input-wrap">983                    <For each={tagsDraft()}>984                      {(tag) => (985                        <span class="peach-tag-chip">986                          {tag}987                          <button988                            class="peach-tag-chip-remove"989                            onMouseDown={(e) => e.preventDefault()}990                            onClick={() => setTagsDraft(tagsDraft().filter(t => t !== tag))}991                            aria-label={`Remove ${tag}`}992                          >×</button>993                        </span>994                      )}995                    </For>996                    <input997                      class="peach-tag-input"998                      placeholder={tagsDraft().length === 0 ? "type and press Enter…" : ""}999                      value={tagInput()}1000                      onInput={(e) => setTagInput(e.currentTarget.value)}1001                      onKeyDown={(e) => {1002                        if (e.key === "Enter" || e.key === ",") {1003                          e.preventDefault();1004                          addTag(tagInput());1005                        } else if (e.key === "Backspace" && !tagInput() && tagsDraft().length > 0) {1006                          setTagsDraft(tagsDraft().slice(0, -1));1007                        } else if (e.key === "Escape") {1008                          e.preventDefault();1009                          setEditingTags(false);1010                          setTagInput("");1011                        }1012                      }}1013                      ref={(el) => requestAnimationFrame(() => el?.focus())}1014                      style="flex:1;min-width:80px;background:transparent;border:none;outline:none;color:var(--t1);font-size:12px;padding:2px;"1015                    />1016                  </div>1017                </div>1018              </Show>10191020              {/* ── TOTP ─────────────────────────────────────────── */}1021              <Show when={entry().type === "login" && !isPasskeyOnly(entry())}>1022                <Show1023                  when={totpState() || editingField() === "totp"}1024                  fallback={1025                    <button1026                      class="peach-add-field-link peach-entry-add-link"1027                      onClick={() => startEdit("totp", "")}1028                    >1029                      + 2FA1030                    </button>1031                  }1032                >1033                  <section class="peach-totp-band">1034                    <Show1035                      when={editingField() === "totp"}1036                      fallback={1037                        <Show when={totpState()}>1038                          {(state) => (1039                            <>1040                              <div1041                                class="peach-totp-main"1042                                onClick={() => void copyField(state().code.replace(" ", ""), "totp")}1043                                onDblClick={() => startEdit("totp", entry().totp ?? "")}1044                                title="Click to copy · double-click to edit"1045                              >1046                                <span class="peach-label">2-factor</span>1047                                <div class="peach-totp-code-row">1048                                  <p class={`peach-totp-code${totpHot() ? " is-hot" : ""}${copied() === "totp" ? " is-copied" : ""}`}>1049                                    <span class="peach-totp-code-text">1050                                      {state().code.slice(0, 3)} {state().code.slice(3)}1051                                    </span>1052                                  </p>1053                                </div>1054                              </div>1055                              <div class="peach-totp-meta">1056                                <span class="peach-label">expires</span>1057                                <div class="peach-totp-countdown">{state().remaining}s</div>1058                                <svg class="peach-totp-ring" width="44" height="44" viewBox="0 0 44 44">1059                                  <circle cx="22" cy="22" r="17" class="peach-totp-ring-track" />1060                                  <circle cx="22" cy="22" r="17" class="peach-totp-ring-progress"1061                                    stroke-dasharray="106.8"1062                                    stroke-dashoffset={(1 - state().remaining / 30) * 106.8}1063                                  />1064                                </svg>1065                              </div>1066                            </>1067                          )}1068                        </Show>1069                      }1070                    >1071                      <div1072                        class="peach-inline-field-wrap peach-inline-field-wrap--totp"1073                        onFocusOut={(event) => handleEditorFocusOut("totp", event)}1074                      >1075                        <span class="peach-label">2-factor</span>1076                        <input1077                          class="peach-inline-input"1078                          value={draft()}1079                          onInput={(e) => setDraft(e.currentTarget.value)}1080                          onKeyDown={onEditKeyDown}1081                          ref={autoFocus}1082                          disabled={saving()}1083                          placeholder="otpauth://... or base32 secret"1084                          autocomplete="off"1085                        />1086                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1087                      </div>1088                    </Show>1089                  </section>1090                </Show>1091              </Show>10921093              {/* ── Passkey ──────────────────────────────────────── */}1094              <Show when={entry().passkey}>1095                {(passkey) => {1096                  const currentBrowser = detectBrowser();1097                  const crossBrowser =1098                    passkey().browser &&1099                    currentBrowser !== "unknown" &&1100                    passkey().browser !== currentBrowser;1101                  const algorithmLabel =1102                    passkey().algorithm === -8 ? "EdDSA (Ed25519)" : "ES256 (P-256)";1103                  const formatDate = (ts?: number) =>1104                    ts ? new Date(ts).toLocaleDateString() : "Never";1105                  return (1106                    <section1107                      class="peach-fields peach-passkey-section"1108                      style="position:relative;z-index:1;margin-top:6px;"1109                    >1110                      <div class="peach-field-card">1111                        <span class="peach-label">passkey · {passkey().rpId}</span>1112                        <div class="peach-field-value-row">1113                          <span class="peach-field-value">{passkey().userName}</span>1114                          <span class="peach-passkey-badge">passkey</span>1115                        </div>1116                      </div>11171118                      <div class="peach-field-card">1119                        <span class="peach-label">algorithm</span>1120                        <div class="peach-field-value-row">1121                          <span class="peach-field-value">{algorithmLabel}</span>1122                        </div>1123                      </div>11241125                      <div class="peach-field-card">1126                        <span class="peach-label">activity</span>1127                        <div class="peach-field-value-row peach-passkey-activity">1128                          <span class="peach-field-value">1129                            used {passkey().signCount} time{passkey().signCount === 1 ? "" : "s"}1130                          </span>1131                          <span class="peach-passkey-meta">1132                            created {formatDate(passkey().createdAt)}1133                          </span>1134                          <span class="peach-passkey-meta">1135                            last used {formatDate(passkey().lastUsedAt)}1136                          </span>1137                        </div>1138                      </div>11391140                      <Show when={crossBrowser}>1141                        <div class="peach-info-notice peach-info-notice--warn">1142                          {`This passkey was created on ${1143                            passkey().browser === "chrome" ? "Chrome" : "Firefox"1144                          }. To use it on ${1145                            currentBrowser === "chrome" ? "Chrome" : "Firefox"1146                          }, re-register it on this device.`}1147                        </div>1148                      </Show>11491150                      <div class="peach-info-notice">1151                        {passkey().browser === "firefox"1152                          ? "This passkey is held by your platform authenticator. Peach stores only the metadata."1153                          : "The private key is stored encrypted in your Peach vault and is never shown in the UI."}1154                      </div>11551156                      <button1157                        class="peach-add-field-link peach-entry-add-link"1158                        style="color:rgba(220,38,38,0.7);"1159                        onClick={async () => {1160                          const id = entry().id;1161                          await sendMessage({1162                            type: "UPDATE_ENTRY",1163                            payload: { id, patch: { passkey: undefined } },1164                          });1165                          await props.onFieldSaved();1166                        }}1167                      >1168                        − remove passkey1169                      </button>1170                    </section>1171                  );1172                }}1173              </Show>11741175              {/* ── Bottom spacer ─────────────────────────────────── */}1176              <div style="height:4px;" />1177            </>1178          )}1179        </Show>1180      </div>11811182    </>1183  );1184}11851186// ── Sub-components ────────────────────────────────────────────────────11871188function InlineSaveCancelRow(props: { onSave: () => void; onCancel: () => void; saving: boolean }) {1189  return (1190    <div class="peach-inline-actions">1191      <button1192        class="peach-inline-save-btn"1193        onClick={props.onSave}1194        disabled={props.saving}1195        aria-label="Save"1196        title="Save (Enter)"1197      >1198        <svg width="11" height="9" viewBox="0 0 11 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">1199          <path d="M1 4.5L4 7.5L10 1.5" />1200        </svg>1201      </button>1202      <button1203        class="peach-inline-cancel-btn"1204        onClick={props.onCancel}1205        disabled={props.saving}1206        aria-label="Cancel"1207        title="Cancel (Esc)"1208      >1209        <svg width="9" height="9" viewBox="0 0 9 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round">1210          <path d="M1 1L8 8M8 1L1 8" />1211        </svg>1212      </button>1213    </div>1214  );1215}12161217function EmptyVaultState() {1218  return (1219    <>1220      <div class="peach-entry-name-wrap" style="position:relative;z-index:1;">1221        <div class="peach-entry-name">1222          <span class="peach-entry-name-text">1223            <span class="peach-entry-name-char" style="--char-index:0">e</span>1224            <span class="peach-entry-name-char" style="--char-index:1">m</span>1225            <span class="peach-entry-name-char" style="--char-index:2">p</span>1226            <span class="peach-entry-name-char" style="--char-index:3">t</span>1227            <span class="peach-entry-name-char" style="--char-index:4">y</span>1228          </span>1229        </div>1230      </div>1231      <div class="peach-empty-copy">1232        <span class="peach-label">vault</span>1233        <p>Your vault is ready for its first entry.</p>1234      </div>1235    </>1236  );1237}12381239function CopyGlyph() {1240  return (1241    <span class="peach-copy-icon">1242      <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">1243        <rect x="4" y="4" width="8" height="9" rx="1.5" />1244        <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" />1245      </svg>1246    </span>1247  );1248}12491250function EyeClosedGlyph() {1251  return (1252    <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">1253      <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />1254      <path d="M3.5 5.5L2.5 7.5" />1255      <path d="M7 6.5V8.5" />1256      <path d="M10.5 5.5L11.5 7.5" />1257    </svg>1258  );1259}12601261function EyeOpenGlyph() {1262  return (1263    <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">1264      <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" />1265      <circle cx="7" cy="5" r="1.75" />1266    </svg>1267  );1268}12691270function ChevronGlyph(props: { open: boolean }) {1271  return (1272    <svg1273      width="8"1274      height="8"1275      viewBox="0 0 8 8"1276      fill="none"1277      stroke="currentColor"1278      stroke-width="1.3"1279      stroke-linecap="round"1280      stroke-linejoin="round"1281      style={`transform: rotate(${props.open ? 90 : 0}deg); transition: transform 160ms ease;`}1282    >1283      <path d="M2 1L6 4L2 7" />1284    </svg>1285  );1286}12871288function CanarySection(props: { entry: VaultEntry; onSaved: () => void | Promise<void> }) {1289  const [enabled, setEnabled] = createSignal(Boolean(props.entry.canary));1290  const [webhookUrl, setWebhookUrl] = createSignal(props.entry.canary?.webhookUrl ?? "");1291  const [label, setLabel] = createSignal(props.entry.canary?.label ?? "");1292  const [testResult, setTestResult] = createSignal<string | null>(null);1293  const [testing, setTesting] = createSignal(false);12941295  async function save() {1296    const url = webhookUrl().trim();1297    const nextLabel = label().trim() || undefined;1298    const canary = enabled() && url1299      ? {1300          webhookUrl: url,1301          label: nextLabel,1302          lastPinged: props.entry.canary?.lastPinged,1303          pingCount: props.entry.canary?.pingCount,1304        }1305      : undefined;1306    await sendMessage({1307      type: "UPDATE_ENTRY",1308      payload: { id: props.entry.id, patch: { canary } },1309    });1310    await props.onSaved();1311  }13121313  async function testPing() {1314    const url = webhookUrl().trim();1315    if (!url) return;1316    setTesting(true);1317    setTestResult(null);1318    const res = await sendMessage({1319      type: "FIRE_CANARY_PING",1320      payload: { webhookUrl: url, entryTitle: props.entry.title, trigger: "test" },1321    }) as { ok: boolean; error?: string };1322    setTestResult(res.ok ? "sent ✓" : `failed — ${res.error ?? "check URL"}`);1323    setTesting(false);1324  }13251326  return (1327    <div class="peach-canary-section">1328      <div class="peach-field-value-row">1329        <span class="peach-field-value" style="font-size:12px;">canary alert</span>1330        <button1331          class={`peach-toggle ${enabled() ? "is-on" : ""}`}1332          onClick={() => { setEnabled(!enabled()); void save(); }}1333          aria-label="Toggle canary"1334        >1335          <span class="peach-toggle-knob" />1336        </button>1337      </div>1338      <p style="font-size:10px;color:var(--peach-t2);margin:4px 0 0;line-height:1.5;">1339        Fires a silent webhook when this entry's password or username is copied. Great for honeypot credentials.1340      </p>1341      <Show when={enabled()}>1342        <div class="peach-inline-field-wrap" style="margin-top:8px;">1343          <input1344            class="peach-inline-input"1345            placeholder="https://hooks.zapier.com/..."1346            value={webhookUrl()}1347            onInput={(e) => setWebhookUrl(e.currentTarget.value)}1348            onBlur={() => void save()}1349          />1350          <input1351            class="peach-inline-input"1352            placeholder="label (optional, e.g. AWS decoy)"1353            value={label()}1354            onInput={(e) => setLabel(e.currentTarget.value)}1355            onBlur={() => void save()}1356          />1357          <div style="display:flex;align-items:center;gap:8px;margin-top:4px;">1358            <button1359              class="peach-btn peach-btn--ghost peach-btn--sm"1360              onClick={() => void testPing()}1361              disabled={testing() || !webhookUrl().trim()}1362            >1363              {testing() ? "sending..." : "test ping"}1364            </button>1365            <Show when={testResult()}>1366              <span style="font-size:10px;color:var(--peach-t2);font-family:var(--font-mono);">{testResult()}</span>1367            </Show>1368          </div>1369          <Show when={props.entry.canary?.lastPinged}>1370            <p style="font-size:10px;color:var(--peach-t2);margin-top:4px;">1371              Last triggered: {new Date(props.entry.canary!.lastPinged!).toLocaleDateString()}1372              {" · "}{props.entry.canary?.pingCount ?? 0} total alerts1373            </p>1374          </Show>1375        </div>1376      </Show>1377    </div>1378  );1379}