v0.2 — meal picks, infinite scroll, search, mushroom vibes, mobile polish
Cobb pass: more mobile friendly + infinite scroll + auto-search +
add-to-AI-plan picks list + mushroom vibes.
DB:
- New migration 005 — cauldron_meal_picks (authentik_sub, recipe_slug,
recipe_name, added_at). Per-user wishlist for the v0.3 AI meal-plan
generator. add/remove/list helpers in DB.
Backend:
- GET /api/recipes.json?page=N&q=Q — paginated + searchable, returns
{items, page, total, total_pages, next}. Each item is annotated with
picked: bool from the user's pick set.
- POST /api/picks/<slug> — add (slug + optional name body)
- DEL /api/picks/<slug> — remove
- GET /api/picks.json — list current picks
- GET /picks — picks page (replaces empty stub)
- Mealie.list_recipes() now accepts search=...
Frontend:
- recipes.html rebuilt:
- sticky search bar with 250ms debounce, hits /api/recipes.json?q=
- IntersectionObserver-driven infinite scroll, loads page+1 when the
sentinel comes into view (200px rootMargin, AbortController for
in-flight cancel on new search)
- per-card mushroom toggle button (top-right) — POST/DELETE to picks
with optimistic UI flip + rollback on failure
- picked cards get a left purple-glow stripe + tinted background
- _recipe_card.html partial — first-page server-render shares markup with
JS-rendered subsequent cards (mushroom SVG inline, same shape)
- recipe_detail.html — '🍄 pin for ai plan' button toggles state in place
- picks.html — list of current picks with remove button + v0.3 explainer
- Topbar nav: dropped /home, added /picks
Mushroom vibes:
- Hand-rolled SVG toadstool (purple cap, bone stem, dark spots) used as
the pick toggle icon — it's the gesture itself
- Same mushroom tiled into the body bg pattern at ~5% opacity in the
bottom-right of the 160px sigil tile, alongside the existing pentagram
- Mushroom emoji on the detail page button + picks page nudge
Mobile pass:
- Topbar nav scrolls horizontally on narrow screens, brand-sub hidden
under 720px, larger tap targets on cards, font-size pulled in slightly
- Recipe grid: 1 col <560, 2 col 560-900, 3 col 900+
- Page-head + button + card padding all tightened on small screens
This commit is contained in:
parent
6c3a45f57a
commit
adec91486c
8 changed files with 454 additions and 49 deletions
|
|
@ -59,6 +59,18 @@ MIGRATIONS = [
|
|||
INDEX idx_user_ts (authentik_sub, ts)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
""",
|
||||
# 005 — meal picks: per-user list of recipes the user wants in the next
|
||||
# AI meal plan run. Pre-populated wishlist that the planner respects.
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS cauldron_meal_picks (
|
||||
authentik_sub VARCHAR(190) NOT NULL,
|
||||
recipe_slug VARCHAR(255) NOT NULL,
|
||||
recipe_name VARCHAR(500) NOT NULL,
|
||||
added_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (authentik_sub, recipe_slug),
|
||||
INDEX idx_user_added (authentik_sub, added_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
""",
|
||||
]
|
||||
|
||||
|
||||
|
|
@ -176,6 +188,44 @@ class DB:
|
|||
(reason[:500], sub),
|
||||
)
|
||||
|
||||
# --- meal picks ---------------------------------------------------------
|
||||
|
||||
def add_meal_pick(self, sub: str, slug: str, name: str) -> bool:
|
||||
with self.conn() as c, c.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT IGNORE INTO cauldron_meal_picks (authentik_sub, recipe_slug, recipe_name)
|
||||
VALUES (%s, %s, %s)
|
||||
""",
|
||||
(sub, slug, name[:500]),
|
||||
)
|
||||
return cur.rowcount > 0
|
||||
|
||||
def remove_meal_pick(self, sub: str, slug: str) -> bool:
|
||||
with self.conn() as c, c.cursor() as cur:
|
||||
cur.execute(
|
||||
"DELETE FROM cauldron_meal_picks WHERE authentik_sub=%s AND recipe_slug=%s",
|
||||
(sub, slug),
|
||||
)
|
||||
return cur.rowcount > 0
|
||||
|
||||
def list_meal_picks(self, sub: str) -> list[dict]:
|
||||
with self.conn() as c, c.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT recipe_slug, recipe_name, added_at FROM cauldron_meal_picks "
|
||||
"WHERE authentik_sub=%s ORDER BY added_at DESC",
|
||||
(sub,),
|
||||
)
|
||||
return [dict(r) for r in cur.fetchall()]
|
||||
|
||||
def list_meal_pick_slugs(self, sub: str) -> set[str]:
|
||||
with self.conn() as c, c.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT recipe_slug FROM cauldron_meal_picks WHERE authentik_sub=%s",
|
||||
(sub,),
|
||||
)
|
||||
return {r["recipe_slug"] for r in cur.fetchall()}
|
||||
|
||||
# --- chat log -----------------------------------------------------------
|
||||
|
||||
def log_chat(
|
||||
|
|
|
|||
|
|
@ -70,8 +70,11 @@ class Mealie:
|
|||
|
||||
# --- recipes ------------------------------------------------------------
|
||||
|
||||
def list_recipes(self, *, page: int = 1, per_page: int = 50) -> dict:
|
||||
return self._get("/api/recipes", page=page, perPage=per_page)
|
||||
def list_recipes(self, *, page: int = 1, per_page: int = 50, search: str | None = None) -> dict:
|
||||
params = {"page": page, "perPage": per_page}
|
||||
if search:
|
||||
params["search"] = search
|
||||
return self._get("/api/recipes", **params)
|
||||
|
||||
def get_recipe(self, slug: str) -> dict:
|
||||
return self._get(f"/api/recipes/{slug}")
|
||||
|
|
|
|||
|
|
@ -226,18 +226,86 @@ def create_app() -> Flask:
|
|||
client = current_user_mealie()
|
||||
if not client:
|
||||
return redirect(url_for("connect_mealie_get"))
|
||||
page = max(1, int(request.args.get("page", "1")))
|
||||
u = session["user"]
|
||||
try:
|
||||
data = client.list_recipes(page=page, per_page=24)
|
||||
data = client.list_recipes(page=1, per_page=24)
|
||||
except Exception:
|
||||
data = {"items": [], "total": 0, "total_pages": 1}
|
||||
items = data.get("items", []) or []
|
||||
total = data.get("total", len(items))
|
||||
pages = data.get("total_pages", 1) or 1
|
||||
pick_slugs = db.list_meal_pick_slugs(u["sub"])
|
||||
for it in items:
|
||||
it["picked"] = it.get("slug") in pick_slugs
|
||||
return render_template(
|
||||
"recipes.html", recipes=items, total=total, page=page, pages=pages, active="recipes"
|
||||
"recipes.html", recipes=items, total=total, pages=pages, active="recipes"
|
||||
)
|
||||
|
||||
@app.get("/api/recipes.json")
|
||||
@require_session
|
||||
def recipes_json():
|
||||
"""Paginated + searchable recipes for the infinite-scroll AJAX path."""
|
||||
client = current_user_mealie()
|
||||
if not client:
|
||||
return jsonify({"error": "not connected"}), 409
|
||||
u = session["user"]
|
||||
page = max(1, int(request.args.get("page", "1")))
|
||||
search = (request.args.get("q") or "").strip() or None
|
||||
try:
|
||||
data = client.list_recipes(page=page, per_page=24, search=search)
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 502
|
||||
items = data.get("items", []) or []
|
||||
pick_slugs = db.list_meal_pick_slugs(u["sub"])
|
||||
for it in items:
|
||||
it["picked"] = it.get("slug") in pick_slugs
|
||||
return jsonify({
|
||||
"items": items,
|
||||
"page": page,
|
||||
"total": data.get("total"),
|
||||
"total_pages": data.get("total_pages") or 1,
|
||||
"next": page + 1 if page < (data.get("total_pages") or 1) else None,
|
||||
})
|
||||
|
||||
@app.post("/api/picks/<slug>")
|
||||
@require_session
|
||||
def add_pick(slug: str):
|
||||
u = session["user"]
|
||||
name = (request.json or {}).get("name", "") if request.is_json else request.form.get("name", "")
|
||||
if not name:
|
||||
# Look it up from Mealie if missing
|
||||
client = current_user_mealie()
|
||||
if client:
|
||||
try:
|
||||
r = client.get_recipe(slug)
|
||||
name = r.get("name") or slug
|
||||
except Exception:
|
||||
name = slug
|
||||
else:
|
||||
name = slug
|
||||
added = db.add_meal_pick(u["sub"], slug, name)
|
||||
return jsonify({"ok": True, "added": added, "slug": slug})
|
||||
|
||||
@app.delete("/api/picks/<slug>")
|
||||
@require_session
|
||||
def del_pick(slug: str):
|
||||
u = session["user"]
|
||||
removed = db.remove_meal_pick(u["sub"], slug)
|
||||
return jsonify({"ok": True, "removed": removed, "slug": slug})
|
||||
|
||||
@app.get("/api/picks.json")
|
||||
@require_session
|
||||
def list_picks_json():
|
||||
u = session["user"]
|
||||
return jsonify({"picks": db.list_meal_picks(u["sub"])})
|
||||
|
||||
@app.get("/picks")
|
||||
@require_session
|
||||
def picks_view():
|
||||
u = session["user"]
|
||||
picks = db.list_meal_picks(u["sub"])
|
||||
return render_template("picks.html", picks=picks, active="picks")
|
||||
|
||||
@app.get("/plan")
|
||||
@require_session
|
||||
def plan_view():
|
||||
|
|
@ -254,14 +322,17 @@ def create_app() -> Flask:
|
|||
client = current_user_mealie()
|
||||
if not client:
|
||||
return redirect(url_for("connect_mealie_get"))
|
||||
u = session["user"]
|
||||
try:
|
||||
recipe = client.get_recipe(slug)
|
||||
except Exception as e:
|
||||
return (f"recipe load failed: {e}", 502)
|
||||
picked = slug in db.list_meal_pick_slugs(u["sub"])
|
||||
return render_template(
|
||||
"recipe_detail.html",
|
||||
recipe=recipe,
|
||||
public_url=cfg.mealie_public_url,
|
||||
picked=picked,
|
||||
active="recipes",
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -56,13 +56,13 @@ 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%);
|
||||
/* faint witchy sigil tile in bone+purple at 4% */
|
||||
/* 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='80' height='80' viewBox='0 0 80 80'><g fill='none' stroke='%239b5fe8' stroke-width='.4' stroke-opacity='.05'><circle cx='40' cy='40' r='18'/><polygon points='40,22 45.3,33.5 58,34.5 48.4,42.7 51.5,55 40,48.5 28.5,55 31.6,42.7 22,34.5 34.7,33.5'/></g></svg>");
|
||||
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, 80px 80px;
|
||||
background-size: auto, auto, 160px 160px;
|
||||
}
|
||||
|
||||
::selection { background: rgba(155, 95, 232, .35); color: var(--bone); }
|
||||
|
|
@ -229,30 +229,54 @@ strong { color: var(--bone); font-weight: 600; }
|
|||
ol, ul { padding-left: 1.4em; }
|
||||
ol li, ul li { margin: .35em 0; }
|
||||
|
||||
/* Recipe grid */
|
||||
.recipe-grid { display: grid; grid-template-columns: 1fr; gap: 10px; }
|
||||
@media (min-width: 600px) { .recipe-grid { grid-template-columns: 1fr 1fr; } }
|
||||
/* Search bar (sticky-ish on the recipes page) */
|
||||
.search-row {
|
||||
display: flex; gap: 10px; align-items: center;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.search-row input {
|
||||
flex: 1; padding: .7em .9em;
|
||||
background: var(--bg-2); border: 1px solid var(--line);
|
||||
border-radius: 4px;
|
||||
color: var(--bone); font-family: var(--sans); font-size: 14px;
|
||||
}
|
||||
.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; }
|
||||
|
||||
/* Recipe grid + cards */
|
||||
.recipe-grid {
|
||||
display: grid; grid-template-columns: 1fr; gap: 12px;
|
||||
}
|
||||
@media (min-width: 560px) { .recipe-grid { grid-template-columns: 1fr 1fr; } }
|
||||
@media (min-width: 900px) { .recipe-grid { grid-template-columns: 1fr 1fr 1fr; } }
|
||||
|
||||
.recipe-card {
|
||||
display: block; padding: 14px 16px;
|
||||
display: block; padding: 14px 14px 14px 16px;
|
||||
border: 1px solid var(--line); background: var(--surface);
|
||||
border-radius: 5px;
|
||||
text-decoration: none; color: inherit;
|
||||
position: relative; overflow: hidden;
|
||||
transition: all .2s ease;
|
||||
}
|
||||
.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);
|
||||
}
|
||||
.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: 36px; }
|
||||
.recipe-card .rname {
|
||||
color: var(--bone); font-family: var(--serif); font-weight: 600;
|
||||
font-size: 1.05em; letter-spacing: .02em;
|
||||
font-size: 1.08em; letter-spacing: .02em; line-height: 1.25;
|
||||
}
|
||||
.recipe-card:hover .rname { color: var(--purple-bright); }
|
||||
.recipe-card .rmeta {
|
||||
color: var(--muted); font-size: 10px; letter-spacing: .15em;
|
||||
text-transform: uppercase; font-family: var(--mono); margin-top: 6px;
|
||||
text-transform: uppercase; font-family: var(--mono); margin-top: 8px;
|
||||
}
|
||||
.recipe-card .rtags { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 8px; }
|
||||
.recipe-card .rtag {
|
||||
|
|
@ -262,6 +286,53 @@ ol li, ul li { margin: .35em 0; }
|
|||
font-size: 9px; letter-spacing: .15em; text-transform: uppercase;
|
||||
font-family: var(--mono);
|
||||
}
|
||||
/* Pick toggle — small mushroom button top-right of card */
|
||||
.pick-btn {
|
||||
position: absolute; top: 10px; right: 10px;
|
||||
width: 30px; height: 30px;
|
||||
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 .2s ease;
|
||||
z-index: 2;
|
||||
}
|
||||
.pick-btn:hover { border-color: var(--purple); background: var(--surface-3); transform: scale(1.08); }
|
||||
.pick-btn .shroom { width: 16px; height: 16px; opacity: .55; transition: opacity .2s; }
|
||||
.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 12px 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; }
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes fadeIn {
|
||||
|
|
@ -270,6 +341,7 @@ ol li, ul li { margin: .35em 0; }
|
|||
}
|
||||
|
||||
form { margin: 0; }
|
||||
button { font-family: inherit; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
|
@ -281,8 +353,8 @@ form { margin: 0; }
|
|||
</div>
|
||||
{% if session.user %}
|
||||
<nav class="nav">
|
||||
<a href="/" class="{% if active == 'home' %}active{% endif %}">home</a>
|
||||
<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>
|
||||
<a href="/me" class="{% if active == 'me' %}active{% endif %}">me</a>
|
||||
|
|
|
|||
25
cauldron/templates/_recipe_card.html
Normal file
25
cauldron/templates/_recipe_card.html
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
{# 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 %}
|
||||
</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>
|
||||
56
cauldron/templates/picks.html
Normal file
56
cauldron/templates/picks.html
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
{% extends "_base.html" %}
|
||||
{% block title %}Picks · Cauldron{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<div class="page-head">
|
||||
<div class="crumb">// picks</div>
|
||||
<h1>your <span class="accent">picks</span></h1>
|
||||
<div class="lede">recipes pinned for the next ai meal plan run. {% if picks %}{{ picks|length }} ready.{% else %}empty — pick some on the grimoire.{% endif %}</div>
|
||||
</div>
|
||||
|
||||
{% if picks %}
|
||||
<section class="panel purple">
|
||||
<div class="panel-head">
|
||||
<h2>pinned</h2>
|
||||
<span class="ctx">{{ picks|length }} {% if picks|length == 1 %}recipe{% else %}recipes{% endif %}</span>
|
||||
</div>
|
||||
<ul id="picks-list" style="list-style: none; padding: 0; margin: 0;">
|
||||
{% for p in picks %}
|
||||
<li data-slug="{{ p.recipe_slug }}" style="display: flex; justify-content: space-between; align-items: center; gap: 14px; padding: 10px 0; border-bottom: 1px solid var(--line-soft);">
|
||||
<a href="/recipes/{{ p.recipe_slug }}" style="flex: 1; color: var(--bone); font-family: var(--serif); font-size: 1.05em; border: none;">{{ p.recipe_name }}</a>
|
||||
<button class="btn" type="button" onclick="removePick('{{ p.recipe_slug }}', this)" style="font-size: 11px; padding: .4em 1em;">remove</button>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="panel green">
|
||||
<div class="panel-head">
|
||||
<h2>next</h2>
|
||||
</div>
|
||||
<p>once the meal plan generator lands in <code>v0.3</code>, these picks become the seed — guaranteed slots for the week, with the ai filling in around them based on family prefs and what's in season.</p>
|
||||
<p class="muted">for now, just pin and wait. you can always remove or add more.</p>
|
||||
</section>
|
||||
{% else %}
|
||||
<section class="panel">
|
||||
<p>head to <a href="/recipes">the grimoire</a>, tap the mushroom 🍄 on any recipe to pin it.</p>
|
||||
<p class="muted">picks are per-user. abby's picks ≠ your picks.</p>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<script>
|
||||
async function removePick(slug, btn) {
|
||||
btn.disabled = true; btn.textContent = '…';
|
||||
try {
|
||||
const r = await fetch(`/api/picks/${encodeURIComponent(slug)}`, { method: 'DELETE' });
|
||||
if (!r.ok) throw new Error(r.status);
|
||||
const li = btn.closest('li');
|
||||
if (li) li.remove();
|
||||
if (!document.querySelectorAll('#picks-list li').length) location.reload();
|
||||
} catch (e) {
|
||||
btn.disabled = false; btn.textContent = 'remove';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
|
|
@ -56,7 +56,37 @@
|
|||
|
||||
<div class="btn-row">
|
||||
<a class="btn btn-purple" href="/recipes">← back to grimoire</a>
|
||||
<button id="pick-toggle" type="button" class="btn {% if picked %}btn-primary{% endif %}"
|
||||
data-slug="{{ recipe.slug }}" data-name="{{ recipe.name }}"
|
||||
onclick="togglePick(this)">
|
||||
🍄 {% if picked %}pinned · remove{% else %}pin for ai plan{% endif %}
|
||||
</button>
|
||||
<a class="btn" href="{{ public_url }}/recipe/{{ recipe.slug }}" target="_blank" rel="noopener">view in mealie ↗</a>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
async function togglePick(btn) {
|
||||
const slug = btn.dataset.slug;
|
||||
const name = btn.dataset.name;
|
||||
const isOn = btn.classList.contains('btn-primary');
|
||||
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-primary');
|
||||
btn.innerHTML = btn.classList.contains('btn-primary')
|
||||
? '🍄 pinned · remove'
|
||||
: '🍄 pin for ai plan';
|
||||
} catch (e) {
|
||||
/* leave as-is */
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -5,45 +5,143 @@
|
|||
<div class="page-head">
|
||||
<div class="crumb">// recipes</div>
|
||||
<h1>the <span class="accent">grimoire</span></h1>
|
||||
<div class="lede">{{ total }} recipes bound to your account. tap any to open.</div>
|
||||
<div class="lede">tap any to open. tap the mushroom to pin it for the next ai meal plan run.</div>
|
||||
</div>
|
||||
|
||||
{% if not recipes %}
|
||||
<section class="panel">
|
||||
<p class="muted">no recipes returned. either mealie has none for your account or the request failed quietly.</p>
|
||||
</section>
|
||||
{% else %}
|
||||
<div class="search-row">
|
||||
<input type="text" id="search" placeholder="search recipes…" autocomplete="off" autocapitalize="off" spellcheck="false">
|
||||
<span class="count" id="count">{{ total }} total</span>
|
||||
</div>
|
||||
|
||||
<section class="panel green">
|
||||
<div class="panel-head">
|
||||
<h2>browse</h2>
|
||||
<span class="ctx">page {{ page }} / {{ pages }}</span>
|
||||
</div>
|
||||
<div class="recipe-grid" id="grid">
|
||||
{% for r in recipes %}
|
||||
{% include "_recipe_card.html" %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="recipe-grid">
|
||||
{% for r in recipes %}
|
||||
<a class="recipe-card" href="/recipes/{{ r.slug }}">
|
||||
<div class="rname">{{ r.name }}</div>
|
||||
<div class="rmeta">
|
||||
{% if r.totalTime %}{{ r.totalTime }} · {% endif %}
|
||||
{% if r.recipeYield %}{{ r.recipeYield }} · {% endif %}
|
||||
{{ r.dateUpdated[:10] if r.dateUpdated else '' }}
|
||||
</div>
|
||||
{% if r.tags %}
|
||||
<div class="rtags">
|
||||
{% for t in r.tags[:4] %}<span class="rtag">{{ t.name }}</span>{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="scroll-state" id="state">{% if total <= recipes|length %}— end —{% endif %}</div>
|
||||
<div class="scroll-sentinel" id="sentinel"></div>
|
||||
|
||||
<div class="btn-row" style="margin-top: 1.4em;">
|
||||
{% if page > 1 %}<a class="btn btn-purple" href="/recipes?page={{ page - 1 }}">← prev</a>{% endif %}
|
||||
{% if page < pages %}<a class="btn btn-purple" href="/recipes?page={{ page + 1 }}">next →</a>{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
<script>
|
||||
(function(){
|
||||
const grid = document.getElementById('grid');
|
||||
const state = document.getElementById('state');
|
||||
const count = document.getElementById('count');
|
||||
const search = document.getElementById('search');
|
||||
const sentinel = document.getElementById('sentinel');
|
||||
|
||||
{% endif %}
|
||||
let nextPage = {{ 2 if pages > 1 else 0 }};
|
||||
let total = {{ total }};
|
||||
let q = '';
|
||||
let loading = false;
|
||||
let aborter = null;
|
||||
|
||||
// ✦ mushroom SVG used in pick toggle
|
||||
const SHROOM = `<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>`;
|
||||
|
||||
function renderCard(r) {
|
||||
const tags = (r.tags || []).slice(0, 3).map(t => `<span class="rtag">${escapeHtml(t.name)}</span>`).join('');
|
||||
const meta = [
|
||||
r.totalTime ? escapeHtml(r.totalTime) : '',
|
||||
r.recipeYield ? escapeHtml(r.recipeYield) : '',
|
||||
r.dateUpdated ? r.dateUpdated.slice(0,10) : ''
|
||||
].filter(Boolean).join(' · ');
|
||||
const picked = r.picked ? ' picked' : '';
|
||||
const onClass = r.picked ? ' on' : '';
|
||||
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>` : ''}
|
||||
</span>
|
||||
<button class="pick-btn${onClass}" type="button" aria-label="add to picks" data-slug="${escapeAttr(r.slug)}" data-name="${escapeAttr(r.name||'')}">${SHROOM}</button>
|
||||
</a>`;
|
||||
}
|
||||
|
||||
function escapeHtml(s) { return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); }
|
||||
function escapeAttr(s) { return escapeHtml(s); }
|
||||
|
||||
async function loadMore(reset=false) {
|
||||
if (loading) return;
|
||||
if (!reset && !nextPage) return;
|
||||
loading = true;
|
||||
if (aborter) aborter.abort();
|
||||
aborter = new AbortController();
|
||||
state.classList.remove('done', 'error');
|
||||
state.textContent = 'loading…';
|
||||
const page = reset ? 1 : nextPage;
|
||||
const url = `/api/recipes.json?page=${page}` + (q ? `&q=${encodeURIComponent(q)}` : '');
|
||||
try {
|
||||
const r = await fetch(url, { signal: aborter.signal });
|
||||
if (!r.ok) throw new Error(r.status);
|
||||
const data = await r.json();
|
||||
if (reset) grid.innerHTML = '';
|
||||
for (const item of (data.items || [])) {
|
||||
grid.insertAdjacentHTML('beforeend', renderCard(item));
|
||||
}
|
||||
total = data.total ?? total;
|
||||
count.textContent = `${total} ${q ? 'matching' : 'total'}`;
|
||||
nextPage = data.next || 0;
|
||||
state.textContent = nextPage ? '' : '— end —';
|
||||
if (!nextPage) state.classList.add('done');
|
||||
} catch (e) {
|
||||
if (e.name !== 'AbortError') {
|
||||
state.textContent = 'load failed';
|
||||
state.classList.add('error');
|
||||
}
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Pick toggle (event delegation)
|
||||
grid.addEventListener('click', async (e) => {
|
||||
const btn = e.target.closest('.pick-btn');
|
||||
if (!btn) return;
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
const slug = btn.dataset.slug;
|
||||
const name = btn.dataset.name;
|
||||
const card = btn.closest('.recipe-card');
|
||||
const wasOn = btn.classList.contains('on');
|
||||
// optimistic flip
|
||||
btn.classList.toggle('on');
|
||||
if (card) card.classList.toggle('picked');
|
||||
try {
|
||||
const r = await fetch(`/api/picks/${encodeURIComponent(slug)}`, {
|
||||
method: wasOn ? 'DELETE' : 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: wasOn ? null : JSON.stringify({ name })
|
||||
});
|
||||
if (!r.ok) throw new Error(r.status);
|
||||
} catch (err) {
|
||||
// rollback on failure
|
||||
btn.classList.toggle('on');
|
||||
if (card) card.classList.toggle('picked');
|
||||
}
|
||||
});
|
||||
|
||||
// Debounced search
|
||||
let searchTimer = null;
|
||||
search.addEventListener('input', () => {
|
||||
clearTimeout(searchTimer);
|
||||
searchTimer = setTimeout(() => {
|
||||
q = search.value.trim();
|
||||
loadMore(true);
|
||||
}, 250);
|
||||
});
|
||||
|
||||
// Infinite scroll
|
||||
const io = new IntersectionObserver((entries) => {
|
||||
if (entries.some(e => e.isIntersecting)) loadMore();
|
||||
}, { rootMargin: '200px 0px' });
|
||||
io.observe(sentinel);
|
||||
})();
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue