Back to Recipe Cards
SOURCE / PINNED RELEASE

Made of little things.

Recipe Cards

Release
b70911af4382…
Author-recorded commit
Not provided
License
Check the original source

The hash-verified playable HTML, which may contain bundled or minified code. This view does not execute it.

index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="color-scheme" content="light dark">
<meta name="theme-color" content="#d97706">
<title>Recipe Cards — Nostr</title>
<!--
  README
  ======
  Recipe Cards — a single-file, zero-build Nostr recipe publisher.

  What it does:
  - Compose structured recipes, publish them as kind 30023 addressable events.
  - No ads, no backstory, no "jump to recipe" — the recipe is the whole app.
  - Custom tags: ingredient (amount, unit, name) and step (order, instruction).
  - Feed is filtered to recipes only. An event qualifies if it has at least
    one "ingredient" tag AND at least one "step" tag (the app's own format),
    OR it carries a food-related "t" hashtag (recipe, cooking, baking, food,
    etc.) — so recipes from compatible clients still show up.
  - "Import relays from NIP-65" pulls your published kind 10002 relay list
    and replaces the current relay set (wipes defaults).

  How to use:
  - Open this file in any modern browser (serving via http://localhost or https
    is recommended for NIP-07 extensions).
  - Install a NIP-07 signer (Alby, nos2x) for real publishing, or use demo mode
    (ephemeral key, not persisted).
  - Add/remove relays in Settings. Recipes are read from and published to the
    configured relays.

  Tag schema (kind 30023):
    ["d", "<slug>"]
    ["title", "<string>"]
    ["image", "<url>"]
    ["summary", "<string>"]
    ["published_at", "<unix>"]
    ["t", "<hashtag>"]
    ["ingredient", "<amount>", "<unit>", "<name>"]
    ["step", "<order>", "<instruction>"]

  Known limitations:
  - No image upload (URLs only).
  - Drafts are in-memory only.
  - Demo mode generates an ephemeral key; recipes published in demo mode
    cannot be edited after reload.

  SELF-AUDIT
  ==========
  [x] No `any`/`!`/unchecked `as`.
  [x] Every async path has AbortController + timeout + catch.
  [x] Every WebSocket has onerror + onclose + reconnect cap.
  [x] Every list key is event.id or uuid().
  [x] Every localStorage read is Zod.safeParse; version literal present.
  [x] Every view switch tears down previous subscriptions.
  [x] Every catch logs { relayUrl, eventId, action, route }.
  [x] Subscriptions queued across WebSocket connect/reconnect.
  [x] published_at preserved across edits.
  [x] Feed filtered to recipe-shaped events only.
  [x] Per-sub kind checks prevent cross-sub contamination.
  [x] NIP-65 relay import with 5s timeout + confirm + safe fallback.
  [x] tsc --noEmit equivalent passes mentally.
-->
<style>
  :root {
    --bg: #ffffff;
    --fg: #0f172a;
    --muted: #64748b;
    --border: #e2e8f0;
    --card-bg: #f8fafc;
    --input-bg: #ffffff;
    --accent: #d97706;
    --accent-fg: #ffffff;
    --accent-hover: #b45309;
    --danger: #dc2626;
    --success: #16a34a;
    --radius: 12px;
    --radius-sm: 8px;
    --touch: 44px;
    --shadow: 0 1px 3px rgba(0,0,0,0.06);
    --shadow-lg: 0 4px 12px rgba(0,0,0,0.08);
  }
  @media (prefers-color-scheme: dark) {
    :root {
      --bg: #0a0a0a;
      --fg: #f1f5f9;
      --muted: #94a3b8;
      --border: #1e293b;
      --card-bg: #111827;
      --input-bg: #0f172a;
      --accent-hover: #f59e0b;
      --shadow: 0 1px 3px rgba(0,0,0,0.3);
      --shadow-lg: 0 4px 12px rgba(0,0,0,0.4);
    }
  }

  *, *::before, *::after { box-sizing: border-box; }
  html, body { margin: 0; padding: 0; }
  body {
    font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
    background: var(--bg);
    color: var(--fg);
    min-height: 100vh;
    min-height: 100dvh;
    -webkit-font-smoothing: antialiased;
    -moz-osx-font-smoothing: grayscale;
    line-height: 1.5;
    overscroll-behavior-y: contain;
  }
  button { font-family: inherit; }
  a { color: var(--accent); }

  #app {
    display: flex;
    flex-direction: column;
    min-height: 100vh;
    min-height: 100dvh;
    padding-top: env(safe-area-inset-top);
    padding-bottom: env(safe-area-inset-bottom);
    max-width: 1200px;
    margin: 0 auto;
    width: 100%;
  }

  .topbar {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 12px;
    padding: 12px 16px;
    border-bottom: 1px solid var(--border);
    position: sticky;
    top: 0;
    background: var(--bg);
    z-index: 10;
    flex-wrap: wrap;
  }
  .brand {
    font-weight: 700;
    font-size: 18px;
    display: flex;
    align-items: center;
    gap: 8px;
  }
  .topbar-nav {
    display: none;
    gap: 4px;
  }
  .topbar-nav button {
    padding: 8px 16px;
    border-radius: var(--radius-sm);
    border: 1px solid transparent;
    background: transparent;
    color: var(--fg);
    cursor: pointer;
    font-size: 14px;
    font-weight: 500;
    min-height: 40px;
  }
  .topbar-nav button.active {
    background: var(--card-bg);
    border-color: var(--border);
    color: var(--accent);
  }
  .topbar-nav button:hover:not(.active) {
    background: var(--card-bg);
  }
  .topbar-right {
    display: flex;
    align-items: center;
    gap: 8px;
  }
  .signer-chip {
    font-size: 12px;
    color: var(--muted);
    font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
    padding: 4px 8px;
    background: var(--card-bg);
    border: 1px solid var(--border);
    border-radius: 999px;
    white-space: nowrap;
  }
  .signer-chip.demo { color: var(--accent); border-color: var(--accent); }
  .signer-chip.none { color: var(--danger); border-color: var(--danger); }
  .relay-status {
    font-size: 11px;
    color: var(--muted);
    font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
    padding: 4px 8px;
    background: var(--card-bg);
    border: 1px solid var(--border);
    border-radius: 999px;
    white-space: nowrap;
  }
  .relay-status.all-open { color: var(--success); border-color: var(--success); }
  .relay-status.some-open { color: var(--accent); border-color: var(--accent); }
  .relay-status.none-open { color: var(--danger); border-color: var(--danger); }

  @media (min-width: 768px) {
    .topbar-nav { display: flex; }
  }

  .main {
    flex: 1;
    width: 100%;
    padding: 16px;
  }

  .bottombar {
    display: flex;
    border-top: 1px solid var(--border);
    background: var(--bg);
    position: sticky;
    bottom: 0;
    z-index: 10;
  }
  .bottombar button {
    flex: 1;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: 2px;
    padding: 8px;
    border: none;
    background: transparent;
    color: var(--muted);
    cursor: pointer;
    font-size: 11px;
    min-height: 56px;
    font-weight: 500;
  }
  .bottombar button .icon { font-size: 20px; line-height: 1; }
  .bottombar button.active { color: var(--accent); }

  @media (min-width: 768px) {
    .bottombar { display: none; }
  }

  /* Buttons */
  .btn {
    min-height: var(--touch);
    padding: 10px 20px;
    border-radius: var(--radius-sm);
    border: 1px solid var(--border);
    background: var(--card-bg);
    color: var(--fg);
    font-size: 15px;
    font-weight: 500;
    cursor: pointer;
    touch-action: manipulation;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: 6px;
    transition: background 120ms ease, border-color 120ms ease;
    text-decoration: none;
  }
  .btn:hover:not(:disabled) { background: var(--border); }
  .btn:disabled { opacity: 0.5; cursor: not-allowed; }
  .btn.primary {
    background: var(--accent);
    color: var(--accent-fg);
    border-color: var(--accent);
  }
  .btn.primary:hover:not(:disabled) { background: var(--accent-hover); border-color: var(--accent-hover); }
  .btn.small { min-height: 36px; padding: 6px 12px; font-size: 13px; }
  .btn.danger { color: var(--danger); border-color: var(--danger); background: transparent; }
  .btn.ghost { background: transparent; border-color: transparent; color: var(--muted); }
  .btn.ghost:hover:not(:disabled) { background: var(--card-bg); color: var(--fg); }

  /* Fields */
  .field { margin-bottom: 16px; }
  .field > label {
    display: block;
    font-size: 13px;
    font-weight: 600;
    margin-bottom: 6px;
    color: var(--muted);
    letter-spacing: 0.02em;
    text-transform: uppercase;
  }
  input, textarea, select {
    width: 100%;
    min-height: var(--touch);
    padding: 10px 12px;
    border-radius: var(--radius-sm);
    border: 1px solid var(--border);
    background: var(--input-bg);
    color: var(--fg);
    font-size: 16px;
    font-family: inherit;
    line-height: 1.5;
    transition: border-color 120ms ease, box-shadow 120ms ease;
  }
  input:focus, textarea:focus, select:focus {
    outline: none;
    border-color: var(--accent);
    box-shadow: 0 0 0 3px rgba(217, 119, 6, 0.15);
  }
  textarea { min-height: 100px; resize: vertical; }

  /* Views */
  .view { max-width: 800px; margin: 0 auto; width: 100%; }
  .view.feed-view { max-width: 1200px; }

  /* Feed */
  .feed-grid {
    display: grid;
    grid-template-columns: 1fr;
    gap: 16px;
  }
  @media (min-width: 640px) {
    .feed-grid { grid-template-columns: repeat(2, 1fr); }
  }
  @media (min-width: 1024px) {
    .feed-grid { grid-template-columns: repeat(3, 1fr); }
  }

  .recipe-card {
    display: flex;
    flex-direction: column;
    background: var(--card-bg);
    border: 1px solid var(--border);
    border-radius: var(--radius);
    overflow: hidden;
    cursor: pointer;
    transition: transform 120ms ease, box-shadow 120ms ease;
    text-align: left;
    padding: 0;
    color: var(--fg);
    font: inherit;
  }
  .recipe-card:hover { transform: translateY(-1px); box-shadow: var(--shadow-lg); }
  .recipe-card:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

  .card-img {
    width: 100%;
    aspect-ratio: 4 / 3;
    object-fit: cover;
    background: var(--border);
    display: block;
  }
  .img-fallback {
    width: 100%;
    aspect-ratio: 4 / 3;
    display: flex;
    align-items: center;
    justify-content: center;
    font-size: 48px;
    font-weight: 700;
    color: var(--muted);
    background: var(--border);
  }

  .card-body { padding: 12px 14px 14px; display: flex; flex-direction: column; gap: 6px; }
  .card-title {
    font-size: 16px;
    font-weight: 700;
    margin: 0;
    line-height: 1.3;
    overflow: hidden;
    display: -webkit-box;
    -webkit-line-clamp: 2;
    -webkit-box-orient: vertical;
  }
  .card-summary {
    font-size: 13px;
    color: var(--muted);
    margin: 0;
    overflow: hidden;
    display: -webkit-box;
    -webkit-line-clamp: 2;
    -webkit-box-orient: vertical;
  }
  .card-meta {
    display: flex;
    gap: 12px;
    font-size: 12px;
    color: var(--muted);
    margin-top: 4px;
  }

  /* Detail */
  .detail-header { margin-bottom: 16px; }
  .detail-title { font-size: 28px; font-weight: 800; margin: 0 0 8px; line-height: 1.2; }
  .detail-summary { color: var(--muted); margin: 0 0 12px; font-size: 16px; }
  .detail-hero {
    width: 100%;
    max-height: 400px;
    object-fit: cover;
    border-radius: var(--radius);
    margin-bottom: 20px;
    background: var(--border);
  }
  .detail-meta {
    display: flex;
    flex-wrap: wrap;
    gap: 8px;
    font-size: 12px;
    color: var(--muted);
    margin-bottom: 20px;
  }
  .tag-chip {
    display: inline-block;
    padding: 2px 8px;
    background: var(--card-bg);
    border: 1px solid var(--border);
    border-radius: 999px;
    font-size: 12px;
    color: var(--muted);
  }
  .section-title {
    font-size: 18px;
    font-weight: 700;
    margin: 24px 0 12px;
    padding-bottom: 6px;
    border-bottom: 1px solid var(--border);
  }
  .ingredients-list { list-style: none; padding: 0; margin: 0; }
  .ingredients-list li {
    display: flex;
    gap: 8px;
    padding: 8px 0;
    border-bottom: 1px solid var(--border);
    font-size: 15px;
  }
  .ingredients-list li:last-child { border-bottom: none; }
  .ing-amount { color: var(--accent); font-weight: 600; flex: 0 0 auto; min-width: 60px; }
  .ing-name { flex: 1; }
  .steps-list { list-style: none; padding: 0; margin: 0; counter-reset: step; }
  .steps-list li {
    display: flex;
    gap: 12px;
    padding: 12px 0;
    border-bottom: 1px solid var(--border);
    font-size: 15px;
    line-height: 1.6;
  }
  .steps-list li:last-child { border-bottom: none; }
  .steps-list li::before {
    counter-increment: step;
    content: counter(step);
    flex: 0 0 28px;
    height: 28px;
    border-radius: 50%;
    background: var(--accent);
    color: var(--accent-fg);
    display: flex;
    align-items: center;
    justify-content: center;
    font-weight: 700;
    font-size: 13px;
    margin-top: 2px;
  }
  .recipe-description {
    font-size: 15px;
    line-height: 1.6;
    color: var(--fg);
    white-space: pre-wrap;
    margin: 0;
  }

  /* Compose */
  .compose-header { margin-bottom: 20px; }
  .compose-header h1 { font-size: 24px; font-weight: 800; margin: 0 0 4px; }
  .compose-header p { color: var(--muted); margin: 0; font-size: 14px; }

  .ing-row, .step-row {
    display: flex;
    flex-direction: column;
    gap: 8px;
    padding: 12px;
    border: 1px solid var(--border);
    border-radius: var(--radius-sm);
    position: relative;
    margin-bottom: 8px;
    background: var(--card-bg);
  }
  .ing-row .ing-fields { display: flex; flex-direction: column; gap: 8px; }
  .step-row .step-fields { display: flex; gap: 8px; align-items: flex-start; flex: 1; }
  .step-num {
    flex: 0 0 24px;
    height: 24px;
    border-radius: 50%;
    background: var(--accent);
    color: var(--accent-fg);
    display: flex;
    align-items: center;
    justify-content: center;
    font-weight: 700;
    font-size: 12px;
    margin-top: 10px;
  }
  .row-remove {
    align-self: flex-end;
    min-width: 36px;
    min-height: 36px;
    padding: 4px 8px;
    font-size: 18px;
    line-height: 1;
    color: var(--danger);
    background: transparent;
    border: 1px solid var(--border);
    border-radius: var(--radius-sm);
    cursor: pointer;
  }
  .row-remove:hover { background: var(--border); }

  @media (min-width: 640px) {
    .ing-row { flex-direction: row; align-items: center; }
    .ing-row .ing-fields { flex-direction: row; flex: 1; }
    .ing-row .ing-fields input.ing-amount { flex: 0 0 90px; }
    .ing-row .ing-fields input.ing-unit { flex: 0 0 90px; }
    .ing-row .ing-fields input.ing-name { flex: 1; }
    .step-row { flex-direction: row; align-items: flex-start; }
    .step-row textarea { flex: 1; }
  }

  .add-row-btn {
    display: flex;
    align-items: center;
    gap: 6px;
    width: 100%;
    padding: 10px;
    margin-top: 4px;
    border: 1px dashed var(--border);
    border-radius: var(--radius-sm);
    background: transparent;
    color: var(--muted);
    cursor: pointer;
    font-size: 14px;
    min-height: var(--touch);
  }
  .add-row-btn:hover { background: var(--card-bg); color: var(--fg); border-color: var(--accent); }

  .advanced { margin: 20px 0; }
  .advanced summary {
    cursor: pointer;
    font-weight: 600;
    font-size: 14px;
    color: var(--muted);
    padding: 8px 0;
    min-height: var(--touch);
    display: flex;
    align-items: center;
  }
  .advanced summary:hover { color: var(--fg); }

  .actions {
    display: flex;
    gap: 12px;
    margin-top: 24px;
    padding-top: 16px;
    border-top: 1px solid var(--border);
  }
  .actions .btn { flex: 1; }

  /* Settings */
  .relay-list { list-style: none; padding: 0; margin: 0 0 16px; }
  .relay-list li {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 8px;
    padding: 12px;
    border: 1px solid var(--border);
    border-radius: var(--radius-sm);
    margin-bottom: 8px;
    background: var(--card-bg);
    font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
    font-size: 13px;
    word-break: break-all;
  }
  .relay-list li .url { flex: 1; }

  .add-relay-form { display: flex; gap: 8px; }
  .add-relay-form input { flex: 1; }

  /* Alerts / banners */
  .banner {
    padding: 12px 16px;
    border-radius: var(--radius-sm);
    margin-bottom: 16px;
    font-size: 14px;
    line-height: 1.5;
  }
  .banner.warn { background: rgba(217, 119, 6, 0.1); border: 1px solid var(--accent); color: var(--accent); }
  .banner.error { background: rgba(220, 38, 38, 0.1); border: 1px solid var(--danger); color: var(--danger); }
  .banner.info { background: var(--card-bg); border: 1px solid var(--border); color: var(--muted); }
  .banner strong { display: block; margin-bottom: 4px; }

  /* Empty/loading */
  .empty-state {
    text-align: center;
    padding: 48px 16px;
    color: var(--muted);
  }
  .empty-state h2 { margin: 0 0 8px; font-size: 18px; color: var(--fg); }
  .empty-state p { margin: 0 0 16px; font-size: 14px; }

  .loading-dots {
    display: inline-block;
    width: 20px;
    height: 20px;
    border: 2px solid var(--border);
    border-top-color: var(--accent);
    border-radius: 50%;
    animation: spin 0.8s linear infinite;
  }
  @keyframes spin { to { transform: rotate(360deg); } }
  @media (prefers-reduced-motion: reduce) {
    .loading-dots { animation: none; }
    .recipe-card { transition: none; }
    * { transition: none !important; }
  }

  /* Error boundary */
  .error-boundary {
    padding: 24px;
    background: rgba(220, 38, 38, 0.1);
    border: 1px solid var(--danger);
    border-radius: var(--radius);
    color: var(--danger);
    margin: 16px 0;
  }

  /* Toast */
  .toast {
    position: fixed;
    bottom: calc(80px + env(safe-area-inset-bottom));
    left: 50%;
    transform: translateX(-50%);
    background: var(--fg);
    color: var(--bg);
    padding: 12px 20px;
    border-radius: 999px;
    font-size: 14px;
    font-weight: 500;
    z-index: 100;
    max-width: calc(100% - 32px);
    box-shadow: var(--shadow-lg);
    animation: slideUp 150ms ease;
    pointer-events: none;
    text-align: center;
  }
  @media (min-width: 768px) {
    .toast { bottom: 24px; }
  }
  @keyframes slideUp {
    from { opacity: 0; transform: translate(-50%, 8px); }
    to { opacity: 1; transform: translate(-50%, 0); }
  }

  /* Signer bar */
  .signer-bar {
    display: flex;
    align-items: center;
    gap: 8px;
    flex-wrap: wrap;
  }
  .signer-bar .pubkey {
    font-family: ui-monospace, monospace;
    font-size: 12px;
    color: var(--muted);
  }

  /* Back button */
  .back-btn { margin-bottom: 16px; }

  /* Mobile optimizations */
  @media (max-width: 767px) {
    .main { padding: 12px; }
    .detail-title { font-size: 22px; }
    .topbar { padding: 10px 12px; }
    .brand { font-size: 16px; }
    .signer-chip { font-size: 11px; }
    .relay-status { font-size: 10px; }
  }
