cauldron/cauldron/templates/_base.html
Kayos 2a357b2acd ui: wide-screen scaling + recipe thumbnails + admin-only consolidate/discover
Three audit-day UX fixes after Cobb's big-screen review:

WIDE SCREEN
- main max-width 920px → 1500px so the layout actually uses 4K real estate
- recipe-grid gets a 4-column breakpoint at 1400px (was 3 at 1100px)
- discover already auto-fills via repeat(auto-fill, minmax(280px, 1fr)) —
  spreads naturally on wider viewports

RECIPE PHOTOS on /recipes
- _index_row_to_card now emits image_url derived from
  raw.id + raw.image + cfg.mealie_public_url, pointing at Mealie's
  /api/media/recipes/{id}/images/min-original.webp endpoint
- raw.image (which Mealie bumps on every update) is appended as
  ?v=<image-stamp> for cache-busting
- new .recipe-card .rimg style: 16:10 aspect ratio, object-fit cover,
  placeholder 🍴 fallback when no image
- _recipe_card.html (server-rendered first page) and recipes.html
  (AJAX-rendered subsequent pages) both render thumbnails consistently

ADMIN-ONLY VISIBILITY (Cobb 2026-05-02)
- new CAULDRON_ADMIN_SUBS env var → cfg.admin_subs (CSV of authentik
  subjects). Empty default = nobody is admin (safe-fail).
- @app.context_processor injects is_admin globally for templates
- _base.html nav: /discover tab gated by {% if is_admin %}
- me.html: consolidate + discover tool blocks gated by {% if is_admin %}.
  Sterilize / dedupe / enrich stay visible to everyone (Cobb's scope was
  consolidate + discover only)
