From adec91486c548e8d0e4d57975a635ecd4f92b760 Mon Sep 17 00:00:00 2001 From: Kayos Date: Tue, 28 Apr 2026 20:53:07 -0700 Subject: [PATCH] =?UTF-8?q?v0.2=20=E2=80=94=20meal=20picks,=20infinite=20s?= =?UTF-8?q?croll,=20search,=20mushroom=20vibes,=20mobile=20polish?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/ — add (slug + optional name body) - DEL /api/picks/ — 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 --- cauldron/db.py | 50 ++++++++ cauldron/mealie.py | 7 +- cauldron/server.py | 77 +++++++++++- cauldron/templates/_base.html | 92 ++++++++++++-- cauldron/templates/_recipe_card.html | 25 ++++ cauldron/templates/picks.html | 56 +++++++++ cauldron/templates/recipe_detail.html | 30 +++++ cauldron/templates/recipes.html | 166 ++++++++++++++++++++------ 8 files changed, 454 insertions(+), 49 deletions(-) create mode 100644 cauldron/templates/_recipe_card.html create mode 100644 cauldron/templates/picks.html diff --git a/cauldron/db.py b/cauldron/db.py index 3561283..5134708 100644 --- a/cauldron/db.py +++ b/cauldron/db.py @@ -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( diff --git a/cauldron/mealie.py b/cauldron/mealie.py index ff036e3..66761e3 100644 --- a/cauldron/mealie.py +++ b/cauldron/mealie.py @@ -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}") diff --git a/cauldron/server.py b/cauldron/server.py index e80a534..839d482 100644 --- a/cauldron/server.py +++ b/cauldron/server.py @@ -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/") + @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/") + @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", ) diff --git a/cauldron/templates/_base.html b/cauldron/templates/_base.html index a58aa0f..6fc3ba8 100644 --- a/cauldron/templates/_base.html +++ b/cauldron/templates/_base.html @@ -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,"); + url("data:image/svg+xml;utf8,"); 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; } @@ -281,8 +353,8 @@ form { margin: 0; } {% if session.user %}