ui: extract templates + add /recipes browse + sulkta-meets-gothic palette
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/<slug> — 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
This commit is contained in:
parent
a329784063
commit
dd9cc266fa
7 changed files with 574 additions and 172 deletions
|
|
@ -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/<slug>")
|
||||
@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 = """<!doctype html>
|
||||
<html><head><meta charset="utf-8"><title>Cauldron</title>
|
||||
<style>{{ css|safe }}</style>
|
||||
</head><body>
|
||||
<div class="brand">Cauldron</div>
|
||||
<h1>{{ user.name or user.email }}</h1>
|
||||
<p class="lede">Welcome back.</p>
|
||||
|
||||
<div class="panel">
|
||||
<h2>Your account</h2>
|
||||
<dl class="kv">
|
||||
<dt>email</dt><dd>{{ user.email }}</dd>
|
||||
<dt>subject</dt><dd>{{ user.sub }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-row">
|
||||
<h2 style="margin-top:0">Mealie</h2>
|
||||
{% if connected %}<span class="status-ok">connected</span>{% else %}<span class="status-warn">not connected</span>{% endif %}
|
||||
</div>
|
||||
{% if connected and mealie_user %}
|
||||
<dl class="kv">
|
||||
<dt>logged in as</dt><dd>{{ mealie_user.username or mealie_user.email }}</dd>
|
||||
<dt>full name</dt><dd>{{ mealie_user.fullName or '—' }}</dd>
|
||||
<dt>admin</dt><dd>{{ 'yes' if mealie_user.admin else 'no' }}</dd>
|
||||
</dl>
|
||||
<form method="post" action="/disconnect-mealie" class="btn-row">
|
||||
<button class="btn btn-secondary" type="submit">Disconnect</button>
|
||||
<span class="muted">Revoking on Mealie's side also works.</span>
|
||||
</form>
|
||||
{% else %}
|
||||
<p>Connect your Mealie account so cauldron can act on your behalf — your recipes, your meal plan, your shopping list.</p>
|
||||
<p><a class="btn" href="/connect-mealie">Connect Mealie</a></p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
<form method="post" action="/logout">
|
||||
<button class="btn btn-secondary" type="submit">Sign out</button>
|
||||
</form>
|
||||
</body></html>
|
||||
"""
|
||||
|
||||
|
||||
CONNECT_TEMPLATE = """<!doctype html>
|
||||
<html><head><meta charset="utf-8"><title>Connect Mealie — Cauldron</title>
|
||||
<style>{{ css|safe }}</style>
|
||||
</head><body>
|
||||
<div class="brand">Cauldron</div>
|
||||
<h1>Connect your Mealie</h1>
|
||||
<p class="lede">Hi {{ user.name or user.email }}. Cauldron acts on Mealie using your own API token. One-time, ~30 seconds.</p>
|
||||
|
||||
<div class="panel">
|
||||
<ol>
|
||||
<li>Open <a href="{{ mealie_url }}/group/api-tokens" target="_blank" rel="noopener">Mealie → API Tokens</a></li>
|
||||
<li>Click <strong>New API Token</strong>, name it <code>cauldron</code>, integration <code>generic</code></li>
|
||||
<li>Copy the long token string</li>
|
||||
<li>Paste below and click Connect</li>
|
||||
</ol>
|
||||
|
||||
<form method="post" action="/connect-mealie">
|
||||
<p><input type="text" name="mealie_token" placeholder="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." required autocomplete="off" autocapitalize="off" spellcheck="false"></p>
|
||||
<div class="btn-row">
|
||||
<button class="btn" type="submit">Connect</button>
|
||||
<a class="btn btn-secondary" href="/me">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<p class="muted">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.</p>
|
||||
</body></html>
|
||||
"""
|
||||
|
||||
|
||||
def _const_eq(a: str, b: str) -> bool:
|
||||
if len(a) != len(b):
|
||||
return False
|
||||
|
|
|
|||
301
cauldron/templates/_base.html
Normal file
301
cauldron/templates/_base.html
Normal file
|
|
@ -0,0 +1,301 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="color-scheme" content="dark">
|
||||
<meta name="theme-color" content="#0a0a0c">
|
||||
<title>{% block title %}Cauldron{% endblock %}</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&family=Cinzel:wght@500;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0a0a0c;
|
||||
--bg-2: #0f0c14;
|
||||
--surface: #14101a;
|
||||
--surface-2: #1d1828;
|
||||
--surface-3: #251f33;
|
||||
--line: #2a223a;
|
||||
--line-soft: #1f1a2a;
|
||||
|
||||
--green: #6ea848;
|
||||
--green-bright: #88c060;
|
||||
--green-dim: #4a7530;
|
||||
--green-glow: rgba(110, 168, 72, .25);
|
||||
|
||||
--purple: #9b5fe8;
|
||||
--purple-bright:#b878ff;
|
||||
--purple-dim: #6b3fa0;
|
||||
--purple-deep: #2d1d4a;
|
||||
--purple-glow: rgba(155, 95, 232, .25);
|
||||
|
||||
--bone: #e8e0c8;
|
||||
--bone-dim: #a89d83;
|
||||
--muted: #6e6478;
|
||||
--warn: #d4a854;
|
||||
--crit: #e8606a;
|
||||
|
||||
--sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
--serif: 'Cinzel', Georgia, serif;
|
||||
--mono: 'JetBrains Mono', ui-monospace, Menlo, monospace;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
|
||||
html { scroll-behavior: smooth; -webkit-text-size-adjust: 100%; }
|
||||
html, body {
|
||||
margin: 0; padding: 0;
|
||||
color: var(--bone);
|
||||
font-family: var(--sans);
|
||||
font-size: 15px; line-height: 1.6;
|
||||
min-height: 100vh; min-height: 100dvh;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
background: var(--bg);
|
||||
}
|
||||
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% */
|
||||
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>");
|
||||
background-attachment: fixed;
|
||||
background-size: auto, auto, 80px 80px;
|
||||
}
|
||||
|
||||
::selection { background: rgba(155, 95, 232, .35); color: var(--bone); }
|
||||
|
||||
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||
::-webkit-scrollbar-track { background: var(--bg-2); }
|
||||
::-webkit-scrollbar-thumb { background: var(--surface-2); border-radius: 4px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--purple-dim); }
|
||||
|
||||
/* Top bar */
|
||||
header.topbar {
|
||||
position: sticky; top: 0; z-index: 50;
|
||||
display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap;
|
||||
gap: 14px;
|
||||
padding: 16px 22px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: rgba(10, 10, 12, .85);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
}
|
||||
.brand { display: flex; align-items: baseline; gap: 14px; min-width: 0; }
|
||||
.brand-mark {
|
||||
font-family: var(--serif); font-weight: 700;
|
||||
color: var(--purple-bright); font-size: 22px;
|
||||
letter-spacing: .15em; text-transform: uppercase;
|
||||
text-shadow: 0 0 24px var(--purple-glow);
|
||||
}
|
||||
.brand-sub { color: var(--muted); font-size: 11px; letter-spacing: .2em; text-transform: uppercase; font-family: var(--mono); }
|
||||
nav.nav { display: flex; gap: 22px; flex-wrap: wrap; }
|
||||
nav.nav a {
|
||||
color: var(--bone-dim); text-decoration: none;
|
||||
font-size: 12px; letter-spacing: .15em; text-transform: uppercase;
|
||||
padding: 6px 0; position: relative; transition: color .2s;
|
||||
}
|
||||
nav.nav a:hover { color: var(--green-bright); }
|
||||
nav.nav a.active { color: var(--purple-bright); }
|
||||
nav.nav a.active::after {
|
||||
content: ""; position: absolute; left: 0; right: 0; bottom: 0; height: 2px;
|
||||
background: var(--purple-bright); box-shadow: 0 0 12px var(--purple-glow);
|
||||
}
|
||||
.topmeta {
|
||||
color: var(--muted); font-size: 11px; letter-spacing: .15em; text-transform: uppercase;
|
||||
font-family: var(--mono);
|
||||
}
|
||||
.topmeta .who { color: var(--green-bright); }
|
||||
|
||||
/* Main */
|
||||
main {
|
||||
max-width: 920px; margin: 0 auto; padding: 36px 22px 80px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Page header */
|
||||
.page-head { margin-bottom: 1.6em; animation: fadeIn .5s ease-out forwards; }
|
||||
.page-head .crumb {
|
||||
color: var(--purple); font-size: 11px; letter-spacing: .25em;
|
||||
text-transform: uppercase; font-family: var(--mono); margin-bottom: .5em;
|
||||
}
|
||||
.page-head h1 {
|
||||
margin: 0; color: var(--bone);
|
||||
font-family: var(--serif); font-weight: 600;
|
||||
font-size: 2.2em; letter-spacing: .04em; line-height: 1.2;
|
||||
}
|
||||
.page-head h1 .accent { color: var(--purple-bright); text-shadow: 0 0 24px var(--purple-glow); }
|
||||
.page-head .lede { color: var(--bone-dim); font-size: 1em; margin-top: .5em; max-width: 60ch; }
|
||||
|
||||
/* Panel */
|
||||
.panel {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
padding: 20px 22px;
|
||||
margin: 16px 0;
|
||||
position: relative;
|
||||
box-shadow: 0 0 24px -8px rgba(155, 95, 232, .08);
|
||||
animation: fadeIn .5s ease-out forwards;
|
||||
opacity: 0;
|
||||
}
|
||||
.panel:nth-child(2) { animation-delay: .05s; }
|
||||
.panel:nth-child(3) { animation-delay: .1s; }
|
||||
.panel:nth-child(4) { animation-delay: .15s; }
|
||||
.panel:hover { border-color: var(--surface-3); }
|
||||
.panel.green {
|
||||
box-shadow: 0 0 24px -8px rgba(110, 168, 72, .1);
|
||||
border-left: 2px solid var(--green-dim);
|
||||
}
|
||||
.panel.purple { border-left: 2px solid var(--purple-dim); }
|
||||
.panel-head {
|
||||
display: flex; align-items: baseline; gap: 14px; flex-wrap: wrap;
|
||||
margin-bottom: 14px; padding-bottom: 10px;
|
||||
border-bottom: 1px solid var(--line-soft);
|
||||
}
|
||||
.panel-head h2 {
|
||||
margin: 0; color: var(--purple-bright);
|
||||
font-family: var(--serif); font-weight: 600;
|
||||
font-size: 1.05em; letter-spacing: .15em; text-transform: uppercase;
|
||||
}
|
||||
.panel.green .panel-head h2 { color: var(--green-bright); }
|
||||
.panel-head .ctx { color: var(--muted); font-size: 11px; letter-spacing: .15em; text-transform: uppercase; margin-left: auto; font-family: var(--mono); }
|
||||
|
||||
/* Pill */
|
||||
.pill {
|
||||
display: inline-block; padding: 3px 10px; border: 1px solid;
|
||||
border-radius: 4px; font-size: 10px; letter-spacing: .2em; text-transform: uppercase;
|
||||
font-family: var(--mono); font-weight: 600;
|
||||
}
|
||||
.pill-ok { color: var(--green-bright); border-color: var(--green-dim); background: rgba(110, 168, 72, .08); }
|
||||
.pill-warn { color: var(--warn); border-color: var(--warn); background: rgba(212, 168, 84, .08); }
|
||||
.pill-crit { color: var(--crit); border-color: var(--crit); background: rgba(232, 96, 106, .08); }
|
||||
.pill-mute { color: var(--bone-dim); border-color: var(--line); background: var(--bg-2); }
|
||||
|
||||
/* KV */
|
||||
.kv { display: grid; grid-template-columns: max-content 1fr; gap: .5em 1.4em; margin: .6em 0; }
|
||||
.kv dt { color: var(--purple); font-size: 11px; letter-spacing: .15em; text-transform: uppercase; font-family: var(--mono); padding-top: 3px; }
|
||||
.kv dd { margin: 0; color: var(--bone); font-size: .95em; word-break: break-all; }
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
display: inline-block; padding: .6em 1.4em;
|
||||
background: var(--surface-2); color: var(--bone);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 5px;
|
||||
font-family: var(--sans); font-weight: 500;
|
||||
font-size: 13px; letter-spacing: .08em;
|
||||
cursor: pointer; text-decoration: none;
|
||||
transition: all .2s ease;
|
||||
}
|
||||
.btn:hover { background: var(--surface-3); border-color: var(--purple-dim); color: var(--bone); }
|
||||
.btn-primary {
|
||||
background: var(--green-dim); color: var(--bone);
|
||||
border-color: var(--green); box-shadow: 0 0 20px -8px var(--green-glow);
|
||||
}
|
||||
.btn-primary:hover { background: var(--green); border-color: var(--green-bright); box-shadow: 0 0 24px -4px var(--green-glow); }
|
||||
.btn-purple {
|
||||
background: var(--purple-deep); color: var(--bone);
|
||||
border-color: var(--purple-dim); box-shadow: 0 0 20px -8px var(--purple-glow);
|
||||
}
|
||||
.btn-purple:hover { background: var(--purple-dim); border-color: var(--purple); box-shadow: 0 0 24px -4px var(--purple-glow); }
|
||||
.btn-row { display: flex; gap: 12px; flex-wrap: wrap; align-items: center; }
|
||||
|
||||
/* Forms */
|
||||
input[type=text], input[type=password], textarea {
|
||||
width: 100%; padding: .75em .9em;
|
||||
background: var(--bg-2); border: 1px solid var(--line);
|
||||
border-radius: 4px;
|
||||
color: var(--bone); font-family: var(--mono); font-size: .92em;
|
||||
transition: border-color .2s, box-shadow .2s;
|
||||
}
|
||||
input[type=text]:focus { outline: none; border-color: var(--purple); box-shadow: 0 0 0 3px rgba(155, 95, 232, .12); }
|
||||
|
||||
/* Misc */
|
||||
a { color: var(--green-bright); text-decoration: none; transition: color .2s; }
|
||||
a:hover { color: var(--purple-bright); }
|
||||
.muted { color: var(--muted); font-size: .9em; }
|
||||
.lede { color: var(--bone-dim); }
|
||||
hr { border: none; border-top: 1px solid var(--line); margin: 2em 0; }
|
||||
code {
|
||||
background: var(--purple-deep); border: 1px solid var(--purple-dim);
|
||||
color: var(--purple-bright);
|
||||
padding: .12em .5em; border-radius: 3px;
|
||||
font-family: var(--mono); font-size: .88em;
|
||||
}
|
||||
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; } }
|
||||
.recipe-card {
|
||||
display: block; padding: 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:hover {
|
||||
border-color: var(--purple-dim); background: var(--surface-2);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 24px -8px var(--purple-glow);
|
||||
}
|
||||
.recipe-card .rname {
|
||||
color: var(--bone); font-family: var(--serif); font-weight: 600;
|
||||
font-size: 1.05em; letter-spacing: .02em;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
.recipe-card .rtags { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 8px; }
|
||||
.recipe-card .rtag {
|
||||
color: var(--green-bright); border: 1px solid var(--green-dim);
|
||||
background: rgba(110, 168, 72, .06);
|
||||
padding: 1px 8px; border-radius: 3px;
|
||||
font-size: 9px; letter-spacing: .15em; text-transform: uppercase;
|
||||
font-family: var(--mono);
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
form { margin: 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="topbar">
|
||||
<div class="brand">
|
||||
<span class="brand-mark">Cauldron</span>
|
||||
<span class="brand-sub">family · LAN</span>
|
||||
</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="/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>
|
||||
</nav>
|
||||
<div class="topmeta">
|
||||
<span class="who">{{ session.user.email }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</header>
|
||||
|
||||
<main>
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
35
cauldron/templates/connect.html
Normal file
35
cauldron/templates/connect.html
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
{% extends "_base.html" %}
|
||||
{% block title %}Connect Mealie · Cauldron{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<div class="page-head">
|
||||
<div class="crumb">// bind</div>
|
||||
<h1>bind your <span class="accent">mealie</span></h1>
|
||||
<div class="lede">cauldron acts on mealie under your own identity. one-time, ~30 seconds.</div>
|
||||
</div>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<h2>token mint</h2>
|
||||
</div>
|
||||
<ol>
|
||||
<li>open <a href="{{ mealie_url }}/group/api-tokens" target="_blank" rel="noopener">mealie → api tokens</a></li>
|
||||
<li>click <strong style="color:var(--hex)">new api token</strong>, name it <code>cauldron</code>, integration <code>generic</code></li>
|
||||
<li>copy the long token string</li>
|
||||
<li>paste below and bind</li>
|
||||
</ol>
|
||||
|
||||
{% if error %}<p style="color: var(--crit); font-size: .9em; margin: .8em 0;">{{ error }}</p>{% endif %}
|
||||
|
||||
<form method="post" action="/connect-mealie">
|
||||
<p><input type="text" name="mealie_token" placeholder="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." required autocomplete="off" autocapitalize="off" spellcheck="false"></p>
|
||||
<div class="btn-row">
|
||||
<button class="btn btn-primary" type="submit">Bind</button>
|
||||
<a class="btn btn-purple" href="/me">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<p class="muted">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.</p>
|
||||
|
||||
{% endblock %}
|
||||
54
cauldron/templates/me.html
Normal file
54
cauldron/templates/me.html
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
{% extends "_base.html" %}
|
||||
{% block title %}{{ user.name or user.email }} · Cauldron{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<div class="page-head">
|
||||
<div class="crumb">// account</div>
|
||||
<h1><span class="accent">{{ user.name or user.email.split('@')[0] }}</span></h1>
|
||||
<div class="lede">welcome back to the coven.</div>
|
||||
</div>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<h2>identity</h2>
|
||||
<span class="ctx">authentik</span>
|
||||
</div>
|
||||
<dl class="kv">
|
||||
<dt>email</dt><dd>{{ user.email }}</dd>
|
||||
<dt>subject</dt><dd>{{ user.sub }}</dd>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="panel green">
|
||||
<div class="panel-head">
|
||||
<h2>mealie</h2>
|
||||
{% if connected %}<span class="pill pill-ok">connected</span>{% else %}<span class="pill pill-warn">not connected</span>{% endif %}
|
||||
</div>
|
||||
|
||||
{% if connected and mealie_user %}
|
||||
<dl class="kv">
|
||||
<dt>logged in as</dt><dd>{{ mealie_user.username or mealie_user.email }}</dd>
|
||||
<dt>full name</dt><dd>{{ mealie_user.fullName or '—' }}</dd>
|
||||
<dt>role</dt><dd>{{ 'admin' if mealie_user.admin else 'member' }}</dd>
|
||||
</dl>
|
||||
<form method="post" action="/disconnect-mealie" class="btn-row">
|
||||
<button class="btn btn-purple" type="submit">Disconnect</button>
|
||||
<span class="muted">revoking on mealie's side also works.</span>
|
||||
</form>
|
||||
{% elif connected %}
|
||||
<p class="muted">token stored, but mealie didn't return profile data. probably revoked or rotated.</p>
|
||||
<form method="post" action="/disconnect-mealie" class="btn-row">
|
||||
<button class="btn btn-purple" type="submit">Reset</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<p>connect mealie so cauldron can act on your behalf — your recipes, your meal plan, your shopping list. one-time, ~30 seconds.</p>
|
||||
<p><a class="btn btn-primary" href="/connect-mealie">Connect mealie →</a></p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<hr>
|
||||
<form method="post" action="/logout">
|
||||
<button class="btn" type="submit">Sign out</button>
|
||||
</form>
|
||||
|
||||
{% endblock %}
|
||||
62
cauldron/templates/recipe_detail.html
Normal file
62
cauldron/templates/recipe_detail.html
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
{% extends "_base.html" %}
|
||||
{% block title %}{{ recipe.name }} · Cauldron{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<div class="page-head">
|
||||
<div class="crumb">// recipes / {{ recipe.slug }}</div>
|
||||
<h1>{{ recipe.name }}</h1>
|
||||
{% if recipe.description %}<div class="lede">{{ recipe.description }}</div>{% endif %}
|
||||
</div>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<h2>meta</h2>
|
||||
</div>
|
||||
<dl class="kv">
|
||||
{% if recipe.totalTime %}<dt>total</dt><dd>{{ recipe.totalTime }}</dd>{% endif %}
|
||||
{% if recipe.prepTime %}<dt>prep</dt><dd>{{ recipe.prepTime }}</dd>{% endif %}
|
||||
{% if recipe.cookTime %}<dt>cook</dt><dd>{{ recipe.cookTime }}</dd>{% endif %}
|
||||
{% if recipe.recipeYield %}<dt>yield</dt><dd>{{ recipe.recipeYield }}</dd>{% endif %}
|
||||
{% if recipe.recipeServings %}<dt>servings</dt><dd>{{ recipe.recipeServings }}</dd>{% endif %}
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="panel green">
|
||||
<div class="panel-head">
|
||||
<h2>ingredients</h2>
|
||||
<span class="ctx">{{ (recipe.recipeIngredient or []) | length }} items</span>
|
||||
</div>
|
||||
{% if recipe.recipeIngredient %}
|
||||
<ul style="list-style: none; padding: 0; margin: 0;">
|
||||
{% for ing in recipe.recipeIngredient %}
|
||||
<li style="padding: 4px 0; border-bottom: 1px dashed var(--line); color: var(--bone);">
|
||||
{% if ing.display %}{{ ing.display }}{% else %}{{ ing.note or '' }}{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="muted">no ingredients listed.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<h2>instructions</h2>
|
||||
</div>
|
||||
{% if recipe.recipeInstructions %}
|
||||
<ol>
|
||||
{% for step in recipe.recipeInstructions %}
|
||||
<li style="margin: .8em 0; color: var(--bone);">{{ step.text }}</li>
|
||||
{% endfor %}
|
||||
</ol>
|
||||
{% else %}
|
||||
<p class="muted">no instructions listed.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<div class="btn-row">
|
||||
<a class="btn btn-purple" href="/recipes">← back to grimoire</a>
|
||||
<a class="btn" href="{{ public_url }}/recipe/{{ recipe.slug }}" target="_blank" rel="noopener">view in mealie ↗</a>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
49
cauldron/templates/recipes.html
Normal file
49
cauldron/templates/recipes.html
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
{% extends "_base.html" %}
|
||||
{% block title %}Recipes · Cauldron{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<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>
|
||||
|
||||
{% 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 %}
|
||||
|
||||
<section class="panel green">
|
||||
<div class="panel-head">
|
||||
<h2>browse</h2>
|
||||
<span class="ctx">page {{ page }} / {{ pages }}</span>
|
||||
</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="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>
|
||||
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
19
cauldron/templates/stub.html
Normal file
19
cauldron/templates/stub.html
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{% extends "_base.html" %}
|
||||
{% block title %}{{ title }} · Cauldron{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<div class="page-head">
|
||||
<div class="crumb">// {{ title }}</div>
|
||||
<h1>the <span class="accent">{{ title }}</span></h1>
|
||||
<div class="lede">coming in v0.3 — {{ coming }}.</div>
|
||||
</div>
|
||||
|
||||
<section class="panel purple">
|
||||
<div class="panel-head">
|
||||
<h2>not yet</h2>
|
||||
<span class="ctx">on the roadmap</span>
|
||||
</div>
|
||||
<p class="muted">brewing.</p>
|
||||
</section>
|
||||
|
||||
{% endblock %}
|
||||
Loading…
Add table
Add a link
Reference in a new issue