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:
parent
bb49652443
commit
2a357b2acd
6 changed files with 135 additions and 47 deletions
|
|
@ -36,6 +36,12 @@ class Config:
|
|||
# Per-user token at-rest crypto
|
||||
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:
|
||||
return Config(
|
||||
|
|
@ -64,4 +70,9 @@ def load() -> Config:
|
|||
db_user=os.environ["DB_USER"],
|
||||
db_password=os.environ["DB_PASSWORD"],
|
||||
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()
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -146,6 +146,14 @@ def create_app() -> Flask:
|
|||
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 --------------------------------------------------
|
||||
|
||||
def require_bearer(fn):
|
||||
|
|
@ -168,6 +176,21 @@ def create_app() -> Flask:
|
|||
return fn(*a, **kw)
|
||||
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:
|
||||
u = session.get("user")
|
||||
if not u:
|
||||
|
|
@ -366,6 +389,7 @@ def create_app() -> Flask:
|
|||
"me.html",
|
||||
user=u, connected=connected, mealie_user=mealie_user,
|
||||
household_size=household_size, active="me",
|
||||
is_admin=(u["sub"] in cfg.admin_subs),
|
||||
)
|
||||
|
||||
@app.get("/me.json")
|
||||
|
|
@ -458,7 +482,7 @@ def create_app() -> Flask:
|
|||
limit=per_page, offset=0,
|
||||
)
|
||||
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)
|
||||
|
||||
pages = max(1, (total + per_page - 1) // per_page)
|
||||
|
|
@ -515,7 +539,7 @@ def create_app() -> Flask:
|
|||
ranked = search_index(rows, search, limit=80)
|
||||
start = (page - 1) * 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_pages = max(1, (total + per_page - 1) // per_page)
|
||||
return jsonify({
|
||||
|
|
@ -536,7 +560,7 @@ def create_app() -> Flask:
|
|||
)
|
||||
has_next = len(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 = (state or {}).get("recipe_count") or len(items)
|
||||
total_pages = max(1, (total + per_page - 1) // per_page)
|
||||
|
|
@ -1913,7 +1937,7 @@ def create_app() -> Flask:
|
|||
# ---------- foods consolidator (Step 3) ------------------------------
|
||||
|
||||
@app.get("/consolidate")
|
||||
@require_session
|
||||
@require_admin
|
||||
def consolidate_page():
|
||||
hid = current_household_id()
|
||||
if not hid:
|
||||
|
|
@ -1924,7 +1948,7 @@ def create_app() -> Flask:
|
|||
)
|
||||
|
||||
@app.post("/api/foods/consolidate-start")
|
||||
@require_session
|
||||
@require_admin
|
||||
def consolidate_start():
|
||||
u = session["user"]
|
||||
hid = current_household_id()
|
||||
|
|
@ -1943,7 +1967,7 @@ def create_app() -> Flask:
|
|||
return jsonify({"ok": True, "job_id": job_id})
|
||||
|
||||
@app.get("/api/foods/consolidate-status")
|
||||
@require_session
|
||||
@require_admin
|
||||
def consolidate_status():
|
||||
hid = current_household_id()
|
||||
if not hid:
|
||||
|
|
@ -1954,7 +1978,7 @@ def create_app() -> Flask:
|
|||
return jsonify({"job": _consolidate_job_payload(job)})
|
||||
|
||||
@app.get("/api/foods/consolidate-jobs/<int:job_id>/proposals")
|
||||
@require_session
|
||||
@require_admin
|
||||
def consolidate_proposals(job_id: int):
|
||||
hid = current_household_id()
|
||||
if not hid:
|
||||
|
|
@ -1977,7 +2001,7 @@ def create_app() -> Flask:
|
|||
})
|
||||
|
||||
@app.post("/api/foods/consolidate-apply/<int:job_id>")
|
||||
@require_session
|
||||
@require_admin
|
||||
def consolidate_apply(job_id: int):
|
||||
hid = current_household_id()
|
||||
if not hid:
|
||||
|
|
@ -1999,7 +2023,7 @@ def create_app() -> Flask:
|
|||
return jsonify({"ok": True, "approved_count": len(approved_ids)})
|
||||
|
||||
@app.post("/api/foods/consolidate-cancel/<int:job_id>")
|
||||
@require_session
|
||||
@require_admin
|
||||
def consolidate_cancel(job_id: int):
|
||||
hid = current_household_id()
|
||||
if not hid:
|
||||
|
|
@ -2060,7 +2084,7 @@ def create_app() -> Flask:
|
|||
# ---------- Discover v0.1 (browse external recipes) ------------------
|
||||
|
||||
@app.get("/discover")
|
||||
@require_session
|
||||
@require_admin
|
||||
def discover_page():
|
||||
# Discover is a global, cross-household corpus — no household
|
||||
# gate. But we still want a connected user before showing the
|
||||
|
|
@ -2077,7 +2101,7 @@ def create_app() -> Flask:
|
|||
)
|
||||
|
||||
@app.get("/api/discover/search")
|
||||
@require_session
|
||||
@require_admin
|
||||
def discover_search():
|
||||
args = request.args
|
||||
q = (args.get("q") or "").strip() or None
|
||||
|
|
@ -2179,7 +2203,7 @@ def create_app() -> Flask:
|
|||
return jsonify({"recipes": out, "count": len(out)})
|
||||
|
||||
@app.post("/api/discover/import/<int:discover_id>")
|
||||
@require_session
|
||||
@require_admin
|
||||
def discover_import(discover_id: int):
|
||||
u = session["user"]
|
||||
row = db.get_discovered_recipe(discover_id)
|
||||
|
|
@ -2218,7 +2242,7 @@ def create_app() -> Flask:
|
|||
return jsonify({"ok": True, "slug": new_slug})
|
||||
|
||||
@app.post("/api/discover/reject/<int:discover_id>")
|
||||
@require_session
|
||||
@require_admin
|
||||
def discover_reject(discover_id: int):
|
||||
"""Per-household 'skip from discover'. Audit F-2 routes 2026-05-02:
|
||||
previously this flipped the GLOBAL `cauldron_discovered_recipes.status
|
||||
|
|
@ -2241,7 +2265,7 @@ def create_app() -> Flask:
|
|||
return jsonify({"ok": True, "scope": "household"})
|
||||
|
||||
@app.post("/api/discover/scrape-start")
|
||||
@require_session
|
||||
@require_admin
|
||||
def discover_scrape_start():
|
||||
u = session["user"]
|
||||
active = db.running_discover_job()
|
||||
|
|
@ -2299,7 +2323,7 @@ def create_app() -> Flask:
|
|||
})
|
||||
|
||||
@app.get("/api/discover/scrape-status")
|
||||
@require_session
|
||||
@require_admin
|
||||
def discover_scrape_status():
|
||||
job = db.latest_discover_job()
|
||||
if not job:
|
||||
|
|
@ -2307,7 +2331,7 @@ def create_app() -> Flask:
|
|||
return jsonify({"job": _consolidate_job_payload(job)})
|
||||
|
||||
@app.post("/api/discover/scrape-cancel/<int:job_id>")
|
||||
@require_session
|
||||
@require_admin
|
||||
def discover_scrape_cancel(job_id: int):
|
||||
job = db.get_discover_job(job_id)
|
||||
if not job:
|
||||
|
|
@ -2468,9 +2492,16 @@ def _index_stale(state: dict | None) -> bool:
|
|||
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
|
||||
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
|
||||
raw = row.get("raw_json")
|
||||
if isinstance(raw, str):
|
||||
|
|
@ -2479,6 +2510,15 @@ def _index_row_to_card(row: dict, pick_slugs: set[str]) -> dict:
|
|||
except Exception:
|
||||
raw = {}
|
||||
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 {
|
||||
"slug": row["slug"],
|
||||
"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),
|
||||
"tags": raw.get("tags") or [],
|
||||
"picked": row["slug"] in pick_slugs,
|
||||
"image_url": image_url,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -111,7 +111,7 @@ nav.nav a.active::after {
|
|||
|
||||
/* Main */
|
||||
main {
|
||||
max-width: 920px; margin: 0 auto; padding: 36px 22px 80px;
|
||||
max-width: 1500px; margin: 0 auto; padding: 36px 22px 80px;
|
||||
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: 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: block; padding: 18px 18px 18px 20px;
|
||||
display: flex; flex-direction: column;
|
||||
border: 1px solid var(--line); background: var(--surface);
|
||||
border-radius: 8px;
|
||||
text-decoration: none; color: inherit;
|
||||
|
|
@ -292,10 +293,26 @@ ol li, ul li { margin: .35em 0; }
|
|||
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);
|
||||
|
|
@ -303,6 +320,7 @@ ol li, ul li { margin: .35em 0; }
|
|||
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;
|
||||
|
|
@ -482,7 +500,7 @@ button { font-family: inherit; }
|
|||
<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>
|
||||
<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>
|
||||
</nav>
|
||||
<div class="topmeta">
|
||||
|
|
|
|||
|
|
@ -1,25 +1,32 @@
|
|||
{# 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 }}">
|
||||
<span class="rmain">
|
||||
<div class="rname">{{ r.name }}</div>
|
||||
<div class="rmeta">
|
||||
{% if r.totalTime %}{{ r.totalTime }}{% if r.recipeYield or r.dateUpdated %} · {% endif %}{% endif %}
|
||||
{% if r.recipeYield %}{{ r.recipeYield }}{% if r.dateUpdated %} · {% 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 %}
|
||||
{% if r.image_url %}
|
||||
<span class="rimg" style="background-image: url('{{ r.image_url }}');"></span>
|
||||
{% else %}
|
||||
<span class="rimg placeholder">🍴</span>
|
||||
{% endif %}
|
||||
<span class="rbody">
|
||||
<span class="rmain">
|
||||
<div class="rname">{{ r.name }}</div>
|
||||
<div class="rmeta">
|
||||
{% if r.totalTime %}{{ r.totalTime }}{% if r.recipeYield or r.dateUpdated %} · {% endif %}{% endif %}
|
||||
{% if r.recipeYield %}{{ r.recipeYield }}{% if r.dateUpdated %} · {% 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>
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -57,8 +57,11 @@
|
|||
</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><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><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><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><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><a class="btn" href="/discover">🌐 discover recipes →</a></p>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
|
|
|
|||
|
|
@ -75,13 +75,19 @@
|
|||
].filter(Boolean).join(' · ');
|
||||
const picked = r.picked ? ' picked' : '';
|
||||
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||'')}">
|
||||
<span class="rmain">
|
||||
<div class="rname">${escapeHtml(r.name||'(untitled)')}</div>
|
||||
<div class="rmeta">${meta}</div>
|
||||
${tags ? `<div class="rtags">${tags}</div>` : ''}
|
||||
${img}
|
||||
<span class="rbody">
|
||||
<span class="rmain">
|
||||
<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>
|
||||
<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>`;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue