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).
This commit is contained in:
Kayos 2026-05-02 13:56:12 -07:00
parent bb49652443
commit 2a357b2acd
6 changed files with 135 additions and 47 deletions

View file

@ -36,6 +36,12 @@ class Config:
# Per-user token at-rest crypto # Per-user token at-rest crypto
fernet_key: str fernet_key: str
# Comma-separated list of authentik subjects who get the operator-tier
# /me admin tools panel (consolidate, discover scrape, etc). Anyone not
# in this list sees a household-friendly /me with the destructive tools
# hidden. Default empty = nobody gets admin tools (safe-fail).
admin_subs: tuple[str, ...]
def load() -> Config: def load() -> Config:
return Config( return Config(
@ -64,4 +70,9 @@ def load() -> Config:
db_user=os.environ["DB_USER"], db_user=os.environ["DB_USER"],
db_password=os.environ["DB_PASSWORD"], db_password=os.environ["DB_PASSWORD"],
fernet_key=os.environ["CAULDRON_FERNET_KEY"], fernet_key=os.environ["CAULDRON_FERNET_KEY"],
admin_subs=tuple(
s.strip()
for s in os.environ.get("CAULDRON_ADMIN_SUBS", "").split(",")
if s.strip()
),
) )

View file

@ -146,6 +146,14 @@ def create_app() -> Flask:
client_secret=cfg.oidc_client_secret, client_secret=cfg.oidc_client_secret,
) )
# Make `is_admin` available in every template — used by _base.html
# to gate the /discover top-nav tab and by /me to gate the operator
# tools panel. Driven by the CAULDRON_ADMIN_SUBS env var.
@app.context_processor
def _inject_is_admin():
u = session.get("user") or {}
return {"is_admin": u.get("sub") in cfg.admin_subs}
# ---------- helpers -------------------------------------------------- # ---------- helpers --------------------------------------------------
def require_bearer(fn): def require_bearer(fn):
@ -168,6 +176,21 @@ def create_app() -> Flask:
return fn(*a, **kw) return fn(*a, **kw)
return w return w
def require_admin(fn):
"""Layered on top of require_session — only members of cfg.admin_subs
proceed. Non-admins get a 404 (not a 403) so the route's existence
isn't advertised. Used for `/discover` and `/consolidate` whose
admin-only nature was scoped per Cobb 2026-05-02."""
@wraps(fn)
def w(*a, **kw):
u = session.get("user")
if not u:
return redirect(url_for("login", next=request.path))
if u.get("sub") not in cfg.admin_subs:
return ("not found", 404)
return fn(*a, **kw)
return w
def current_user_mealie() -> Mealie | None: def current_user_mealie() -> Mealie | None:
u = session.get("user") u = session.get("user")
if not u: if not u:
@ -366,6 +389,7 @@ def create_app() -> Flask:
"me.html", "me.html",
user=u, connected=connected, mealie_user=mealie_user, user=u, connected=connected, mealie_user=mealie_user,
household_size=household_size, active="me", household_size=household_size, active="me",
is_admin=(u["sub"] in cfg.admin_subs),
) )
@app.get("/me.json") @app.get("/me.json")
@ -458,7 +482,7 @@ def create_app() -> Flask:
limit=per_page, offset=0, limit=per_page, offset=0,
) )
pick_slugs = db.list_household_pick_slugs(hid) pick_slugs = db.list_household_pick_slugs(hid)
items = [_index_row_to_card(r, pick_slugs) for r in rows] items = [_index_row_to_card(r, pick_slugs, cfg.mealie_public_url) for r in rows]
total = (state or {}).get("recipe_count") or len(items) total = (state or {}).get("recipe_count") or len(items)
pages = max(1, (total + per_page - 1) // per_page) pages = max(1, (total + per_page - 1) // per_page)
@ -515,7 +539,7 @@ def create_app() -> Flask:
ranked = search_index(rows, search, limit=80) ranked = search_index(rows, search, limit=80)
start = (page - 1) * per_page start = (page - 1) * per_page
slice_ = ranked[start:start + per_page] slice_ = ranked[start:start + per_page]
items = [_index_row_to_card(r, pick_slugs) for r in slice_] items = [_index_row_to_card(r, pick_slugs, cfg.mealie_public_url) for r in slice_]
total = len(ranked) total = len(ranked)
total_pages = max(1, (total + per_page - 1) // per_page) total_pages = max(1, (total + per_page - 1) // per_page)
return jsonify({ return jsonify({
@ -536,7 +560,7 @@ def create_app() -> Flask:
) )
has_next = len(rows) > per_page has_next = len(rows) > per_page
rows = rows[:per_page] rows = rows[:per_page]
items = [_index_row_to_card(r, pick_slugs) for r in rows] items = [_index_row_to_card(r, pick_slugs, cfg.mealie_public_url) for r in rows]
# total — cheap-ish: count only when we don't already know # total — cheap-ish: count only when we don't already know
total = (state or {}).get("recipe_count") or len(items) total = (state or {}).get("recipe_count") or len(items)
total_pages = max(1, (total + per_page - 1) // per_page) total_pages = max(1, (total + per_page - 1) // per_page)
@ -1913,7 +1937,7 @@ def create_app() -> Flask:
# ---------- foods consolidator (Step 3) ------------------------------ # ---------- foods consolidator (Step 3) ------------------------------
@app.get("/consolidate") @app.get("/consolidate")
@require_session @require_admin
def consolidate_page(): def consolidate_page():
hid = current_household_id() hid = current_household_id()
if not hid: if not hid:
@ -1924,7 +1948,7 @@ def create_app() -> Flask:
) )
@app.post("/api/foods/consolidate-start") @app.post("/api/foods/consolidate-start")
@require_session @require_admin
def consolidate_start(): def consolidate_start():
u = session["user"] u = session["user"]
hid = current_household_id() hid = current_household_id()
@ -1943,7 +1967,7 @@ def create_app() -> Flask:
return jsonify({"ok": True, "job_id": job_id}) return jsonify({"ok": True, "job_id": job_id})
@app.get("/api/foods/consolidate-status") @app.get("/api/foods/consolidate-status")
@require_session @require_admin
def consolidate_status(): def consolidate_status():
hid = current_household_id() hid = current_household_id()
if not hid: if not hid:
@ -1954,7 +1978,7 @@ def create_app() -> Flask:
return jsonify({"job": _consolidate_job_payload(job)}) return jsonify({"job": _consolidate_job_payload(job)})
@app.get("/api/foods/consolidate-jobs/<int:job_id>/proposals") @app.get("/api/foods/consolidate-jobs/<int:job_id>/proposals")
@require_session @require_admin
def consolidate_proposals(job_id: int): def consolidate_proposals(job_id: int):
hid = current_household_id() hid = current_household_id()
if not hid: if not hid:
@ -1977,7 +2001,7 @@ def create_app() -> Flask:
}) })
@app.post("/api/foods/consolidate-apply/<int:job_id>") @app.post("/api/foods/consolidate-apply/<int:job_id>")
@require_session @require_admin
def consolidate_apply(job_id: int): def consolidate_apply(job_id: int):
hid = current_household_id() hid = current_household_id()
if not hid: if not hid:
@ -1999,7 +2023,7 @@ def create_app() -> Flask:
return jsonify({"ok": True, "approved_count": len(approved_ids)}) return jsonify({"ok": True, "approved_count": len(approved_ids)})
@app.post("/api/foods/consolidate-cancel/<int:job_id>") @app.post("/api/foods/consolidate-cancel/<int:job_id>")
@require_session @require_admin
def consolidate_cancel(job_id: int): def consolidate_cancel(job_id: int):
hid = current_household_id() hid = current_household_id()
if not hid: if not hid:
@ -2060,7 +2084,7 @@ def create_app() -> Flask:
# ---------- Discover v0.1 (browse external recipes) ------------------ # ---------- Discover v0.1 (browse external recipes) ------------------
@app.get("/discover") @app.get("/discover")
@require_session @require_admin
def discover_page(): def discover_page():
# Discover is a global, cross-household corpus — no household # Discover is a global, cross-household corpus — no household
# gate. But we still want a connected user before showing the # gate. But we still want a connected user before showing the
@ -2077,7 +2101,7 @@ def create_app() -> Flask:
) )
@app.get("/api/discover/search") @app.get("/api/discover/search")
@require_session @require_admin
def discover_search(): def discover_search():
args = request.args args = request.args
q = (args.get("q") or "").strip() or None q = (args.get("q") or "").strip() or None
@ -2179,7 +2203,7 @@ def create_app() -> Flask:
return jsonify({"recipes": out, "count": len(out)}) return jsonify({"recipes": out, "count": len(out)})
@app.post("/api/discover/import/<int:discover_id>") @app.post("/api/discover/import/<int:discover_id>")
@require_session @require_admin
def discover_import(discover_id: int): def discover_import(discover_id: int):
u = session["user"] u = session["user"]
row = db.get_discovered_recipe(discover_id) row = db.get_discovered_recipe(discover_id)
@ -2218,7 +2242,7 @@ def create_app() -> Flask:
return jsonify({"ok": True, "slug": new_slug}) return jsonify({"ok": True, "slug": new_slug})
@app.post("/api/discover/reject/<int:discover_id>") @app.post("/api/discover/reject/<int:discover_id>")
@require_session @require_admin
def discover_reject(discover_id: int): def discover_reject(discover_id: int):
"""Per-household 'skip from discover'. Audit F-2 routes 2026-05-02: """Per-household 'skip from discover'. Audit F-2 routes 2026-05-02:
previously this flipped the GLOBAL `cauldron_discovered_recipes.status previously this flipped the GLOBAL `cauldron_discovered_recipes.status
@ -2241,7 +2265,7 @@ def create_app() -> Flask:
return jsonify({"ok": True, "scope": "household"}) return jsonify({"ok": True, "scope": "household"})
@app.post("/api/discover/scrape-start") @app.post("/api/discover/scrape-start")
@require_session @require_admin
def discover_scrape_start(): def discover_scrape_start():
u = session["user"] u = session["user"]
active = db.running_discover_job() active = db.running_discover_job()
@ -2299,7 +2323,7 @@ def create_app() -> Flask:
}) })
@app.get("/api/discover/scrape-status") @app.get("/api/discover/scrape-status")
@require_session @require_admin
def discover_scrape_status(): def discover_scrape_status():
job = db.latest_discover_job() job = db.latest_discover_job()
if not job: if not job:
@ -2307,7 +2331,7 @@ def create_app() -> Flask:
return jsonify({"job": _consolidate_job_payload(job)}) return jsonify({"job": _consolidate_job_payload(job)})
@app.post("/api/discover/scrape-cancel/<int:job_id>") @app.post("/api/discover/scrape-cancel/<int:job_id>")
@require_session @require_admin
def discover_scrape_cancel(job_id: int): def discover_scrape_cancel(job_id: int):
job = db.get_discover_job(job_id) job = db.get_discover_job(job_id)
if not job: if not job:
@ -2468,9 +2492,16 @@ def _index_stale(state: dict | None) -> bool:
return age > _INDEX_TTL_SECS return age > _INDEX_TTL_SECS
def _index_row_to_card(row: dict, pick_slugs: set[str]) -> dict: def _index_row_to_card(row: dict, pick_slugs: set[str], mealie_public_url: str = "") -> dict:
"""Index row → recipe card dict the JS frontend expects (matches the """Index row → recipe card dict the JS frontend expects (matches the
Mealie recipe shape closely enough for renderCard).""" Mealie recipe shape closely enough for renderCard).
image_url: Mealie serves recipe thumbnails at
`<MEALIE>/api/media/recipes/<recipe_id>/images/min-original.webp`
when an image was uploaded (`raw.image` is truthy and not the literal
'no image' sentinel). We build the URL once server-side so the
frontend doesn't need to know Mealie's path scheme.
"""
import json as _json import json as _json
raw = row.get("raw_json") raw = row.get("raw_json")
if isinstance(raw, str): if isinstance(raw, str):
@ -2479,6 +2510,15 @@ def _index_row_to_card(row: dict, pick_slugs: set[str]) -> dict:
except Exception: except Exception:
raw = {} raw = {}
raw = raw or {} raw = raw or {}
image_url = None
raw_id = raw.get("id")
raw_img = raw.get("image")
if raw_id and raw_img and raw_img != "no image" and mealie_public_url:
image_url = (
f"{mealie_public_url.rstrip('/')}"
f"/api/media/recipes/{raw_id}/images/min-original.webp"
f"?v={raw_img}"
)
return { return {
"slug": row["slug"], "slug": row["slug"],
"name": row["name"], "name": row["name"],
@ -2487,6 +2527,7 @@ def _index_row_to_card(row: dict, pick_slugs: set[str]) -> dict:
"dateUpdated": (raw.get("dateUpdated") if raw else None) or (row["date_updated"].isoformat() if row.get("date_updated") else None), "dateUpdated": (raw.get("dateUpdated") if raw else None) or (row["date_updated"].isoformat() if row.get("date_updated") else None),
"tags": raw.get("tags") or [], "tags": raw.get("tags") or [],
"picked": row["slug"] in pick_slugs, "picked": row["slug"] in pick_slugs,
"image_url": image_url,
} }

