From dd9cc266fa038967132205f898c0dd0d56cb806e Mon Sep 17 00:00:00 2001 From: Kayos Date: Tue, 28 Apr 2026 20:38:54 -0700 Subject: [PATCH] ui: extract templates + add /recipes browse + sulkta-meets-gothic palette MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cobb feedback: pull back from cWHO terminal-coded look, blend Abby's gothic witch with sulkta.com polish. 'burn tokens till we nail it.' Style direction: - Polished dark base like sulkta.com — soft purple/green radial glows on near-black, faint witchy pentagram-circle SVG bg pattern at 5% opacity - NO scanlines, NO CRT vignette overlay (cWHO is too terminal for this) - Inter for body (sulkta.com), Cinzel SERIF for h1/h2/brand/recipe-card titles (gothic flourish), JetBrains Mono for code/labels/uppercase chips - Soft glow shadows (rgba box-shadow) instead of hard cWHO 3px offsets - Rounded corners 4-6px throughout - Smooth fade-in animations on .panel and .page-head - Pills with subtle background tint (sulkta pill style) - KV labels in mono uppercase purple — kept the gothic occult-tag feel - Recipe cards lift on hover with purple glow shadow Templates extracted from server.py to cauldron/templates/: - _base.html — full layout shell, topbar, scanlines REMOVED, animation - me.html — uses {extends '_base.html'} - connect.html — same - recipes.html — NEW, paginated grid view - recipe_detail.html — NEW, full recipe with ingredients + instructions - stub.html — NEW, placeholder for /plan and /list (v0.3) Routes added: - GET /recipes — user-tier: list via current_user_mealie() - GET /recipes/ — user-tier: detail view - GET /plan, /list — stubs so nav doesn't 404 Server: - render_template_string → render_template (proper Jinja file lookup) - Stripped inline _PALETTE_CSS / ME_TEMPLATE / CONNECT_TEMPLATE constants - Added current_user_mealie() helper to all user-facing routes --- cauldron/server.py | 226 +++++-------------- cauldron/templates/_base.html | 301 ++++++++++++++++++++++++++ cauldron/templates/connect.html | 35 +++ cauldron/templates/me.html | 54 +++++ cauldron/templates/recipe_detail.html | 62 ++++++ cauldron/templates/recipes.html | 49 +++++ cauldron/templates/stub.html | 19 ++ 7 files changed, 574 insertions(+), 172 deletions(-) create mode 100644 cauldron/templates/_base.html create mode 100644 cauldron/templates/connect.html create mode 100644 cauldron/templates/me.html create mode 100644 cauldron/templates/recipe_detail.html create mode 100644 cauldron/templates/recipes.html create mode 100644 cauldron/templates/stub.html diff --git a/cauldron/server.py b/cauldron/server.py index e99d052..e80a534 100644 --- a/cauldron/server.py +++ b/cauldron/server.py @@ -23,7 +23,7 @@ Routes (current): """ from functools import wraps -from flask import Flask, jsonify, redirect, render_template_string, request, session, url_for +from flask import Flask, jsonify, redirect, render_template, request, session, url_for from .config import load from .crypto import TokenCrypto @@ -168,8 +168,8 @@ def create_app() -> Flask: mealie_user = client.who_am_i() except Exception: mealie_user = None - return render_template_string( - ME_TEMPLATE, user=u, connected=connected, mealie_user=mealie_user, css=_PALETTE_CSS + return render_template( + "me.html", user=u, connected=connected, mealie_user=mealie_user, active="me" ) @app.get("/me.json") @@ -185,11 +185,11 @@ def create_app() -> Flask: @require_session def connect_mealie_get(): u = session["user"] - return render_template_string( - CONNECT_TEMPLATE, + return render_template( + "connect.html", user=u, mealie_url=cfg.mealie_public_url, - css=_PALETTE_CSS, + active="me", ) @app.post("/connect-mealie") @@ -218,11 +218,58 @@ def create_app() -> Flask: db.delete_user_mealie_token(u["sub"]) return redirect(url_for("me")) + # ---------- recipes (user-tier) -------------------------------------- + + @app.get("/recipes") + @require_session + def recipes_list(): + client = current_user_mealie() + if not client: + return redirect(url_for("connect_mealie_get")) + page = max(1, int(request.args.get("page", "1"))) + try: + data = client.list_recipes(page=page, 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 + return render_template( + "recipes.html", recipes=items, total=total, page=page, pages=pages, active="recipes" + ) + + @app.get("/plan") + @require_session + def plan_view(): + return render_template("stub.html", title="plan", coming="weekly meal plan generator", active="plan") + + @app.get("/list") + @require_session + def list_view(): + return render_template("stub.html", title="list", coming="aggregated shopping list from this week's plan", active="list") + + @app.get("/recipes/") + @require_session + def recipe_detail(slug: str): + client = current_user_mealie() + if not client: + return redirect(url_for("connect_mealie_get")) + try: + recipe = client.get_recipe(slug) + except Exception as e: + return (f"recipe load failed: {e}", 502) + return render_template( + "recipe_detail.html", + recipe=recipe, + public_url=cfg.mealie_public_url, + active="recipes", + ) + # ---------- v0.1 admin endpoints (carry over) ------------------------ @app.get("/api/recipes") @require_bearer - def list_recipes(): + def list_recipes_api(): page = int(request.args.get("page", "1")) per_page = min(int(request.args.get("per_page", "50")), 200) return jsonify(system_mealie.list_recipes(page=page, per_page=per_page)) @@ -247,171 +294,6 @@ def create_app() -> Flask: return app -_PALETTE_CSS = """ -@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@500;700;900&family=JetBrains+Mono:wght@400;600;700&family=Nosifer&display=swap'); -/* - Halloween underground — black + deep purple + poison green. Hard edges, no - rounded corners, no soft gradients. Witch / occult / terminal vibe. - - Black : #050505 deep, #0a0a0e crypt, #110a1a vault - Purple : #1a0d24 eggplant, #3d1f5a witch, #5a2d8c violet, #b878ff hex - Green : #1a2611 moss, #2a3a1d swamp, #5a8c3a poison, #88c060 toxic, #9bff5a acid - Cream/bone: #d8c8a8 bone, #c9b27c warn, #f0e6cc rare highlight -*/ -* { box-sizing: border-box; } -html, body { height: 100%; } -body { - font-family: 'JetBrains Mono', ui-monospace, Menlo, monospace; - background: #050505; - background-image: - radial-gradient(circle at 0% 0%, rgba(58, 31, 90, .25), transparent 40%), - radial-gradient(circle at 100% 100%, rgba(42, 58, 29, .2), transparent 50%); - color: #d8c8a8; max-width: 760px; margin: 0 auto; padding: 3em 1.5em; line-height: 1.55; - min-height: 100vh; - font-size: 15px; -} -::selection { background: #5a2d8c; color: #d8c8a8; } - -h1 { - font-family: 'Cinzel', Georgia, serif; font-weight: 900; - color: #88c060; - margin: 0 0 .3em 0; font-size: 2.3em; - letter-spacing: .08em; text-transform: uppercase; - text-shadow: 2px 2px 0 #050505, 0 0 24px rgba(155, 255, 90, .12); -} -h2 { - font-family: 'Cinzel', Georgia, serif; font-weight: 700; - color: #b878ff; margin: 1.8em 0 .3em 0; font-size: 1.3em; - letter-spacing: .12em; text-transform: uppercase; - border-bottom: 1px solid #3d1f5a; padding-bottom: .3em; -} -.lede { color: #5a8c3a; font-size: .95em; } -a { color: #88c060; text-decoration: none; border-bottom: 1px solid #2a3a1d; transition: none; } -a:hover { color: #9bff5a; border-bottom-color: #88c060; background: #0a0a0e; } -.panel { - background: #0a0a0e; - border: 1px solid #1a0d24; - border-left: 3px solid #5a2d8c; - padding: 1.2em 1.4em; margin: 1.4em 0; -} -.panel + .panel { border-left-color: #5a8c3a; } -.panel-row { display: flex; justify-content: space-between; align-items: baseline; gap: 1em; } -.muted { color: #5a8c3a; font-size: .85em; } -.kv { display: grid; grid-template-columns: max-content 1fr; gap: .5em 1.2em; margin: .8em 0; } -.kv dt { color: #b878ff; font-size: .8em; text-transform: uppercase; letter-spacing: .15em; font-weight: 700; } -.kv dd { margin: 0; color: #d8c8a8; font-size: .92em; word-break: break-all; } -.btn { - display: inline-block; padding: .6em 1.4em; - background: #5a8c3a; color: #050505 !important; - border: 1px solid #88c060; - font-family: 'Cinzel', Georgia, serif; font-weight: 700; - font-size: .85em; letter-spacing: .15em; text-transform: uppercase; - cursor: pointer; border-bottom: 1px solid #88c060; -} -.btn:hover { background: #88c060; border-color: #9bff5a; color: #050505 !important; box-shadow: 3px 3px 0 #1a2611; } -.btn-purple { background: #5a2d8c; color: #d8c8a8 !important; border-color: #b878ff; border-bottom: 1px solid #b878ff; } -.btn-purple:hover { background: #7a3dac; color: #f0e6cc !important; box-shadow: 3px 3px 0 #1a0d24; } -.btn-secondary { - background: #050505; color: #b878ff !important; - border: 1px solid #3d1f5a; border-bottom: 1px solid #3d1f5a; -} -.btn-secondary:hover { background: #1a0d24; color: #d8c8a8 !important; border-color: #5a2d8c; } -.btn-row { display: flex; gap: .8em; flex-wrap: wrap; align-items: center; } -input[type=text] { - width: 100%; padding: .7em; - background: #050505; border: 1px solid #3d1f5a; - color: #88c060; font-family: 'JetBrains Mono', monospace; font-size: .9em; -} -input[type=text]:focus { outline: none; border-color: #88c060; background: #0a120a; } -ol li, ul li { margin: .35em 0; } -code { background: #1a0d24; padding: .1em .45em; border: 1px solid #3d1f5a; color: #b878ff; font-family: 'JetBrains Mono', monospace; font-size: .9em; } -.status-ok { color: #9bff5a; font-size: .8em; text-transform: uppercase; letter-spacing: .15em; font-weight: 700; } -.status-warn { color: #c9b27c; font-size: .8em; text-transform: uppercase; letter-spacing: .15em; font-weight: 700; } -.brand { - font-family: 'Nosifer', 'Cinzel', cursive; - color: #5a8c3a; font-size: 1.4em; letter-spacing: .15em; - margin-bottom: -.1em; line-height: 1; -} -hr { border: none; border-top: 1px solid #1a0d24; margin: 2em 0; } -form { margin: 0; } -""" - - -ME_TEMPLATE = """ -Cauldron - - -
Cauldron
-