- new @require_admin decorator (returns 404, not 403, to not advertise
  the route's existence) applied to all 13 consolidate/discover routes:
  pages + api endpoints. URL-typing non-admins now blocked, not just
  hidden in UI

Tested AST. Deploy: cauldron uses up -d --build (no source bind mount).
2026-05-02 13:56:12 -07:00

684 lines
27 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="color-scheme" content="dark">
<meta name="theme-color" content="#0a0a0c">
<title>{% block title %}Cauldron{% endblock %}</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&family=Cinzel:wght@500;600;700&display=swap" rel="stylesheet">
<style>
:root {
--bg: #0a0a0c;
--bg-2: #0f0c14;
--surface: #14101a;
--surface-2: #1d1828;
--surface-3: #251f33;
--line: #2a223a;
--line-soft: #1f1a2a;
--green: #6ea848;
--green-bright: #88c060;
--green-dim: #4a7530;
--green-glow: rgba(110, 168, 72, .25);
--purple: #9b5fe8;
--purple-bright:#b878ff;
--purple-dim: #6b3fa0;
--purple-deep: #2d1d4a;
--purple-glow: rgba(155, 95, 232, .25);
--bone: #e8e0c8;
--bone-dim: #a89d83;
--muted: #6e6478;
--warn: #d4a854;
--crit: #e8606a;
--sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--serif: 'Cinzel', Georgia, serif;
--mono: 'JetBrains Mono', ui-monospace, Menlo, monospace;
}
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
html { scroll-behavior: smooth; -webkit-text-size-adjust: 100%; }
html, body {
margin: 0; padding: 0;
color: var(--bone);
font-family: var(--sans);
font-size: 15px; line-height: 1.6;
min-height: 100vh; min-height: 100dvh;
-webkit-font-smoothing: antialiased;
background: var(--bg);
}
body {
background-image:
radial-gradient(ellipse 80% 60% at 15% 0%, rgba(155, 95, 232, .07) 0%, transparent 60%),
radial-gradient(ellipse 80% 60% at 85% 100%, rgba(110, 168, 72, .05) 0%, transparent 60%);
/* layered: corner candleglow, mossy underbelly, witchy sigil + mushroom tile */
background-image:
radial-gradient(ellipse 80% 60% at 15% 0%, rgba(155, 95, 232, .07) 0%, transparent 60%),
radial-gradient(ellipse 80% 60% at 85% 100%, rgba(110, 168, 72, .05) 0%, transparent 60%),
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='160' height='160' viewBox='0 0 160 160'><g fill='none' stroke='%239b5fe8' stroke-width='.4' stroke-opacity='.05'><circle cx='40' cy='40' r='14'/><polygon points='40,28 43.5,37 53,38 45.8,44 48.1,53 40,48 31.9,53 34.2,44 27,38 36.5,37'/></g><g fill='%236ea848' fill-opacity='.045' stroke='%236ea848' stroke-width='.3' stroke-opacity='.06'><path d='M118 96 q-9 -1 -9 8 q0 4 9 4 q9 0 9 -4 q0 -9 -9 -8z'/><path d='M115 108 v8 q0 2 3 2 q3 0 3 -2 v-8'/><circle cx='113' cy='100' r='1' fill='%23000' fill-opacity='.4' stroke='none'/><circle cx='121' cy='99' r='.8' fill='%23000' fill-opacity='.4' stroke='none'/><circle cx='117' cy='104' r='.7' fill='%23000' fill-opacity='.4' stroke='none'/></g></svg>");
background-attachment: fixed;
background-size: auto, auto, 160px 160px;
}
::selection { background: rgba(155, 95, 232, .35); color: var(--bone); }
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: var(--bg-2); }
::-webkit-scrollbar-thumb { background: var(--surface-2); border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: var(--purple-dim); }
/* Top bar */
header.topbar {
position: sticky; top: 0; z-index: 50;
display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap;
gap: 14px;
padding: 16px 22px;
border-bottom: 1px solid var(--line);
background: rgba(10, 10, 12, .85);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
.brand { display: flex; align-items: baseline; gap: 14px; min-width: 0; }
.brand-mark {
font-family: var(--serif); font-weight: 700;
color: var(--purple-bright); font-size: 22px;
letter-spacing: .15em; text-transform: uppercase;
text-shadow: 0 0 24px var(--purple-glow);
}
.brand-sub { color: var(--muted); font-size: 11px; letter-spacing: .2em; text-transform: uppercase; font-family: var(--mono); }
nav.nav { display: flex; gap: 22px; flex-wrap: wrap; }
nav.nav a {
color: var(--bone-dim); text-decoration: none;
font-size: 12px; letter-spacing: .15em; text-transform: uppercase;
padding: 6px 0; position: relative; transition: color .2s;
}
nav.nav a:hover { color: var(--green-bright); }
nav.nav a.active { color: var(--purple-bright); }
nav.nav a.active::after {
content: ""; position: absolute; left: 0; right: 0; bottom: 0; height: 2px;
background: var(--purple-bright); box-shadow: 0 0 12px var(--purple-glow);
}
.topmeta {
color: var(--muted); font-size: 11px; letter-spacing: .15em; text-transform: uppercase;
font-family: var(--mono);
}
.topmeta .who { color: var(--green-bright); }
/* Main */
main {
max-width: 1500px; margin: 0 auto; padding: 36px 22px 80px;
position: relative;
}
/* Page header */
.page-head { margin-bottom: 1.6em; animation: fadeIn .5s ease-out forwards; }
.page-head .crumb {
color: var(--purple); font-size: 11px; letter-spacing: .25em;
text-transform: uppercase; font-family: var(--mono); margin-bottom: .5em;
}
.page-head h1 {
margin: 0; color: var(--bone);
font-family: var(--serif); font-weight: 600;
font-size: 2.2em; letter-spacing: .04em; line-height: 1.2;
}
.page-head h1 .accent { color: var(--purple-bright); text-shadow: 0 0 24px var(--purple-glow); }
.page-head .lede { color: var(--bone-dim); font-size: 1em; margin-top: .5em; max-width: 60ch; }
/* Panel */
.panel {
background: var(--surface);
border: 1px solid var(--line);
border-radius: 6px;
padding: 20px 22px;
margin: 16px 0;
position: relative;
box-shadow: 0 0 24px -8px rgba(155, 95, 232, .08);
animation: fadeIn .5s ease-out forwards;
opacity: 0;
}
.panel:nth-child(2) { animation-delay: .05s; }
.panel:nth-child(3) { animation-delay: .1s; }
.panel:nth-child(4) { animation-delay: .15s; }
.panel:hover { border-color: var(--surface-3); }
.panel.green {
box-shadow: 0 0 24px -8px rgba(110, 168, 72, .1);
border-left: 2px solid var(--green-dim);
}
.panel.purple { border-left: 2px solid var(--purple-dim); }
.panel-head {
display: flex; align-items: baseline; gap: 14px; flex-wrap: wrap;
margin-bottom: 14px; padding-bottom: 10px;
border-bottom: 1px solid var(--line-soft);
}
.panel-head h2 {
margin: 0; color: var(--purple-bright);
font-family: var(--serif); font-weight: 600;
font-size: 1.05em; letter-spacing: .15em; text-transform: uppercase;
}
.panel.green .panel-head h2 { color: var(--green-bright); }
.panel-head .ctx { color: var(--muted); font-size: 11px; letter-spacing: .15em; text-transform: uppercase; margin-left: auto; font-family: var(--mono); }
/* Pill */
.pill {
display: inline-block; padding: 3px 10px; border: 1px solid;
border-radius: 4px; font-size: 10px; letter-spacing: .2em; text-transform: uppercase;
font-family: var(--mono); font-weight: 600;
}
.pill-ok { color: var(--green-bright); border-color: var(--green-dim); background: rgba(110, 168, 72, .08); }
.pill-warn { color: var(--warn); border-color: var(--warn); background: rgba(212, 168, 84, .08); }
.pill-crit { color: var(--crit); border-color: var(--crit); background: rgba(232, 96, 106, .08); }
.pill-mute { color: var(--bone-dim); border-color: var(--line); background: var(--bg-2); }
/* KV */
.kv { display: grid; grid-template-columns: max-content 1fr; gap: .5em 1.4em; margin: .6em 0; }
.kv dt { color: var(--purple); font-size: 11px; letter-spacing: .15em; text-transform: uppercase; font-family: var(--mono); padding-top: 3px; }
.kv dd { margin: 0; color: var(--bone); font-size: .95em; word-break: break-all; }
/* Buttons */
.btn {
display: inline-block; padding: .6em 1.4em;
background: var(--surface-2); color: var(--bone);
border: 1px solid var(--line);
border-radius: 5px;
font-family: var(--sans); font-weight: 500;
font-size: 13px; letter-spacing: .08em;
cursor: pointer; text-decoration: none;
transition: all .2s ease;
}
.btn:hover { background: var(--surface-3); border-color: var(--purple-dim); color: var(--bone); }
.btn-primary {
background: var(--green-dim); color: var(--bone);
border-color: var(--green); box-shadow: 0 0 20px -8px var(--green-glow);
}
.btn-primary:hover { background: var(--green); border-color: var(--green-bright); box-shadow: 0 0 24px -4px var(--green-glow); }
.btn-purple {
background: var(--purple-deep); color: var(--bone);
border-color: var(--purple-dim); box-shadow: 0 0 20px -8px var(--purple-glow);
}
.btn-purple:hover { background: var(--purple-dim); border-color: var(--purple); box-shadow: 0 0 24px -4px var(--purple-glow); }
.btn-row { display: flex; gap: 12px; flex-wrap: wrap; align-items: center; }
/* Forms */
input[type=text], input[type=password], textarea {
width: 100%; padding: .75em .9em;
background: var(--bg-2); border: 1px solid var(--line);
border-radius: 4px;
color: var(--bone); font-family: var(--mono); font-size: .92em;
transition: border-color .2s, box-shadow .2s;
}
input[type=text]:focus { outline: none; border-color: var(--purple); box-shadow: 0 0 0 3px rgba(155, 95, 232, .12); }
/* Misc */
a { color: var(--green-bright); text-decoration: none; transition: color .2s; }
a:hover { color: var(--purple-bright); }
.muted { color: var(--muted); font-size: .9em; }
.lede { color: var(--bone-dim); }
hr { border: none; border-top: 1px solid var(--line); margin: 2em 0; }
code {
background: var(--purple-deep); border: 1px solid var(--purple-dim);
color: var(--purple-bright);
padding: .12em .5em; border-radius: 3px;
font-family: var(--mono); font-size: .88em;
}
strong { color: var(--bone); font-weight: 600; }
ol, ul { padding-left: 1.4em; }
ol li, ul li { margin: .35em 0; }
/* Search bar (sticky on the recipes page for one-handed scroll) */
.search-row {
display: flex; gap: 10px; align-items: center;
margin-bottom: 14px;
position: sticky; top: 0; z-index: 30;
background: rgba(10, 10, 12, .92); backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
margin-left: -22px; margin-right: -22px;
padding: 14px 22px 10px 22px;
}
.search-row input {
flex: 1; padding: .85em 1em;
background: var(--bg-2); border: 1px solid var(--line);
border-radius: 6px;
color: var(--bone); font-family: var(--sans); font-size: 16px;
/* font-size 16px keeps iOS from auto-zooming on focus */
}
.search-row input:focus { outline: none; border-color: var(--purple); box-shadow: 0 0 0 3px rgba(155, 95, 232, .12); }
.search-row .count { color: var(--muted); font-size: 11px; letter-spacing: .15em; text-transform: uppercase; font-family: var(--mono); white-space: nowrap; }
/* Chip rows — sort + category quick filters, horizontal scroll on mobile */
.chip-row {
display: flex; gap: 8px; flex-wrap: nowrap; overflow-x: auto;
padding: 4px 0 8px 0; margin-bottom: 4px;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
.chip-row::-webkit-scrollbar { display: none; }
.chip {
flex-shrink: 0;
display: inline-block; padding: .5em 1em;
background: var(--surface); border: 1px solid var(--line);
border-radius: 999px;
color: var(--bone-dim); text-decoration: none;
font-family: var(--sans); font-weight: 500;
font-size: 13px; letter-spacing: .03em;
white-space: nowrap; transition: all .15s ease;
}
.chip:hover { border-color: var(--purple-dim); color: var(--bone); background: var(--surface-2); }
.chip.active { background: var(--purple-deep); color: var(--purple-bright); border-color: var(--purple); }
.chip.active:hover { background: var(--purple-dim); color: var(--bone); }
.chip-label {
flex-shrink: 0; align-self: center;
color: var(--muted); font-size: 11px; letter-spacing: .15em;
text-transform: uppercase; font-family: var(--mono); padding: 0 4px;
}
/* Recipe grid + cards — bigger, mobile-first */
.recipe-grid {
display: grid; grid-template-columns: 1fr; gap: 14px;
}
@media (min-width: 720px) { .recipe-grid { grid-template-columns: 1fr 1fr; gap: 16px; } }
@media (min-width: 1100px) { .recipe-grid { grid-template-columns: 1fr 1fr 1fr; } }
@media (min-width: 1400px) { .recipe-grid { grid-template-columns: 1fr 1fr 1fr 1fr; } }
.recipe-card {
display: flex; flex-direction: column;
border: 1px solid var(--line); background: var(--surface);
border-radius: 8px;
text-decoration: none; color: inherit;
position: relative; overflow: hidden;
transition: all .2s ease;
min-height: 88px; /* makes the whole card a comfortable tap */
}
.recipe-card .rimg {
width: 100%; aspect-ratio: 16/10;
background: var(--bg-2) center/cover no-repeat;
border-bottom: 1px solid var(--line);
display: block;
}
.recipe-card .rimg.placeholder {
display: flex; align-items: center; justify-content: center;
color: var(--muted); font-size: 28px; opacity: .55;
}
.recipe-card .rbody { padding: 14px 16px 16px 20px; }
.recipe-card.picked > .rbody::before {
content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 3px;
background: var(--purple-bright); box-shadow: 0 0 12px var(--purple-glow);
}
.recipe-card.picked { border-color: var(--purple-dim); background: rgba(45, 29, 74, .35); }
.recipe-card.picked::before {
content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 3px;
background: var(--purple-bright); box-shadow: 0 0 12px var(--purple-glow);
z-index: 1;
}
.recipe-card:active, .recipe-card:hover {
border-color: var(--purple-dim); background: var(--surface-2);
transform: translateY(-1px);
box-shadow: 0 4px 24px -8px var(--purple-glow);
}
.recipe-card .rmain { display: block; padding-right: 56px; }
.recipe-card .pick-btn { z-index: 2; }
.recipe-card .rname {
color: var(--bone); font-family: var(--serif); font-weight: 600;
font-size: 1.2em; letter-spacing: .02em; line-height: 1.25;
}
.recipe-card:hover .rname { color: var(--purple-bright); }
.recipe-card .rmeta {
color: var(--muted); font-size: 11px; letter-spacing: .15em;
text-transform: uppercase; font-family: var(--mono); margin-top: 10px;
}
.recipe-card .rtags { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 10px; }
.recipe-card .rtag {
color: var(--green-bright); border: 1px solid var(--green-dim);
background: rgba(110, 168, 72, .06);
padding: 2px 10px; border-radius: 4px;
font-size: 10px; letter-spacing: .15em; text-transform: uppercase;
font-family: var(--mono);
}
/* Pick toggle — mushroom button. 48px tap target (Apple HIG); inner SVG smaller. */
.pick-btn {
position: absolute; top: 8px; right: 8px;
width: 48px; height: 48px;
background: var(--bg-2); border: 1px solid var(--line);
border-radius: 50%;
display: flex; align-items: center; justify-content: center;
cursor: pointer; padding: 0;
transition: all .15s ease;
z-index: 2;
-webkit-tap-highlight-color: transparent;
}
.pick-btn:active { transform: scale(.92); }
.pick-btn:hover { border-color: var(--purple); background: var(--surface-3); }
.pick-btn .shroom { width: 24px; height: 24px; opacity: .65; transition: opacity .15s; pointer-events: none; }
.pick-btn:hover .shroom, .pick-btn.on .shroom { opacity: 1; }
.pick-btn.on {
border-color: var(--purple-bright);
background: var(--purple-deep);
box-shadow: 0 0 16px var(--purple-glow);
}
/* Infinite-scroll sentinel */
.scroll-sentinel { height: 24px; }
.scroll-state { text-align: center; color: var(--muted); font-size: 11px; letter-spacing: .2em; text-transform: uppercase; font-family: var(--mono); padding: 14px 0; }
.scroll-state.done { color: var(--bone-dim); }
.scroll-state.error { color: var(--crit); }
/* Mobile niceties */
@media (max-width: 720px) {
body { font-size: 14.5px; }
main { padding: 22px 14px 60px; }
header.topbar { padding: 12px 14px; gap: 8px; }
.brand-mark { font-size: 19px; letter-spacing: .12em; }
.brand-sub { display: none; }
nav.nav { gap: 14px; width: 100%; order: 3; overflow-x: auto; padding-bottom: 4px; }
nav.nav a { font-size: 11px; flex-shrink: 0; padding: 4px 0; }
.topmeta .who { font-size: 10px; }
.page-head h1 { font-size: 1.7em; }
.panel { padding: 16px; }
.recipe-card { padding: 12px 12px 12px 14px; }
.recipe-card .rmain { padding-right: 32px; }
.recipe-card .rname { font-size: 1em; }
.pick-btn { width: 28px; height: 28px; top: 8px; right: 8px; }
.btn { padding: .65em 1.2em; font-size: 12.5px; }
.search-row { flex-wrap: wrap; }
.search-row .count { width: 100%; }
}
@media (max-width: 380px) {
.page-head h1 { font-size: 1.5em; }
.recipe-card .rname { font-size: .95em; }
}
/* Recipe modal */
.modal-backdrop {
position: fixed; inset: 0; z-index: 100;
background: rgba(5, 5, 8, .82);
backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px);
display: none; align-items: flex-end; justify-content: center;
animation: backdropIn .2s ease-out forwards;
overscroll-behavior: contain;
}
.modal-backdrop.open { display: flex; }
@media (min-width: 720px) { .modal-backdrop { align-items: center; padding: 30px; } }
.modal {
background: var(--surface);
border: 1px solid var(--line);
border-radius: 8px 8px 0 0;
width: 100%; max-width: 720px;
max-height: 92vh; max-height: 92dvh;
display: flex; flex-direction: column;
box-shadow: 0 -10px 60px -10px rgba(0,0,0,.7), 0 0 0 1px rgba(155,95,232,.15);
animation: modalIn .25s ease-out forwards;
overscroll-behavior: contain;
}
@media (min-width: 720px) { .modal { border-radius: 8px; max-height: 86vh; } }
.modal-head {
padding: 14px 18px;
border-bottom: 1px solid var(--line);
display: flex; align-items: center; justify-content: space-between; gap: 10px;
background: var(--bg-2);
border-radius: 8px 8px 0 0;
}
.modal-head .title {
flex: 1; min-width: 0;
color: var(--bone); font-family: var(--serif); font-weight: 600; font-size: 1.15em;
letter-spacing: .02em;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.modal-close {
width: 40px; height: 40px; border-radius: 50%;
background: var(--surface-2); border: 1px solid var(--line);
color: var(--bone-dim); font-size: 22px; line-height: 1;
cursor: pointer; display: flex; align-items: center; justify-content: center;
flex-shrink: 0;
}
.modal-close:active { transform: scale(.92); background: var(--surface-3); }
.modal-body {
padding: 18px;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
flex: 1;
}
.modal-body h3 {
margin: 1.2em 0 .4em 0; padding-bottom: .3em;
border-bottom: 1px solid var(--line-soft);
color: var(--purple-bright); font-family: var(--serif); font-weight: 600;
font-size: 1em; letter-spacing: .15em; text-transform: uppercase;
}
.modal-body h3:first-child { margin-top: 0; }
.modal-body .ing-list { list-style: none; padding: 0; margin: 0; }
.modal-body .ing-list li { padding: 6px 0; border-bottom: 1px dashed var(--line-soft); color: var(--bone); }
.modal-body .ing-list li:last-child { border-bottom: none; }
.modal-body ol.steps { padding-left: 1.4em; margin: 0; }
.modal-body ol.steps li { padding: 6px 0; color: var(--bone); }
.modal-foot {
padding: 14px 18px;
border-top: 1px solid var(--line);
background: var(--bg-2);
display: flex; gap: 10px; flex-wrap: wrap; align-items: center;
}
.modal-foot .btn { flex: 1; min-width: 120px; text-align: center; }
.modal-foot .btn.full { flex: 1 0 100%; }
.modal-meta {
display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 12px;
color: var(--muted); font-size: 11px; letter-spacing: .15em;
text-transform: uppercase; font-family: var(--mono);
}
.modal-meta .m { padding: 3px 10px; border: 1px solid var(--line); border-radius: 999px; }
/* Animations */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes backdropIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes modalIn {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
form { margin: 0; }
button { font-family: inherit; }
</style>
</head>
<body>
<header class="topbar">
<div class="brand">
<span class="brand-mark">Cauldron</span>
<span class="brand-sub">family · LAN</span>
</div>
{% if session.user %}
<nav class="nav">
<a href="/recipes" class="{% if active == 'recipes' %}active{% endif %}">recipes</a>
<a href="/picks" class="{% if active == 'picks' %}active{% endif %}">picks</a>
<a href="/plan" class="{% if active == 'plan' %}active{% endif %}">plan</a>
<a href="/list" class="{% if active == 'list' %}active{% endif %}">list</a>
{% if is_admin %}<a href="/discover" class="{% if active == 'discover' %}active{% endif %}">discover</a>{% endif %}
<a href="/me" class="{% if active == 'me' %}active{% endif %}">me</a>
</nav>
<div class="topmeta">
<span class="who">{{ session.user.email }}</span>
</div>
{% endif %}
</header>
<main>
{% block content %}{% endblock %}
</main>
<div class="modal-backdrop" id="recipe-modal" role="dialog" aria-modal="true" aria-hidden="true">
<div class="modal" role="document">
<div class="modal-head">
<div class="title" id="modal-title"></div>
<button class="modal-close" type="button" id="modal-close" aria-label="close">×</button>
</div>
<div class="modal-body" id="modal-body">
<p style="color: var(--muted); text-align: center; padding: 30px 0;">summoning…</p>
</div>
<div class="modal-foot" id="modal-foot"></div>
</div>
</div>
<script>
(function(){
const modal = document.getElementById('recipe-modal');
const titleEl = document.getElementById('modal-title');
const bodyEl = document.getElementById('modal-body');
const footEl = document.getElementById('modal-foot');
const closeBtn = document.getElementById('modal-close');
let currentSlug = null;
let openedHistory = false;
function escapeHtml(s) { return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }
function setHidden(hidden) {
modal.classList.toggle('open', !hidden);
modal.setAttribute('aria-hidden', hidden ? 'true' : 'false');
document.body.style.overflow = hidden ? '' : 'hidden';
}
async function openRecipe(slug, name, pushHistory=true) {
currentSlug = slug;
titleEl.textContent = name || '…';
bodyEl.innerHTML = '<p style="color: var(--muted); text-align: center; padding: 30px 0;">summoning…</p>';
footEl.innerHTML = '';
setHidden(false);
if (pushHistory) {
history.pushState({modalSlug: slug}, '', '/recipes/' + encodeURIComponent(slug));
openedHistory = true;
}
try {
const r = await fetch('/api/recipes/' + encodeURIComponent(slug) + '.json');
if (!r.ok) throw new Error(r.status);
const data = await r.json();
renderRecipe(data.recipe, data.mealie_url);
} catch (e) {
bodyEl.innerHTML = '<p style="color: var(--crit);">load failed.</p>';
}
}
function renderRecipe(r, mealieUrl) {
titleEl.textContent = r.name || '(untitled)';
const meta = [];
if (r.totalTime) meta.push('<span class="m">' + escapeHtml(r.totalTime) + '</span>');
if (r.prepTime) meta.push('<span class="m">prep ' + escapeHtml(r.prepTime) + '</span>');
if (r.cookTime) meta.push('<span class="m">cook ' + escapeHtml(r.cookTime) + '</span>');
if (r.recipeYield) meta.push('<span class="m">' + escapeHtml(r.recipeYield) + '</span>');
const metaHtml = meta.length ? '<div class="modal-meta">' + meta.join('') + '</div>' : '';
let html = metaHtml;
if (r.description) html += '<p style="color: var(--bone-dim); font-style: italic;">' + escapeHtml(r.description) + '</p>';
const ings = r.recipeIngredient || [];
if (ings.length) {
html += '<h3>ingredients · ' + ings.length + '</h3><ul class="ing-list">';
for (const i of ings) {
const txt = i.display || i.note || '';
html += '<li>' + escapeHtml(txt) + '</li>';
}
html += '</ul>';
}
const steps = r.recipeInstructions || [];
if (steps.length) {
html += '<h3>instructions</h3><ol class="steps">';
for (const s of steps) {
html += '<li>' + escapeHtml(s.text || '') + '</li>';
}
html += '</ol>';
}
bodyEl.innerHTML = html;
bodyEl.scrollTop = 0;
const isPicked = !!r.picked;
footEl.innerHTML = `
<button class="btn ${isPicked ? 'btn-purple' : 'btn-primary'}" type="button" id="modal-pin"
data-slug="${escapeHtml(r.slug)}" data-name="${escapeHtml(r.name||'')}">
🍄 ${isPicked ? 'pinned · unpin' : 'pin to plan'}
</button>
<a class="btn" href="${mealieUrl}" target="_blank" rel="noopener">in mealie ↗</a>
`;
}
function closeModal() {
setHidden(true);
currentSlug = null;
if (openedHistory && history.state && history.state.modalSlug) {
openedHistory = false;
history.back();
}
}
closeBtn.addEventListener('click', closeModal);
modal.addEventListener('click', (e) => { if (e.target === modal) closeModal(); });
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && modal.classList.contains('open')) closeModal();
});
window.addEventListener('popstate', (e) => {
if (modal.classList.contains('open')) {
setHidden(true);
currentSlug = null;
openedHistory = false;
}
});
// Hijack any recipe-card click anywhere on the page → open modal
document.addEventListener('click', (e) => {
const card = e.target.closest('.recipe-card');
if (!card) return;
if (e.target.closest('.pick-btn')) return; // mushroom handles its own
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) return; // open in new tab
e.preventDefault();
const slug = card.dataset.slug || (card.getAttribute('href') || '').replace('/recipes/','');
if (slug) openRecipe(slug, card.querySelector('.rname')?.textContent);
});
// Pin toggle inside the modal (event delegation on body)
footEl.addEventListener('click', async (e) => {
const btn = e.target.closest('#modal-pin');
if (!btn) return;
const slug = btn.dataset.slug;
const name = btn.dataset.name;
const isOn = btn.classList.contains('btn-purple');
btn.disabled = true;
try {
const r = await fetch('/api/picks/' + encodeURIComponent(slug), {
method: isOn ? 'DELETE' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: isOn ? null : JSON.stringify({ name })
});
if (!r.ok) throw new Error(r.status);
btn.classList.toggle('btn-purple');
btn.classList.toggle('btn-primary');
btn.innerHTML = btn.classList.contains('btn-purple')
? '🍄 pinned · unpin'
: '🍄 pin to plan';
// Sync the matching card on the page (if visible)
const card = document.querySelector('.recipe-card[data-slug="' + slug + '"]');
if (card) {
card.classList.toggle('picked');
const pickBtn = card.querySelector('.pick-btn');
if (pickBtn) pickBtn.classList.toggle('on');
}
} catch (err) {
/* leave as-is */
} finally {
btn.disabled = false;
}
});
// Direct-link fallback: if user lands on /recipes/<slug> directly (e.g. shared link),
// the server-rendered detail page handles it. No modal pop on initial load.
})();
</script>
</body>
</html>