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.
<!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>