{{ user.name or user.email }}

-

Welcome back.

- -
-

Your account

-
-
email
{{ user.email }}
-
subject
{{ user.sub }}
-
-
- -
-
-

Mealie

- {% if connected %}connected{% else %}not connected{% endif %} -
- {% if connected and mealie_user %} -
-
logged in as
{{ mealie_user.username or mealie_user.email }}
-
full name
{{ mealie_user.fullName or '—' }}
-
admin
{{ 'yes' if mealie_user.admin else 'no' }}
-
-
- - Revoking on Mealie's side also works. -
- {% else %} -

Connect your Mealie account so cauldron can act on your behalf — your recipes, your meal plan, your shopping list.

-

Connect Mealie

- {% endif %} -
- -
-
- -
- -""" - - -CONNECT_TEMPLATE = """ -Connect Mealie — Cauldron - - -
Cauldron
-

Connect your Mealie

-

Hi {{ user.name or user.email }}. Cauldron acts on Mealie using your own API token. One-time, ~30 seconds.

- -
-
    -
  1. Open Mealie → API Tokens
  2. -
  3. Click New API Token, name it cauldron, integration generic
  4. -
  5. Copy the long token string
  6. -
  7. Paste below and click Connect
  8. -
- -
-

-
- - Cancel -
-
-
- -

