diff --git a/cauldron/mealie.py b/cauldron/mealie.py index 66761e3..0bf4d69 100644 --- a/cauldron/mealie.py +++ b/cauldron/mealie.py @@ -70,12 +70,35 @@ class Mealie: # --- recipes ------------------------------------------------------------ - def list_recipes(self, *, page: int = 1, per_page: int = 50, search: str | None = None) -> dict: - params = {"page": page, "perPage": per_page} + def list_recipes( + self, + *, + page: int = 1, + per_page: int = 50, + search: str | None = None, + order_by: str | None = None, + order_direction: str | None = None, + categories: list[str] | None = None, + tags: list[str] | None = None, + ) -> dict: + params: dict = {"page": page, "perPage": per_page} if search: params["search"] = search + if order_by: + params["orderBy"] = order_by + if order_direction: + params["orderDirection"] = order_direction + if categories: + params["categories"] = categories + if tags: + params["tags"] = tags return self._get("/api/recipes", **params) + def list_categories(self) -> dict: + """GET /api/organizers/categories — returns full list of categories + in the household.""" + return self._get("/api/organizers/categories", perPage=200) + 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 6fef3b5..a871047 100644 --- a/cauldron/server.py +++ b/cauldron/server.py @@ -108,7 +108,13 @@ def create_app() -> Flask: def sync_user_household(sub: str) -> int | None: """Pull the user's Mealie household, upsert into cauldron, ensure - membership. Idempotent. Returns local household_id or None.""" + membership. Idempotent. Returns local household_id or None. + + Mealie's user-self response shape varies across versions — the + `household` field can be a dict (with id+name+slug), a plain + slug-string, or absent. We also accept top-level householdId / + householdSlug. If we can't resolve a real ID, fall back to slug. + """ client = current_user_mealie() if not client: return None @@ -116,16 +122,30 @@ def create_app() -> Flask: me = client.who_am_i() except Exception: return None - h = me.get("household") or {} - h_id_mealie = h.get("id") - h_name = h.get("name") or h.get("slug") or "default" + + h = me.get("household") + h_id_mealie: str | None = None + h_name: str | None = None + + if isinstance(h, dict): + h_id_mealie = h.get("id") or h.get("slug") + h_name = h.get("name") or h.get("slug") + elif isinstance(h, str) and h: + # newer Mealie versions return household as a slug string + h_id_mealie = h + h_name = h + + # Fall back to top-level fields + h_id_mealie = h_id_mealie or me.get("householdId") or me.get("household_id") or me.get("householdSlug") or me.get("household_slug") + h_name = h_name or me.get("householdName") or h_id_mealie or "default" + if not h_id_mealie: return None - local_id = db.upsert_household(mealie_household_id=str(h_id_mealie), name=h_name) - # First member of a household becomes admin; rest stay 'member' + + local_id = db.upsert_household(mealie_household_id=str(h_id_mealie), name=str(h_name)) existing = db.list_household_member_subs(local_id) - role = "member" if existing else "admin" - db.add_household_member(local_id, sub, role=role if sub not in existing else "member") + role = "admin" if not existing else "member" + db.add_household_member(local_id, sub, role=role) return local_id def current_household_id() -> int | None: @@ -272,34 +292,60 @@ def create_app() -> Flask: if not client: return redirect(url_for("connect_mealie_get")) u = session["user"] + sort = request.args.get("sort", "newest") + category = (request.args.get("cat") or "").strip() + order_by, order_dir = _sort_to_order(sort) try: - data = client.list_recipes(page=1, per_page=24) + data = client.list_recipes( + page=1, per_page=20, + order_by=order_by, order_direction=order_dir, + categories=[category] if category else None, + ) except Exception: data = {"items": [], "total": 0, "total_pages": 1} + # Categories for the chip row + categories: list[dict] = [] + try: + cat_data = client.list_categories() + categories = cat_data.get("items") or [] + except Exception: + pass + items = data.get("items", []) or [] total = data.get("total", len(items)) pages = data.get("total_pages", 1) or 1 - # Picked is true if ANYONE in the household pinned it (shared pool) hid = current_household_id() pick_slugs = db.list_household_pick_slugs(hid) if hid else set() for it in items: it["picked"] = it.get("slug") in pick_slugs return render_template( - "recipes.html", recipes=items, total=total, pages=pages, active="recipes" + "recipes.html", + recipes=items, total=total, pages=pages, + sort=sort, category=category, + categories=categories, + active="recipes", ) @app.get("/api/recipes.json") @require_session def recipes_json(): - """Paginated + searchable recipes for the infinite-scroll AJAX path.""" + """Paginated + searchable + sortable + category-filtered 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 + sort = request.args.get("sort", "newest") + category = (request.args.get("cat") or "").strip() or None + order_by, order_dir = _sort_to_order(sort) try: - data = client.list_recipes(page=page, per_page=24, search=search) + data = client.list_recipes( + page=page, per_page=20, search=search, + order_by=order_by, order_direction=order_dir, + categories=[category] if category else None, + ) except Exception as e: return jsonify({"error": str(e)}), 502 items = data.get("items", []) or [] @@ -474,6 +520,18 @@ def create_app() -> Flask: return app +def _sort_to_order(sort: str) -> tuple[str, str]: + """Map our sort keys to Mealie's orderBy + direction.""" + return { + "newest": ("created_at", "desc"), + "oldest": ("created_at", "asc"), + "az": ("name", "asc"), + "za": ("name", "desc"), + "made": ("last_made", "desc"), + "updated": ("updated_at", "desc"), + }.get(sort, ("created_at", "desc")) + + 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 index 6fc3ba8..97d9833 100644 --- a/cauldron/templates/_base.html +++ b/cauldron/templates/_base.html @@ -229,78 +229,118 @@ strong { color: var(--bone); font-weight: 600; } ol, ul { padding-left: 1.4em; } ol li, ul li { margin: .35em 0; } -/* Search bar (sticky-ish on the recipes page) */ +/* Search bar (sticky on the recipes page for one-handed scroll) */ .search-row { display: flex; gap: 10px; align-items: center; - margin-bottom: 18px; + margin-bottom: 14px; + position: sticky; top: 0; z-index: 30; + background: rgba(10, 10, 12, .92); backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + margin-left: -22px; margin-right: -22px; + padding: 14px 22px 10px 22px; } .search-row input { - flex: 1; padding: .7em .9em; + flex: 1; padding: .85em 1em; background: var(--bg-2); border: 1px solid var(--line); - border-radius: 4px; - color: var(--bone); font-family: var(--sans); font-size: 14px; + border-radius: 6px; + color: var(--bone); font-family: var(--sans); font-size: 16px; + /* font-size 16px keeps iOS from auto-zooming on focus */ } .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; +/* Chip rows — sort + category quick filters, horizontal scroll on mobile */ +.chip-row { + display: flex; gap: 8px; flex-wrap: nowrap; overflow-x: auto; + padding: 4px 0 8px 0; margin-bottom: 4px; + -webkit-overflow-scrolling: touch; + scrollbar-width: none; } -@media (min-width: 560px) { .recipe-grid { grid-template-columns: 1fr 1fr; } } -@media (min-width: 900px) { .recipe-grid { grid-template-columns: 1fr 1fr 1fr; } } +.chip-row::-webkit-scrollbar { display: none; } +.chip { + flex-shrink: 0; + display: inline-block; padding: .5em 1em; + background: var(--surface); border: 1px solid var(--line); + border-radius: 999px; + color: var(--bone-dim); text-decoration: none; + font-family: var(--sans); font-weight: 500; + font-size: 13px; letter-spacing: .03em; + white-space: nowrap; transition: all .15s ease; +} +.chip:hover { border-color: var(--purple-dim); color: var(--bone); background: var(--surface-2); } +.chip.active { background: var(--purple-deep); color: var(--purple-bright); border-color: var(--purple); } +.chip.active:hover { background: var(--purple-dim); color: var(--bone); } +.chip-label { + flex-shrink: 0; align-self: center; + color: var(--muted); font-size: 11px; letter-spacing: .15em; + text-transform: uppercase; font-family: var(--mono); padding: 0 4px; +} + +/* Recipe grid + cards — bigger, mobile-first */ +.recipe-grid { + display: grid; grid-template-columns: 1fr; gap: 14px; +} +@media (min-width: 720px) { .recipe-grid { grid-template-columns: 1fr 1fr; gap: 16px; } } +@media (min-width: 1100px) { .recipe-grid { grid-template-columns: 1fr 1fr 1fr; } } .recipe-card { - display: block; padding: 14px 14px 14px 16px; + display: block; padding: 18px 18px 18px 20px; border: 1px solid var(--line); background: var(--surface); - border-radius: 5px; + border-radius: 8px; text-decoration: none; color: inherit; position: relative; overflow: hidden; transition: all .2s ease; + min-height: 88px; /* makes the whole card a comfortable tap */ } .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 { +.recipe-card:active, .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 .rmain { display: block; padding-right: 56px; } .recipe-card .rname { color: var(--bone); font-family: var(--serif); font-weight: 600; - font-size: 1.08em; letter-spacing: .02em; line-height: 1.25; + font-size: 1.2em; 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: 8px; + color: var(--muted); font-size: 11px; letter-spacing: .15em; + text-transform: uppercase; font-family: var(--mono); margin-top: 10px; } -.recipe-card .rtags { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 8px; } +.recipe-card .rtags { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 10px; } .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; + padding: 2px 10px; border-radius: 4px; + font-size: 10px; letter-spacing: .15em; text-transform: uppercase; font-family: var(--mono); } -/* Pick toggle — small mushroom button top-right of card */ +/* Pick toggle — mushroom button. 48px tap target (Apple HIG); inner SVG smaller. */ .pick-btn { - position: absolute; top: 10px; right: 10px; - width: 30px; height: 30px; + position: absolute; top: 8px; right: 8px; + width: 48px; height: 48px; 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; + transition: all .15s ease; z-index: 2; + -webkit-tap-highlight-color: transparent; } -.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:active { transform: scale(.92); } +.pick-btn:hover { border-color: var(--purple); background: var(--surface-3); } +.pick-btn .shroom { width: 24px; height: 24px; opacity: .65; transition: opacity .15s; pointer-events: none; } .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); } +.pick-btn.on { + border-color: var(--purple-bright); + background: var(--purple-deep); + box-shadow: 0 0 16px var(--purple-glow); +} /* Infinite-scroll sentinel */ .scroll-sentinel { height: 24px; } diff --git a/cauldron/templates/recipes.html b/cauldron/templates/recipes.html index ac86d1c..bb4070a 100644 --- a/cauldron/templates/recipes.html +++ b/cauldron/templates/recipes.html @@ -5,14 +5,31 @@
// recipes