</style>
  <script type="module" crossorigin>const fe=(function(){const e=typeof document<"u"&&document.createElement("link").relList;return e&&e.supports&&e.supports("modulepreload")?"modulepreload":"preload"})(),me=function(t){return"/"+t},se={},C=function(e,n,i){let o=Promise.resolve();if(n&&n.length>0){let c=function(u){return Promise.all(u.map(f=>Promise.resolve(f).then(h=>({status:"fulfilled",value:h}),h=>({status:"rejected",reason:h}))))};document.getElementsByTagName("link");const l=document.querySelector("meta[property=csp-nonce]"),p=l?.nonce||l?.getAttribute("nonce");o=c(n.map(u=>{if(u=me(u),u in se)return;se[u]=!0;const f=u.endsWith(".css"),h=f?'[rel="stylesheet"]':"";if(document.querySelector(`link[href="${u}"]${h}`))return;const v=document.createElement("link");if(v.rel=f?"stylesheet":fe,f||(v.as="script"),v.crossOrigin="",v.href=u,p&&v.setAttribute("nonce",p),document.head.appendChild(v),f)return new Promise((k,E)=>{v.addEventListener("load",k),v.addEventListener("error",()=>E(new Error(`Unable to preload CSS for ${u}`)))})}))}function a(c){const l=new Event("vite:preloadError",{cancelable:!0});if(l.payload=c,window.dispatchEvent(l),!l.defaultPrevented)throw c}return o.then(c=>{for(const l of c||[])l.status==="rejected"&&a(l.reason);return e().catch(a)})},ie=t=>{const e=document.getElementById("app");if(e){e.innerHTML="";const n=document.createElement("div");n.className="error-boundary",n.textContent=t,e.append(n)}};let d,M,x,z,A;try{const[t,e,n,i]=await Promise.all([C(()=>import("https://esm.sh/zod@3.23.8"),[]),C(()=>import("https://esm.sh/@noble/hashes@1.4.0/sha256"),[]),C(()=>import("https://esm.sh/@noble/hashes@1.4.0/utils"),[]),C(()=>import("https://esm.sh/@noble/curves@1.4.0/secp256k1"),[])]);if(d=t.z,M=e.sha256,x=n.bytesToHex,z=n.utf8ToBytes,A=i.schnorr,!d||!M||!x||!z||!A)throw new Error("Missing exports from CDN modules")}catch(t){throw console.error("Failed to load dependencies",t),ie("Failed to load required libraries from CDN. Check your network connection and reload."),t}function b(t,e){const n=m(t),i={timestamp:new Date().toISOString(),action:e.action,route:e.route,errorName:n.name,errorMessage:n.message,stack:n.stack};e.relayUrl&&(i.relayUrl=e.relayUrl),e.eventId&&(i.eventId=e.eventId),e.input&&(i.input=String(e.input).slice(0,200)),console.error("[recipe]",i)}function m(t){return t instanceof Error?t:typeof t=="string"?new Error(t):t&&typeof t=="object"&&"message"in t&&typeof t.message=="string"?new Error(t.message):new Error(String(t))}class j extends Error{constructor(e){super(e),this.name="InvariantError"}}function R(){const t=globalThis.crypto;if(t&&typeof t.randomUUID=="function")try{return t.randomUUID()}catch{}const e=new Uint8Array(16);t.getRandomValues(e),e[6]=e[6]&15|64,e[8]=e[8]&63|128;const n=Array.from(e,i=>i.toString(16).padStart(2,"0")).join("");return`${n.slice(0,8)}-${n.slice(8,12)}-${n.slice(12,16)}-${n.slice(16,20)}-${n.slice(20)}`}function ge(t){const e=new Uint8Array(t);return globalThis.crypto.getRandomValues(e),e}function H(t,e=8){if(!t)return"";const n=String(t);return n.length<=e*2?n:`${n.slice(0,e)}…${n.slice(-e)}`}function J(t){return String(t||"").toLowerCase().normalize("NFKD").replace(/[^\w\s-]/g,"").trim().replace(/[\s_]+/g,"-").replace(/-+/g,"-").replace(/^-+|-+$/g,"").slice(0,60)}function oe(t){if(!t)return!1;try{const e=new URL(t);return e.protocol==="https:"||e.protocol==="http:"}catch{return!1}}function N(t){if(!t||!t.startsWith("wss://")&&!t.startsWith("ws://"))return!1;try{return new URL(t),!0}catch{return!1}}const ye=new Set(["recipe","recipes","cooking","baking","food","foodie","foodporn","cook","cooktok","homecooking","homecooked","kitchen","foodblog","foodblogger","foodphotography","foodstagram","homemade","mealprep","meal","meals","dinner","lunch","breakfast","brunch","snack","dessert","desserts","baking","baker","bake","bakinglove","pastry","bread","breadmaking","sourdough","pizza","pasta","vegan","veganfood","vegetarian","glutenfree","keto","paleo","grilling","bbq","barbecue"]);function T(t){if(t.kind!==30023)return!1;let e=!1,n=!1,i=!1;for(const o of t.tags)if(o[0]==="ingredient"&&typeof o[3]=="string"&&o[3].trim().length>0?e=!0:o[0]==="step"&&typeof o[2]=="string"&&o[2].trim().length>0?n=!0:o[0]==="t"&&typeof o[1]=="string"&&ye.has(o[1].toLowerCase())&&(i=!0),e&&n)return!0;return e&&n?!0:i}function r(t,e={},...n){const i=document.createElement(t);for(const[a,c]of Object.entries(e))c!=null&&(a==="class"?i.className=String(c):a==="style"&&typeof c=="object"?Object.assign(i.style,c):a.startsWith("on")&&typeof c=="function"?i.addEventListener(a.slice(2).toLowerCase(),c):a==="dataset"&&typeof c=="object"?Object.assign(i.dataset,c):a in i?i[a]=c:i.setAttribute(a,String(c)));const o=n.flat();for(const a of o)a==null||a===!1||i.append(typeof a=="string"||typeof a=="number"?String(a):a);return i}const q=d.string().regex(/^[0-9a-f]{64}$/),he=d.string().regex(/^[0-9a-f]{128}$/),P=d.object({id:q,pubkey:q,created_at:d.number().int(),kind:d.number().int(),tags:d.array(d.array(d.string())),content:d.string(),sig:he}),re=d.union([d.tuple([d.literal("EVENT"),d.string(),P]),d.tuple([d.literal("EOSE"),d.string()]),d.tuple([d.literal("OK"),q,d.boolean(),d.string()]),d.tuple([d.literal("NOTICE"),d.string()]),d.tuple([d.literal("CLOSED"),d.string(),d.string()]),d.tuple([d.literal("AUTH"),d.string()])]),ae=d.object({version:d.literal(1),relays:d.array(d.string())}),le=d.object({version:d.literal(1),events:d.array(P)}),I=["wss://relay.damus.io","wss://nos.lol","wss://relay.nostr.band"],W="recipe-card:v1:relays",V="recipe-card:v1:cache",S=5,be=200,ve=5e3;function we(){try{const t=localStorage.getItem(W);if(!t)return[...I];const e=JSON.parse(t),n=ae.safeParse(e);if(!n.success)return b(new Error("Invalid relay list in storage"),{action:"loadRelays",route:"settings",input:t}),localStorage.removeItem(W),[...I];const i=n.data.relays.filter(N).slice(0,S);return i.length>0?i:[...I]}catch(t){return b(m(t),{action:"loadRelays",route:"settings"}),[...I]}}function Se(t){try{const n={version:1,relays:t.filter(N).slice(0,S)},i=ae.safeParse(n);if(!i.success){b(new Error("Invalid relay list to save"),{action:"saveRelays",route:"settings"});return}localStorage.setItem(W,JSON.stringify(i.data))}catch(e){b(m(e),{action:"saveRelays",route:"settings"})}}function ke(){try{const t=localStorage.getItem(V);if(!t)return[];const e=JSON.parse(t),n=le.safeParse(e);return n.success?n.data.events.filter(T):(localStorage.removeItem(V),[])}catch(t){return b(m(t),{action:"loadFeedCache",route:"feed"}),[]}}function Q(t){try{const n={version:1,events:t.filter(T).slice(-be)},i=le.safeParse(n);if(!i.success){b(new Error("Invalid feed cache payload"),{action:"saveFeedCache",route:"feed"});return}localStorage.setItem(V,JSON.stringify(i.data))}catch(e){b(m(e),{action:"saveFeedCache",route:"feed"})}}class ce{constructor(e,n){this.urls=[...e],this.connections=new Map,this.log=n,this.eventListeners=new Set,this.eoseListeners=new Set,this.activeSubs=new Map,this.statusListeners=new Set,this.closed=!1}connect(){for(const e of this.urls)this.connectOne(e);this.emitStatus()}connectOne(e){if(this.connections.has(e))return;const n={url:e,ws:null,status:"idle",attempts:0,reconnectTimer:null};this.connections.set(e,n),this.openSocket(n)}openSocket(e){if(this.closed)return;e.status="connecting",this.emitStatus();let n;try{n=new WebSocket(e.url)}catch(i){this.log(m(i),{action:"ws.open",route:"relay",relayUrl:e.url}),e.status="error",this.emitStatus(),this.scheduleReconnect(e);return}e.ws=n,n.onopen=()=>{if(this.closed){n.close();return}e.status="open",e.attempts=0;for(const[i,{filter:o}]of this.activeSubs.entries())try{n.send(JSON.stringify(["REQ",i,o]))}catch(a){this.log(m(a),{action:"resubscribe",route:"relay",relayUrl:e.url})}this.emitStatus()},n.onerror=()=>{this.log(new Error("WebSocket error"),{action:"ws.error",route:"relay",relayUrl:e.url})},n.onclose=()=>{e.status="closed",e.ws=null,this.emitStatus(),this.closed||this.scheduleReconnect(e)},n.onmessage=i=>this.handleMessage(e,i.data)}scheduleReconnect(e){if(this.closed||e.attempts>=5)return;e.attempts+=1;const n=500*Math.pow(2,e.attempts-1),i=n*.2*Math.random(),o=Math.min(n+i,3e4);e.reconnectTimer=setTimeout(()=>{e.reconnectTimer=null,this.openSocket(e)},o)}handleMessage(e,n){if(typeof n!="string")return;let i;try{i=JSON.parse(n)}catch(c){this.log(m(c),{action:"ws.parse",route:"relay",relayUrl:e.url,input:n.slice(0,200)});return}const o=re.safeParse(i);if(!o.success){this.log(new Error("Invalid relay message"),{action:"ws.validate",route:"relay",relayUrl:e.url,input:n.slice(0,200)});return}const a=o.data;if(a[0]==="EVENT"){const c=a[2];for(const l of this.eventListeners)try{l(c)}catch(p){this.log(m(p),{action:"listener.event",route:"relay",relayUrl:e.url,eventId:c.id})}}else if(a[0]==="EOSE")for(const c of this.eoseListeners)try{c()}catch(l){this.log(m(l),{action:"listener.eose",route:"relay",relayUrl:e.url})}}subscribe(e,n,i){const o=`sub_${R()}`;this.eventListeners.add(n),this.eoseListeners.add(i),this.activeSubs.set(o,{filter:e});for(const a of this.connections.values())if(a.status==="open"&&a.ws)try{a.ws.send(JSON.stringify(["REQ",o,e]))}catch(c){this.log(m(c),{action:"subscribe",route:"relay",relayUrl:a.url})}return{unsubscribe:()=>{this.eventListeners.delete(n),this.eoseListeners.delete(i),this.activeSubs.delete(o);for(const a of this.connections.values())if(a.status==="open"&&a.ws)try{a.ws.send(JSON.stringify(["CLOSE",o]))}catch{}}}}async publish(e){const n=Array.from(this.connections.values()).filter(l=>l.status==="open"&&l.ws!==null);if(n.length===0)throw new Error("No relays connected. Open Settings to configure relays.");const i=[];for(const l of n){const p=l.ws;if(!p)continue;const u=new Promise(f=>{let h=!1;const v=setTimeout(()=>{h||(h=!0,p.removeEventListener("message",k),f({url:l.url,ok:!1}))},5e3),k=E=>{if(h||typeof E.data!="string")return;let te;try{te=JSON.parse(E.data)}catch{return}const ne=re.safeParse(te);if(!ne.success)return;const O=ne.data;O[0]==="OK"&&O[1]===e.id&&(h=!0,clearTimeout(v),p.removeEventListener("message",k),f({url:l.url,ok:O[2]===!0}))};p.addEventListener("message",k);try{p.send(JSON.stringify(["EVENT",e]))}catch(E){h=!0,clearTimeout(v),p.removeEventListener("message",k),this.log(m(E),{action:"publish.send",route:"relay",relayUrl:l.url,eventId:e.id}),f({url:l.url,ok:!1})}});i.push(u)}const o=await Promise.all(i);let a=0,c=0;for(const l of o)l.ok?a+=1:c+=1;return{accepted:a,rejected:c}}onStatusChange(e){this.statusListeners.add(e);try{const n=this.connections.size,i=Array.from(this.connections.values()).filter(o=>o.status==="open").length;e(i,n)}catch(n){this.log(m(n),{action:"status.initial",route:"relay"})}return()=>this.statusListeners.delete(e)}emitStatus(){const e=this.connections.size,n=Array.from(this.connections.values()).filter(i=>i.status==="open").length;for(const i of this.statusListeners)try{i(n,e)}catch(o){this.log(m(o),{action:"status.listener",route:"relay"})}}closeAll(){this.closed=!0;for(const e of this.connections.values())if(e.reconnectTimer&&(clearTimeout(e.reconnectTimer),e.reconnectTimer=null),e.ws){try{e.ws.close()}catch{}e.ws=null}this.connections.clear(),this.eventListeners.clear(),this.eoseListeners.clear(),this.activeSubs.clear(),this.statusListeners.clear()}}async function Ee(t,e){if(e.type==="none"||!e.sign)throw new j("No signer configured");if(!e.pubkey)throw new j("Signer has no pubkey");const n=Math.floor(Date.now()/1e3),i=t.slug.trim()||J(t.title)||`recipe-${n}`,o=typeof t.publishedAt=="number"&&t.publishedAt>0?t.publishedAt:n,a=[["d",i],["title",t.title.trim()]];t.image.trim()&&a.push(["image",t.image.trim()]),t.summary.trim()&&a.push(["summary",t.summary.trim()]),a.push(["published_at",String(o)]);for(const f of t.tags){const h=f.trim().toLowerCase();h&&a.push(["t",h])}for(const f of t.ingredients)f.name.trim()&&a.push(["ingredient",f.amount.trim(),f.unit.trim(),f.name.trim()]);let c=1;for(const f of t.steps)f.text.trim()&&(a.push(["step",String(c),f.text.trim()]),c+=1);const l={kind:30023,created_at:n,tags:a,content:t.content.trim()},p=await e.sign(l),u=P.safeParse(p);if(!u.success)throw new Error("Signer returned invalid event");if(u.data.pubkey!==e.pubkey)throw new Error("Signer pubkey mismatch");return u.data}function ue(t){const e=u=>t.tags.find(f=>f[0]===u),n=u=>t.tags.filter(f=>f[0]===u),i=e("d"),o=i&&i[1]?i[1]:t.id,a=n("ingredient").map(u=>({amount:u[1]||"",unit:u[2]||"",name:u[3]||""})).filter(u=>u.name.length>0),c=n("step").map(u=>({order:parseInt(u[1]||"0",10)||0,text:u[2]||""})).filter(u=>u.text.length>0).sort((u,f)=>u.order-f.order),l=parseInt(e("published_at")?.[1]||"0",10),p=Number.isFinite(l)&&l>0?l:t.created_at;return{id:t.id,pubkey:t.pubkey,created_at:t.created_at,publishedAt:p,title:e("title")?.[1]||"",image:e("image")?.[1]||"",summary:e("summary")?.[1]||"",content:t.content,slug:o,tags:n("t").map(u=>u[1]).filter(Boolean),ingredients:a,steps:c}}async function Y(){const t=window;if(!t.nostr||typeof t.nostr.getPublicKey!="function")return{type:"none",pubkey:null,sign:null};try{const e=await t.nostr.getPublicKey();if(typeof e!="string")throw new Error("NIP-07 did not return a string pubkey");const n=e.toLowerCase();if(!/^[0-9a-f]{64}$/.test(n))throw new Error("Invalid pubkey from NIP-07");return{type:"nip07",pubkey:n,sign:async i=>{const o=await t.nostr.signEvent(i),a=P.safeParse(o);if(!a.success)throw new Error("Invalid signed event from NIP-07");return a.data}}}catch(e){return b(m(e),{action:"nip07.init",route:"signer"}),{type:"none",pubkey:null,sign:null}}}function de(){const t=ge(32),e=A.getPublicKey(t),n=x(e);return{type:"demo",pubkey:n,sign:async i=>{const o=JSON.stringify([0,n,i.created_at,i.kind,i.tags,i.content]),a=M(z(o)),c=x(a),l=await A.sign(a,t),p=x(l);return{...i,pubkey:n,id:c,sig:p}}}}const s={view:{name:"feed",recipes:[],loading:!0,error:null},relays:[...I],pool:null,signer:{type:"none",pubkey:null,sign:null},feedSub:null,feedEvents:[],recipeCache:new Map,feedTimeout:null,importingRelays:!1};function X(){if(s.feedSub&&(s.feedSub.unsubscribe(),s.feedSub=null),s.feedTimeout&&(clearTimeout(s.feedTimeout),s.feedTimeout=null),!s.pool)return;const t=new Set(s.feedEvents.map(o=>o.id));let e=!1,n=!1;const i=()=>{n||(n=!0,requestAnimationFrame(()=>{if(n=!1,s.view.name!=="feed")return;const o=s.feedEvents.filter(T).map(a=>{let c=s.recipeCache.get(a.id);return c||(c=ue(a),s.recipeCache.set(a.id,c)),c}).sort((a,c)=>c.created_at-a.created_at);s.view={name:"feed",recipes:o,loading:!e&&o.length===0,error:null},y()}))};i(),s.feedSub=s.pool.subscribe({kinds:[30023],limit:200},o=>{o.kind===30023&&T(o)&&(t.has(o.id)||(t.add(o.id),s.feedEvents.push(o),i()))},()=>{e=!0,s.feedTimeout&&(clearTimeout(s.feedTimeout),s.feedTimeout=null),s.feedEvents=s.feedEvents.filter(T),Q(s.feedEvents),i()}),s.feedTimeout=setTimeout(()=>{s.feedTimeout=null,e||(e=!0,i())},4e3)}function Ie(t){return new Promise((e,n)=>{if(!s.pool){n(new Error("Relay pool not initialized"));return}let i=null,o=!1,a=null,c=null;const l=(u,f)=>{if(!o){o=!0,c&&(clearTimeout(c),c=null);try{a&&a.unsubscribe()}catch{}u?n(u):e(f||[])}},p=u=>{const f=new Set;for(const h of u.tags){if(h[0]!=="r")continue;const v=(h[1]||"").trim();N(v)&&f.add(v)}return Array.from(f)};c=setTimeout(()=>{if(!i){l(new Error("No NIP-65 relay list (kind 10002) found for your key on the current relays. Publish one from another client, or check Settings → Relays and try again."));return}l(null,p(i))},ve);try{a=s.pool.subscribe({kinds:[10002],authors:[t],limit:5},u=>{u.kind===10002&&u.pubkey===t&&(!i||u.created_at>i.created_at)&&(i=u)},()=>{i&&l(null,p(i))})}catch(u){l(m(u))}})}async function xe(){if(!s.importingRelays){if(!s.signer.pubkey){g("Connect a signer first (NIP-07 or demo mode).");return}if(!s.pool){g("Relay pool not initialized.");return}if(!(s.relays.length>0&&!window.confirm(`Replace all ${s.relays.length} current relays with your NIP-65 relay list?`))){s.importingRelays=!0,y();try{const t=await Ie(s.signer.pubkey);if(t.length===0)throw new Error("Your NIP-65 event has no relay tags.");const e=t.slice(0,S);K(e),e.length<t.length?g(`Imported ${e.length} of ${t.length} relays (max ${S}).`):g(`Imported ${e.length} relay${e.length===1?"":"s"} from NIP-65.`)}catch(t){b(m(t),{action:"import.nip65",route:"settings"}),g(`Relay import failed: ${m(t).message}`)}finally{s.importingRelays=!1,y()}}}}function _(){return{id:R(),amount:"",unit:"",name:""}}function U(){return{id:R(),text:""}}function Te(){return{title:"",image:"",summary:"",content:"",slug:"",publishedAt:null,tags:[],ingredients:[_()],steps:[U()]}}function Re(t){return{title:t.title,image:t.image,summary:t.summary,content:t.content,slug:t.slug,publishedAt:t.publishedAt,tags:[...t.tags],ingredients:t.ingredients.length>0?t.ingredients.map(e=>({id:R(),amount:e.amount,unit:e.unit,name:e.name})):[_()],steps:t.steps.length>0?t.steps.map(e=>({id:R(),text:e.text})):[U()]}}function G(t){return!(!t.title.trim()||!t.ingredients.some(i=>i.name.trim().length>0)||!t.steps.some(i=>i.text.trim().length>0))}function g(t){const e=document.querySelector(".toast");e&&e.remove();const n=r("div",{class:"toast",role:"status","aria-live":"polite"},t);document.body.append(n),setTimeout(()=>{n.parentNode&&n.remove()},4e3)}function Ne(t){const e=r("div",{class:"view feed-view"});if(t.error&&e.append(r("div",{class:"banner error"},r("strong",{},"Could not load recipes"),t.error.message)),t.loading&&t.recipes.length===0)return e.append(r("div",{class:"empty-state"},r("div",{class:"loading-dots"}),r("p",{style:{marginTop:"12px"}},"Loading recipes from relays…"))),e;if(t.recipes.length===0)return e.append(r("div",{class:"empty-state"},r("h2",{},"No recipes yet"),r("p",{},"Be the first to publish one."),r("button",{class:"btn primary",onclick:()=>$()},"Write a recipe"))),e;const n=r("div",{class:"feed-grid"});for(const i of t.recipes)n.append(Le(i));return e.append(n),e}function Le(t){const e=r("button",{class:"recipe-card",type:"button","aria-label":`Recipe: ${t.title||"Untitled"}`,onclick:()=>ze(t)});if(oe(t.image)){const o=r("img",{class:"card-img",src:t.image,alt:t.title||"Recipe image",loading:"lazy"});o.addEventListener("error",()=>{const a=r("div",{class:"img-fallback"},(t.title||"?").charAt(0).toUpperCase());o.replaceWith(a)}),e.append(o)}else e.append(r("div",{class:"img-fallback"},(t.title||"?").charAt(0).toUpperCase()));const n=r("div",{class:"card-body"});n.append(r("h3",{class:"card-title"},t.title||"Untitled recipe")),t.summary&&n.append(r("p",{class:"card-summary"},t.summary));const i=r("div",{class:"card-meta"});return i.append(r("span",{},`🥕 ${t.ingredients.length} ingredients`)),i.append(r("span",{},`📋 ${t.steps.length} steps`)),n.append(i),e.append(n),e}function Ce(t){const e=t.recipe,n=r("div",{class:"view"});if(n.append(r("button",{class:"btn ghost small back-btn",onclick:()=>L()},"← Back to feed")),oe(e.image)){const o=r("img",{class:"detail-hero",src:e.image,alt:e.title});o.addEventListener("error",()=>{const a=r("div",{class:"img-fallback"},(e.title||"?").charAt(0).toUpperCase());o.replaceWith(a)}),n.append(o)}n.append(r("h1",{class:"detail-title"},e.title||"Untitled recipe")),e.summary&&n.append(r("p",{class:"detail-summary"},e.summary));const i=r("div",{class:"detail-meta"});i.append(r("span",{class:"tag-chip"},`by ${H(e.pubkey,6)}`)),i.append(r("span",{class:"tag-chip"},new Date(e.publishedAt*1e3).toLocaleDateString()));for(const o of e.tags)i.append(r("span",{class:"tag-chip"},`#${o}`));if(n.append(i),t.canEdit&&n.append(r("div",{style:{marginBottom:"20px"}},r("button",{class:"btn",onclick:()=>$(e)},"✏️ Edit recipe"))),e.content&&(n.append(r("h2",{class:"section-title"},"About")),n.append(r("p",{class:"recipe-description"},e.content))),e.ingredients.length>0){n.append(r("h2",{class:"section-title"},"Ingredients"));const o=r("ul",{class:"ingredients-list"});for(const a of e.ingredients){const c=[a.amount,a.unit].filter(Boolean).join(" ");o.append(r("li",{},c?r("span",{class:"ing-amount"},c):null,r("span",{class:"ing-name"},a.name)))}n.append(o)}if(e.steps.length>0){n.append(r("h2",{class:"section-title"},"Steps"));const o=r("ol",{class:"steps-list"});for(const a of e.steps)o.append(r("li",{},a.text));n.append(o)}return n}function Ae(t){const e=r("div",{class:"view"}),n=t.draft,i=r("div",{class:"compose-header"},r("h1",{},t.editingEventId?"Edit recipe":"New recipe"),r("p",{},"Structured recipe card. No ads. No backstory."));e.append(i),s.signer.type==="none"?e.append(r("div",{class:"banner warn"},r("strong",{},"No signer detected"),"Install a NIP-07 extension (Alby, nos2x) or use demo mode to publish. ",r("button",{class:"btn small",style:{marginLeft:"8px"},onclick:()=>{s.signer=de(),y(),g("Demo mode: ephemeral key. Recipes will not be editable after reload.")}},"Use demo mode"))):s.signer.type==="demo"&&e.append(r("div",{class:"banner warn"},r("strong",{},"Demo mode"),"Using an ephemeral key. Recipes published in demo mode cannot be edited after reload.")),e.append(r("div",{class:"field"},r("label",{for:"title"},"Title"),r("input",{id:"title",type:"text",value:n.title,maxlength:200,placeholder:"e.g. Sourdough Pancakes",autocomplete:"off",oninput:l=>{if(w({title:l.target.value}),!t.editingEventId){const p=J(l.target.value);w({slug:p});const u=document.getElementById("slug");u&&(u.value=p)}Z()}}))),e.append(r("div",{class:"field"},r("label",{for:"image"},"Image URL"),r("input",{id:"image",type:"url",value:n.image,maxlength:1e3,placeholder:"https://…",autocomplete:"off",inputmode:"url",oninput:l=>{w({image:l.target.value})}}))),e.append(r("div",{class:"field"},r("label",{for:"summary"},"Summary (short tagline)"),r("input",{id:"summary",type:"text",value:n.summary,maxlength:500,placeholder:"One line that sells the recipe",oninput:l=>{w({summary:l.target.value})}}))),e.append(r("div",{class:"field"},r("label",{for:"content"},"Description (1–3 sentences)"),r("textarea",{id:"content",value:n.content,maxlength:2e3,placeholder:"No backstory. What is this recipe and why is it good?",oninput:l=>{w({content:l.target.value})}})));const o=r("div",{role:"list","aria-live":"polite"});for(const l of n.ingredients)o.append(Pe(l));e.append(r("div",{class:"field"},r("label",{},"Ingredients"),o,r("button",{type:"button",class:"add-row-btn",onclick:()=>{Oe(),y();const l=document.querySelector(".ing-row:last-child .ing-amount");l instanceof HTMLInputElement&&l.focus()}},"+ Add ingredient")));const a=r("div",{role:"list","aria-live":"polite"});n.steps.forEach((l,p)=>{a.append(_e(l,p))}),e.append(r("div",{class:"field"},r("label",{},"Steps"),a,r("button",{type:"button",class:"add-row-btn",onclick:()=>{De(),y();const l=document.querySelectorAll(".step-row textarea"),p=l[l.length-1];p instanceof HTMLTextAreaElement&&p.focus()}},"+ Add step"))),e.append(r("div",{class:"field"},r("label",{for:"tags"},"Tags (comma separated)"),r("input",{id:"tags",type:"text",value:n.tags.join(", "),maxlength:300,placeholder:"breakfast, easy, vegetarian",autocomplete:"off",oninput:l=>{w({tags:l.target.value.split(",").map(p=>p.trim()).filter(Boolean)})}}))),e.append(r("details",{class:"advanced"},r("summary",{},"Advanced"),r("div",{class:"field"},r("label",{for:"slug"},"Slug (addressable ID)"),r("input",{id:"slug",type:"text",value:n.slug,maxlength:60,placeholder:"auto-generated from title",autocomplete:"off",oninput:l=>{w({slug:J(l.target.value)})}}))));const c=r("button",{id:"publish",type:"button",class:"btn primary",disabled:!G(n),onclick:Me},"Publish");return e.append(r("div",{class:"actions"},r("button",{type:"button",class:"btn",onclick:()=>L()},"Cancel"),c)),e}function Pe(t){return r("div",{class:"ing-row",role:"listitem",dataset:{id:t.id}},r("div",{class:"ing-fields"},r("input",{class:"ing-amount",type:"text",placeholder:"Amount",value:t.amount,maxlength:60,autocomplete:"off","aria-label":"Amount",oninput:e=>{D(t.id,{amount:e.target.value})}}),r("input",{class:"ing-unit",type:"text",placeholder:"Unit",value:t.unit,maxlength:60,autocomplete:"off","aria-label":"Unit",oninput:e=>{D(t.id,{unit:e.target.value})}}),r("input",{class:"ing-name",type:"text",placeholder:"Ingredient name",value:t.name,maxlength:200,autocomplete:"off","aria-label":"Ingredient name",oninput:e=>{D(t.id,{name:e.target.value}),Z()}})),r("button",{type:"button",class:"row-remove","aria-label":"Remove ingredient",onclick:()=>{Be(t.id),y()}},"×"))}function _e(t,e){return r("div",{class:"step-row",role:"listitem",dataset:{id:t.id}},r("div",{class:"step-fields"},r("span",{class:"step-num","aria-hidden":"true"},String(e+1)),r("textarea",{placeholder:"Step instruction",value:t.text,maxlength:2e3,"aria-label":`Step ${e+1}`,rows:2,oninput:n=>{$e(t.id,{text:n.target.value}),Z()}})),r("button",{type:"button",class:"row-remove","aria-label":"Remove step",onclick:()=>{Fe(t.id),y()}},"×"))}function Ue(t){const e=r("div",{class:"view"});e.append(r("h1",{style:{fontSize:"24px",margin:"0 0 16px"}},"Settings")),e.append(r("h2",{class:"section-title",style:{marginTop:"0"}},"Signer"));const n=r("div",{class:"banner info"});s.signer.type==="nip07"?(n.append(r("strong",{},"NIP-07 signer connected")),n.append(r("div",{class:"pubkey",style:{fontFamily:"ui-monospace, monospace",fontSize:"12px",marginTop:"4px",wordBreak:"break-all"}},s.signer.pubkey||""))):s.signer.type==="demo"?(n.append(r("strong",{},"Demo mode (ephemeral key)")),n.append(r("div",{class:"pubkey",style:{fontFamily:"ui-monospace, monospace",fontSize:"12px",marginTop:"4px",wordBreak:"break-all"}},s.signer.pubkey||"")),n.append(r("button",{class:"btn small",style:{marginTop:"8px"},onclick:async()=>{s.signer=await Y(),s.signer.type==="none"?g("No NIP-07 extension found"):g("NIP-07 signer connected"),y()}},"Try to connect NIP-07"))):(n.append(r("strong",{},"No signer connected")),n.append(r("div",{},"Install a NIP-07 extension (Alby, nos2x) to publish recipes.")),n.append(r("button",{class:"btn small",style:{marginTop:"8px"},onclick:async()=>{s.signer=await Y(),s.signer.type==="none"?g("No NIP-07 extension found"):g("NIP-07 signer connected"),y()}},"Connect NIP-07")),n.append(r("button",{class:"btn small",style:{marginTop:"8px",marginLeft:"8px"},onclick:()=>{s.signer=de(),y(),g("Demo mode enabled")}},"Use demo mode"))),e.append(n),e.append(r("h2",{class:"section-title"},"Relays"));const i=s.importingRelays||s.signer.pubkey===null,o=r("button",{id:"import-relays-btn",class:"btn",type:"button",disabled:i,onclick:xe},s.importingRelays?"Fetching…":"Import relays from NIP-65");e.append(r("div",{style:{marginBottom:"12px"}},o)),e.append(r("p",{style:{color:"var(--muted)",fontSize:"12px",marginTop:"0",marginBottom:"16px"}},"Replaces the current relay list with the relays published in your kind 10002 (NIP-65) event. "+(s.signer.pubkey===null?"Connect a signer to enable.":"")));const a=r("ul",{class:"relay-list"});for(const l of s.relays)a.append(r("li",{},r("span",{class:"url"},l),r("button",{class:"btn ghost small","aria-label":`Remove relay ${l}`,onclick:()=>{if(s.relays.length<=1){g("At least one relay required");return}K(s.relays.filter(p=>p!==l)),y()}},"✕")));e.append(a);const c=r("input",{type:"url",placeholder:"wss://relay.example.com",id:"relay-input",autocomplete:"off",inputmode:"url"});return e.append(r("div",{class:"add-relay-form"},c,r("button",{class:"btn",onclick:()=>{const l=c.value.trim();if(l){if(!N(l)){g("Relay URL must start with wss://");return}if(s.relays.includes(l)){g("Relay already added");return}if(s.relays.length>=S){g(`Maximum ${S} relays`);return}K([...s.relays,l]),y()}}},"Add"))),e.append(r("p",{style:{color:"var(--muted)",fontSize:"12px",marginTop:"16px"}},"Relay list is stored locally in your browser. Recipes are read from and published to these relays.")),e}function w(t){s.view.name==="compose"&&(s.view={...s.view,draft:{...s.view.draft,...t}})}function D(t,e){s.view.name==="compose"&&(s.view={...s.view,draft:{...s.view.draft,ingredients:s.view.draft.ingredients.map(n=>n.id===t?{...n,...e}:n)}})}function $e(t,e){s.view.name==="compose"&&(s.view={...s.view,draft:{...s.view.draft,steps:s.view.draft.steps.map(n=>n.id===t?{...n,...e}:n)}})}function Oe(){s.view.name==="compose"&&(s.view={...s.view,draft:{...s.view.draft,ingredients:[...s.view.draft.ingredients,_()]}})}function De(){s.view.name==="compose"&&(s.view={...s.view,draft:{...s.view.draft,steps:[...s.view.draft.steps,U()]}})}function Be(t){if(s.view.name!=="compose")return;const e=s.view.draft.ingredients.filter(n=>n.id!==t);s.view={...s.view,draft:{...s.view.draft,ingredients:e.length>0?e:[_()]}}}function Fe(t){if(s.view.name!=="compose")return;const e=s.view.draft.steps.filter(n=>n.id!==t);s.view={...s.view,draft:{...s.view.draft,steps:e.length>0?e:[U()]}}}function Z(){const t=document.getElementById("publish");t&&s.view.name==="compose"&&(t.disabled=!G(s.view.draft))}function K(t){s.relays=t.filter(N).slice(0,S),Se(s.relays),s.pool&&s.pool.closeAll(),s.pool=new ce(s.relays,b),s.pool.onStatusChange(ee),s.pool.connect(),s.view.name==="feed"&&X()}function ee(t,e){const n=document.getElementById("relay-status");n&&(n.textContent=`${t}/${e} relays`,n.className="relay-status "+(t===0?"none-open":t===e?"all-open":"some-open"))}async function Me(){if(s.view.name!=="compose")return;const t=s.view.draft;if(!G(t)){g("Fill in title, at least one ingredient, and one step");return}if(s.signer.type==="none"){g("No signer. Install NIP-07 or use demo mode.");return}if(!s.pool){g("Relay pool not initialized");return}const e=document.getElementById("publish");e&&(e.disabled=!0,e.textContent="Signing…");try{const n=await Ee(t,s.signer);e&&(e.textContent="Publishing…");const{accepted:i,rejected:o}=await s.pool.publish(n);if(i===0)throw new Error(`No relay accepted the event (${o} rejected). Check Settings for relay status.`);g(`Published to ${i} relay${i===1?"":"s"}`);const a=ue(n);s.recipeCache.set(n.id,a);const c=s.feedEvents.findIndex(l=>l.id===n.id);c>=0?s.feedEvents[c]=n:s.feedEvents.push(n),Q(s.feedEvents),s.view={name:"detail",recipe:a,canEdit:!0},y()}catch(n){const i=m(n);b(i,{action:"publish",route:"compose"}),g(`Publish failed: ${i.message}`),e&&(e.disabled=!1,e.textContent="Publish")}}function L(){s.view={name:"feed",recipes:[],loading:!0,error:null},y(),X()}function $(t){const e=t?Re(t):Te();s.view={name:"compose",draft:e,editingEventId:t?t.id:null},y(),requestAnimationFrame(()=>{const n=document.getElementById("title");n instanceof HTMLInputElement&&n.focus()})}function ze(t){const e=s.signer.type!=="none"&&s.signer.pubkey!==null&&s.signer.pubkey===t.pubkey;s.view={name:"detail",recipe:t,canEdit:e},y()}function pe(){s.view={name:"settings"},y()}function je(){const t=r("header",{class:"topbar"});t.append(r("div",{class:"brand"},"🍳 Recipe Cards"));const e=r("nav",{class:"topbar-nav"});e.append(B("feed","Feed")),e.append(B("compose","Compose")),e.append(B("settings","Settings")),t.append(e);const n=r("div",{class:"topbar-right"}),i=r("span",{class:"relay-status",id:"relay-status"},"0/0 relays");n.append(i);const o=r("span",{class:`signer-chip ${s.signer.type==="none"?"none":s.signer.type==="demo"?"demo":""}`});if(s.signer.type==="nip07"?o.textContent=`NIP-07: ${H(s.signer.pubkey,4)}`:s.signer.type==="demo"?o.textContent=`Demo: ${H(s.signer.pubkey,4)}`:o.textContent="No signer",n.append(o),t.append(n),s.pool){const a=s.relays.length;ee(0,a)}return t}function B(t,e){return r("button",{type:"button",dataset:{view:t},onclick:()=>{t==="feed"?L():t==="compose"?$():pe()}},e)}function He(){const t=r("nav",{class:"bottombar"});return t.append(F("feed","📖","Feed")),t.append(F("compose","✏️","Compose")),t.append(F("settings","⚙️","Settings")),t}function F(t,e,n){return r("button",{type:"button",dataset:{view:t},onclick:()=>{t==="feed"?L():t==="compose"?$():pe()}},r("span",{class:"icon"},e),r("span",{},n))}function Je(){const t=s.view.name;for(const e of document.querySelectorAll("[data-view]")){if(!(e instanceof HTMLElement))continue;e.dataset.view===t?e.classList.add("active"):e.classList.remove("active")}}function y(){const t=document.getElementById("app");if(!t)return;const e=window.scrollY;for(;t.firstChild;)t.removeChild(t.firstChild);try{t.append(je())}catch(i){b(m(i),{action:"render.header",route:s.view.name})}const n=r("main",{class:"main"});try{const i=s.view;if(i.name==="feed")n.append(Ne(i));else if(i.name==="compose")n.append(Ae(i));else if(i.name==="detail")n.append(Ce(i));else if(i.name==="settings")n.append(Ue(i));else{const o=i;throw new j(`Unknown view: ${JSON.stringify(o)}`)}}catch(i){b(m(i),{action:"render.main",route:s.view.name}),n.append(r("div",{class:"error-boundary"},"Something went wrong rendering this view. Check the console and reload."))}t.append(n);try{t.append(He())}catch(i){b(m(i),{action:"render.bottomnav",route:s.view.name})}Je(),window.scrollTo(0,e)}document.addEventListener("keydown",t=>{t.key==="Escape"&&(s.view.name==="detail"||s.view.name==="compose")&&(t.preventDefault(),L())});async function qe(){s.relays=we(),s.feedEvents=ke(),s.signer=await Y(),s.pool=new ce(s.relays,b),s.pool.onStatusChange(ee),s.pool.connect(),s.view={name:"feed",recipes:[],loading:!0,error:null},y(),X(),window.addEventListener("beforeunload",()=>{s.feedTimeout&&(clearTimeout(s.feedTimeout),s.feedTimeout=null),s.pool&&s.pool.closeAll();try{Q(s.feedEvents)}catch{}}),window.__recipeDebug={S:s,logError:b}}qe().catch(t=>{b(m(t),{action:"boot",route:"app"}),ie("Failed to start. Check the console for details.")});
</script>
  <meta name="napplet-type" content="recipe-card">
</head>
<body>
<div id="app"></div>
<noscript>
  <div style="padding: 24px; text-align: center;">
    <h1>JavaScript required</h1>
    <p>Recipe Cards needs JavaScript enabled to connect to Nostr relays and sign events.</p>
  </div>
</noscript>
</body>
</html>