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).
684 lines
27 KiB
HTML
684 lines
27 KiB
HTML
<!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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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>
|