Stored encrypted at rest with a Fernet key that lives only in cauldron's env. Revoke anytime in Mealie's UI — cauldron will detect and re-prompt.

- -""" - - def _const_eq(a: str, b: str) -> bool: if len(a) != len(b): return False diff --git a/cauldron/templates/_base.html b/cauldron/templates/_base.html new file mode 100644 index 0000000..a58aa0f --- /dev/null +++ b/cauldron/templates/_base.html @@ -0,0 +1,301 @@ + + + + + + + +{% block title %}Cauldron{% endblock %} + + + + + + + +
+
+ Cauldron + family · LAN +
+ {% if session.user %} + +
+ {{ session.user.email }} +
+ {% endif %} +
+ +
+{% block content %}{% endblock %} +
+ + + diff --git a/cauldron/templates/connect.html b/cauldron/templates/connect.html new file mode 100644 index 0000000..a6dfed6 --- /dev/null +++ b/cauldron/templates/connect.html @@ -0,0 +1,35 @@ +{% extends "_base.html" %} +{% block title %}Connect Mealie · Cauldron{% endblock %} +{% block content %} + +
+
// bind
+

bind your mealie

+
cauldron acts on mealie under your own identity. one-time, ~30 seconds.
+
+ +
+
+

token mint

+
+
    +
  1. open mealie → api tokens
  2. +
  3. click new api token, name it cauldron, integration generic
  4. +
  5. copy the long token string
  6. +
  7. paste below and bind
  8. +
+ + {% if error %}

{{ error }}

{% endif %} + +
+

+
+ + Cancel +
+
+
+ +

stored encrypted at rest with a fernet key that lives only in cauldron's env. revoke anytime in mealie's ui — cauldron will detect and re-prompt.

+ +{% endblock %} diff --git a/cauldron/templates/me.html b/cauldron/templates/me.html new file mode 100644 index 0000000..10518dc --- /dev/null +++ b/cauldron/templates/me.html @@ -0,0 +1,54 @@ +{% extends "_base.html" %} +{% block title %}{{ user.name or user.email }} · Cauldron{% endblock %} +{% block content %} + +
+
// account
+

{{ user.name or user.email.split('@')[0] }}

+
welcome back to the coven.
+
+ +
+
+

identity

+ authentik +
+
+
email
{{ user.email }}
+
subject
{{ user.sub }}
+
+
+ +
+
+

mealie

+ {% if connected %}connected{% else %}not connected{% endif %} +
+ + {% if connected and mealie_user %} +
+
logged in as
{{ mealie_user.username or mealie_user.email }}
+
full name
{{ mealie_user.fullName or '—' }}
+
role
{{ 'admin' if mealie_user.admin else 'member' }}
+
+
+ + revoking on mealie's side also works. +
+ {% elif connected %} +

token stored, but mealie didn't return profile data. probably revoked or rotated.

+
+ +
+ {% else %} +

connect mealie so cauldron can act on your behalf — your recipes, your meal plan, your shopping list. one-time, ~30 seconds.

+

Connect mealie →

+ {% endif %} +
+ +
+
+ +
+ +{% endblock %} diff --git a/cauldron/templates/recipe_detail.html b/cauldron/templates/recipe_detail.html new file mode 100644 index 0000000..b6c0696 --- /dev/null +++ b/cauldron/templates/recipe_detail.html @@ -0,0 +1,62 @@ +{% extends "_base.html" %} +{% block title %}{{ recipe.name }} · Cauldron{% endblock %} +{% block content %} + +
+
// recipes / {{ recipe.slug }}
+

{{ recipe.name }}

+ {% if recipe.description %}
{{ recipe.description }}
{% endif %} +
+ +
+
+

meta

+
+
+ {% if recipe.totalTime %}
total
{{ recipe.totalTime }}
{% endif %} + {% if recipe.prepTime %}
prep
{{ recipe.prepTime }}
{% endif %} + {% if recipe.cookTime %}
cook
{{ recipe.cookTime }}
{% endif %} + {% if recipe.recipeYield %}
yield
{{ recipe.recipeYield }}
{% endif %} + {% if recipe.recipeServings %}
servings
{{ recipe.recipeServings }}
{% endif %} +
+
+ +
+
+

ingredients

+ {{ (recipe.recipeIngredient or []) | length }} items +
+ {% if recipe.recipeIngredient %} +
    + {% for ing in recipe.recipeIngredient %} +
  • + {% if ing.display %}{{ ing.display }}{% else %}{{ ing.note or '' }}{% endif %} +
  • + {% endfor %} +
+ {% else %} +

no ingredients listed.

+ {% endif %} +
+ +
+
+

instructions

+
+ {% if recipe.recipeInstructions %} +
    + {% for step in recipe.recipeInstructions %} +
  1. {{ step.text }}
  2. + {% endfor %} +
+ {% else %} +

no instructions listed.

+ {% endif %} +
+ + + +{% endblock %} diff --git a/cauldron/templates/recipes.html b/cauldron/templates/recipes.html new file mode 100644 index 0000000..b8250fa --- /dev/null +++ b/cauldron/templates/recipes.html @@ -0,0 +1,49 @@ +{% extends "_base.html" %} +{% block title %}Recipes · Cauldron{% endblock %} +{% block content %} + +
+
// recipes
+

the grimoire

+
{{ total }} recipes bound to your account. tap any to open.
+
+ +{% if not recipes %} +
+

no recipes returned. either mealie has none for your account or the request failed quietly.

+
+{% else %} + +
+
+

browse

+ page {{ page }} / {{ pages }} +
+ + + +
+ {% if page > 1 %}← prev{% endif %} + {% if page < pages %}next →{% endif %} +
+
+ +{% endif %} + +{% endblock %} diff --git a/cauldron/templates/stub.html b/cauldron/templates/stub.html new file mode 100644 index 0000000..4730917 --- /dev/null +++ b/cauldron/templates/stub.html @@ -0,0 +1,19 @@ +{% extends "_base.html" %} +{% block title %}{{ title }} · Cauldron{% endblock %} +{% block content %} + +
+
// {{ title }}
+

the {{ title }}

+
coming in v0.3 — {{ coming }}.
+
+ +
+
+

not yet

+ on the roadmap +
+

brewing.

+
+ +{% endblock %}