View file

@ -111,7 +111,7 @@ nav.nav a.active::after {
/* Main */ /* Main */
main { main {
max-width: 920px; margin: 0 auto; padding: 36px 22px 80px; max-width: 1500px; margin: 0 auto; padding: 36px 22px 80px;
position: relative; position: relative;
} }
@ -282,9 +282,10 @@ ol li, ul li { margin: .35em 0; }
} }
@media (min-width: 720px) { .recipe-grid { grid-template-columns: 1fr 1fr; gap: 16px; } } @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: 1100px) { .recipe-grid { grid-template-columns: 1fr 1fr 1fr; } }
@media (min-width: 1400px) { .recipe-grid { grid-template-columns: 1fr 1fr 1fr 1fr; } }
.recipe-card { .recipe-card {
display: block; padding: 18px 18px 18px 20px; display: flex; flex-direction: column;
border: 1px solid var(--line); background: var(--surface); border: 1px solid var(--line); background: var(--surface);
border-radius: 8px; border-radius: 8px;
text-decoration: none; color: inherit; text-decoration: none; color: inherit;
@ -292,10 +293,26 @@ ol li, ul li { margin: .35em 0; }
transition: all .2s ease; transition: all .2s ease;
min-height: 88px; /* makes the whole card a comfortable tap */ 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 { border-color: var(--purple-dim); background: rgba(45, 29, 74, .35); }
.recipe-card.picked::before { .recipe-card.picked::before {
content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 3px; content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 3px;
background: var(--purple-bright); box-shadow: 0 0 12px var(--purple-glow); background: var(--purple-bright); box-shadow: 0 0 12px var(--purple-glow);
z-index: 1;
} }
.recipe-card:active, .recipe-card:hover { .recipe-card:active, .recipe-card:hover {
border-color: var(--purple-dim); background: var(--surface-2); border-color: var(--purple-dim); background: var(--surface-2);
@ -303,6 +320,7 @@ ol li, ul li { margin: .35em 0; }
box-shadow: 0 4px 24px -8px var(--purple-glow); box-shadow: 0 4px 24px -8px var(--purple-glow);
} }
.recipe-card .rmain { display: block; padding-right: 56px; } .recipe-card .rmain { display: block; padding-right: 56px; }
.recipe-card .pick-btn { z-index: 2; }
.recipe-card .rname { .recipe-card .rname {
color: var(--bone); font-family: var(--serif); font-weight: 600; color: var(--bone); font-family: var(--serif); font-weight: 600;
font-size: 1.2em; letter-spacing: .02em; line-height: 1.25; font-size: 1.2em; letter-spacing: .02em; line-height: 1.25;
@ -482,7 +500,7 @@ button { font-family: inherit; }
<a href="/picks" class="{% if active == 'picks' %}active{% endif %}">picks</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="/plan" class="{% if active == 'plan' %}active{% endif %}">plan</a>
<a href="/list" class="{% if active == 'list' %}active{% endif %}">list</a> <a href="/list" class="{% if active == 'list' %}active{% endif %}">list</a>
<a href="/discover" class="{% if active == 'discover' %}active{% endif %}">discover</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> <a href="/me" class="{% if active == 'me' %}active{% endif %}">me</a>
</nav> </nav>
<div class="topmeta"> <div class="topmeta">

View file

@ -1,25 +1,32 @@
{# server-rendered first page of recipe cards. JS appends matching markup for subsequent pages. #} {# server-rendered first page of recipe cards. JS appends matching markup for subsequent pages. #}
<a class="recipe-card{% if r.picked %} picked{% endif %}" href="/recipes/{{ r.slug }}" data-slug="{{ r.slug }}" data-name="{{ r.name }}"> <a class="recipe-card{% if r.picked %} picked{% endif %}" href="/recipes/{{ r.slug }}" data-slug="{{ r.slug }}" data-name="{{ r.name }}">
<span class="rmain"> {% if r.image_url %}
<div class="rname">{{ r.name }}</div> <span class="rimg" style="background-image: url('{{ r.image_url }}');"></span>
<div class="rmeta"> {% else %}
{% if r.totalTime %}{{ r.totalTime }}{% if r.recipeYield or r.dateUpdated %} · {% endif %}{% endif %} <span class="rimg placeholder">🍴</span>
{% if r.recipeYield %}{{ r.recipeYield }}{% if r.dateUpdated %} · {% endif %}{% endif %} {% endif %}
{{ r.dateUpdated[:10] if r.dateUpdated else '' }} <span class="rbody">
</div> <span class="rmain">
{% if r.tags %} <div class="rname">{{ r.name }}</div>
<div class="rtags"> <div class="rmeta">
{% for t in r.tags[:3] %}<span class="rtag">{{ t.name }}</span>{% endfor %} {% if r.totalTime %}{{ r.totalTime }}{% if r.recipeYield or r.dateUpdated %} · {% endif %}{% endif %}
</div> {% if r.recipeYield %}{{ r.recipeYield }}{% if r.dateUpdated %} · {% endif %}{% endif %}
{% endif %} {{ r.dateUpdated[:10] if r.dateUpdated else '' }}
</div>
{% if r.tags %}
<div class="rtags">
{% for t in r.tags[:3] %}<span class="rtag">{{ t.name }}</span>{% endfor %}
</div>
{% endif %}
</span>
<button class="pick-btn{% if r.picked %} on{% endif %}" type="button" aria-label="add to picks" data-slug="{{ r.slug }}" data-name="{{ r.name }}">
<svg class="shroom" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5 11 q0 -7 7 -7 q7 0 7 7 q0 1.4 -1 1.4 H6 q-1 0 -1 -1.4z" fill="#9b5fe8"/>
<path d="M9.5 12.4 v6 q0 1.6 2.5 1.6 q2.5 0 2.5 -1.6 v-6" fill="#e8e0c8" stroke="#9b5fe8" stroke-width=".7"/>
<circle cx="9" cy="8" r="1" fill="#0a0a0c"/>
<circle cx="15" cy="7.5" r=".8" fill="#0a0a0c"/>
<circle cx="12" cy="9.5" r=".7" fill="#0a0a0c"/>
</svg>
</button>
</span> </span>
<button class="pick-btn{% if r.picked %} on{% endif %}" type="button" aria-label="add to picks" data-slug="{{ r.slug }}" data-name="{{ r.name }}">
<svg class="shroom" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5 11 q0 -7 7 -7 q7 0 7 7 q0 1.4 -1 1.4 H6 q-1 0 -1 -1.4z" fill="#9b5fe8"/>
<path d="M9.5 12.4 v6 q0 1.6 2.5 1.6 q2.5 0 2.5 -1.6 v-6" fill="#e8e0c8" stroke="#9b5fe8" stroke-width=".7"/>
<circle cx="9" cy="8" r="1" fill="#0a0a0c"/>
<circle cx="15" cy="7.5" r=".8" fill="#0a0a0c"/>
<circle cx="12" cy="9.5" r=".7" fill="#0a0a0c"/>
</svg>
</button>
</a> </a>

View file

@ -57,8 +57,11 @@
</div> </div>
<p class="muted">mealie's parser is per-recipe; this kicks off a bulk pass over your whole library. review proposals, apply the good ones.</p> <p class="muted">mealie's parser is per-recipe; this kicks off a bulk pass over your whole library. review proposals, apply the good ones.</p>
<p><a class="btn" href="/sterilize">🪄 bulk sterilize recipes →</a></p> <p><a class="btn" href="/sterilize">🪄 bulk sterilize recipes →</a></p>
{% if is_admin %}
<p class="muted" style="margin-top:14px;">scan your foods table for dupes, ask hecate to pick canonicals, merge in mealie. one-time cleanup; aliases get attached to the survivors so the parser fuzzy-matches variants from now on.</p> <p class="muted" style="margin-top:14px;">scan your foods table for dupes, ask hecate to pick canonicals, merge in mealie. one-time cleanup; aliases get attached to the survivors so the parser fuzzy-matches variants from now on.</p>
<p><a class="btn" href="/consolidate">🔮 consolidate foods table →</a></p> <p><a class="btn" href="/consolidate">🔮 consolidate foods table →</a></p>
{% endif %}
<p class="muted" style="margin-top:14px;">find duplicate recipes by name + ingredient similarity. hecate picks the canonical to keep; you confirm per cluster before mealie deletes the others. permanent — review carefully.</p> <p class="muted" style="margin-top:14px;">find duplicate recipes by name + ingredient similarity. hecate picks the canonical to keep; you confirm per cluster before mealie deletes the others. permanent — review carefully.</p>
<p><a class="btn" href="/dedupe-recipes">🌀 dedupe recipes →</a></p> <p><a class="btn" href="/dedupe-recipes">🌀 dedupe recipes →</a></p>
@ -66,8 +69,10 @@
<p class="muted" style="margin-top:14px;">have hecate generate per-recipe metadata — cuisine, complexity, macros, primary protein/carb, comfort tier, summary. the plan generator reads this so "high protein week" is a real query, not just a vibe.</p> <p class="muted" style="margin-top:14px;">have hecate generate per-recipe metadata — cuisine, complexity, macros, primary protein/carb, comfort tier, summary. the plan generator reads this so "high protein week" is a real query, not just a vibe.</p>
<p><a class="btn" href="/enrich-recipes">✨ enrich recipes →</a></p> <p><a class="btn" href="/enrich-recipes">✨ enrich recipes →</a></p>
{% if is_admin %}
<p class="muted" style="margin-top:14px;">browse a cross-household corpus of scraped recipes — search by cuisine / protein / time / kid-friendliness. one click sends a recipe to your mealie library; sterilize+enrich pipelines run on it like any other.</p> <p class="muted" style="margin-top:14px;">browse a cross-household corpus of scraped recipes — search by cuisine / protein / time / kid-friendliness. one click sends a recipe to your mealie library; sterilize+enrich pipelines run on it like any other.</p>
<p><a class="btn" href="/discover">🌐 discover recipes →</a></p> <p><a class="btn" href="/discover">🌐 discover recipes →</a></p>
{% endif %}
</section> </section>
{% endif %} {% endif %}

View file

@ -75,13 +75,19 @@
].filter(Boolean).join(' · '); ].filter(Boolean).join(' · ');
const picked = r.picked ? ' picked' : ''; const picked = r.picked ? ' picked' : '';
const onClass = r.picked ? ' on' : ''; const onClass = r.picked ? ' on' : '';
const img = r.image_url
? `<span class="rimg" style="background-image: url('${escapeAttr(r.image_url)}');"></span>`
: `<span class="rimg placeholder">🍴</span>`;
return `<a class="recipe-card${picked}" href="/recipes/${encodeURIComponent(r.slug)}" data-slug="${escapeAttr(r.slug)}" data-name="${escapeAttr(r.name||'')}"> return `<a class="recipe-card${picked}" href="/recipes/${encodeURIComponent(r.slug)}" data-slug="${escapeAttr(r.slug)}" data-name="${escapeAttr(r.name||'')}">
<span class="rmain"> ${img}
<div class="rname">${escapeHtml(r.name||'(untitled)')}</div> <span class="rbody">
<div class="rmeta">${meta}</div> <span class="rmain">
${tags ? `<div class="rtags">${tags}</div>` : ''} <div class="rname">${escapeHtml(r.name||'(untitled)')}</div>
<div class="rmeta">${meta}</div>
${tags ? `<div class="rtags">${tags}</div>` : ''}
</span>
<button class="pick-btn${onClass}" type="button" aria-label="pin to plan" data-slug="${escapeAttr(r.slug)}" data-name="${escapeAttr(r.name||'')}">${SHROOM}</button>
</span> </span>
<button class="pick-btn${onClass}" type="button" aria-label="pin to plan" data-slug="${escapeAttr(r.slug)}" data-name="${escapeAttr(r.name||'')}">${SHROOM}</button>
</a>`; </a>`;
} }