the grimoire

-
tap any to open. tap the mushroom to pin it for the next ai meal plan run.
+
tap any to open. mushroom 🍄 pins it for the household plan.
- - {{ total }} total + + {{ total }}
+
+ sort + {% for s in [('newest','newest'), ('made','recent'), ('az','a–z'), ('updated','updated')] %} + {{ s[1] }} + {% endfor %} +
+ +{% if categories %} +
+ cat + all + {% for c in categories[:14] %} + {{ c.name|lower }} + {% endfor %} +
+{% endif %} +
{% for r in recipes %} {% include "_recipe_card.html" %} @@ -30,13 +47,14 @@ const search = document.getElementById('search'); const sentinel = document.getElementById('sentinel'); + const initialSort = {{ sort|tojson }}; + const initialCat = {{ (category or '')|tojson }}; 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 = ` @@ -45,6 +63,9 @@ `; + function escapeHtml(s) { return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); } + function escapeAttr(s) { return escapeHtml(s); } + function renderCard(r) { const tags = (r.tags || []).slice(0, 3).map(t => `${escapeHtml(t.name)}`).join(''); const meta = [ @@ -60,13 +81,10 @@
${meta}
${tags ? `
${tags}
` : ''} - + `; } - 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; @@ -74,9 +92,11 @@ if (aborter) aborter.abort(); aborter = new AbortController(); state.classList.remove('done', 'error'); - state.textContent = 'loading…'; + state.textContent = '…'; const page = reset ? 1 : nextPage; - const url = `/api/recipes.json?page=${page}` + (q ? `&q=${encodeURIComponent(q)}` : ''); + const url = `/api/recipes.json?page=${page}&sort=${encodeURIComponent(initialSort)}` + + (initialCat ? `&cat=${encodeURIComponent(initialCat)}` : '') + + (q ? `&q=${encodeURIComponent(q)}` : ''); try { const r = await fetch(url, { signal: aborter.signal }); if (!r.ok) throw new Error(r.status); @@ -86,7 +106,7 @@ grid.insertAdjacentHTML('beforeend', renderCard(item)); } total = data.total ?? total; - count.textContent = `${total} ${q ? 'matching' : 'total'}`; + count.textContent = `${total}`; nextPage = data.next || 0; state.textContent = nextPage ? '' : '— end —'; if (!nextPage) state.classList.add('done'); @@ -100,7 +120,6 @@ } } - // Pick toggle (event delegation) grid.addEventListener('click', async (e) => { const btn = e.target.closest('.pick-btn'); if (!btn) return; @@ -109,7 +128,6 @@ 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 { @@ -120,13 +138,11 @@ }); 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); @@ -136,10 +152,9 @@ }, 250); }); - // Infinite scroll const io = new IntersectionObserver((entries) => { if (entries.some(e => e.isIntersecting)) loadMore(); - }, { rootMargin: '200px 0px' }); + }, { rootMargin: '300px 0px' }); io.observe(sentinel); })();