fix /plan 500 + bigger touch targets + sort/category chips + sticky search

The /plan crash:
  AttributeError: 'str' object has no attribute 'get'
  in sync_user_household — Mealie's user-self response sometimes returns
  household as a slug-string (newer versions) instead of a dict.
Fix: defensive — accept dict OR str, also fall back to top-level
householdId / householdSlug fields. Doesn't crash if all are missing.

Mobile + UX pass (Cobb 2026-04-29 — 'two kids screaming at the store'):
- Mushroom pick button bumped 30px → 48px (Apple HIG min tap target),
  inner SVG 16 → 24px, kept opacity transition. Stays one-handed-friendly.
- Recipe cards: padding 14px → 18px, min-height 88px, font-size 1.08em
  → 1.2em on the title. Bigger comfortable tap area.
- Recipe grid: 1col by default, 2col at 720px+, 3col at 1100px+ (was
  560/900). Phones get single column, the bigger card.
- Search bar: 14px → 16px font (kills iOS auto-zoom on focus), 8px → 10px
  padding. Now sticky at top of /recipes with backdrop-blur.
- Pill chip rows for sort + category — horizontally scrollable, hidden
  scrollbar, pill rounded shape, active state in purple-deep bg.
- Sort options: newest, recent (last_made), a-z, updated. Default = newest.
- Category chips: pulled live from Mealie's
  /api/organizers/categories, top 14 shown. 'all' chip clears filter.

Mealie client: list_recipes() now accepts orderBy + orderDirection +
categories[] + tags[] params. New list_categories() helper.

Server: _sort_to_order() maps our sort keys to Mealie's orderBy. Recipes
+ json endpoints both honor sort + cat query params and pass through to
the AJAX paginator.
This commit is contained in:
Kayos 2026-04-28 21:26:22 -07:00
parent 1540c2f436
commit 9e62a3e17f
4 changed files with 195 additions and 59 deletions

View file

@ -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}")

View file

@ -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

View file

@ -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; }

View file

@ -5,14 +5,31 @@
<div class="page-head">
<div class="crumb">// recipes</div>
<h1>the <span class="accent">grimoire</span></h1>
<div class="lede">tap any to open. tap the mushroom to pin it for the next ai meal plan run.</div>
<div class="lede">tap any to open. mushroom 🍄 pins it for the household plan.</div>
</div>
<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>
<input type="text" id="search" placeholder="search…" autocomplete="off" autocapitalize="off" spellcheck="false">
<span class="count" id="count">{{ total }}</span>
</div>
<div class="chip-row">
<span class="chip-label">sort</span>
{% for s in [('newest','newest'), ('made','recent'), ('az','az'), ('updated','updated')] %}
<a class="chip {% if sort == s[0] %}active{% endif %}" href="?sort={{ s[0] }}{% if category %}&cat={{ category }}{% endif %}">{{ s[1] }}</a>
{% endfor %}
</div>
{% if categories %}
<div class="chip-row">
<span class="chip-label">cat</span>
<a class="chip {% if not category %}active{% endif %}" href="?sort={{ sort }}">all</a>
{% for c in categories[:14] %}
<a class="chip {% if category == c.slug %}active{% endif %}" href="?sort={{ sort }}&cat={{ c.slug }}">{{ c.name|lower }}</a>
{% endfor %}
</div>
{% endif %}
<div class="recipe-grid" id="grid">
{% 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 = `<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"/>
@ -45,6 +63,9 @@
<circle cx="12" cy="9.5" r=".7" fill="#0a0a0c"/>
</svg>`;
function escapeHtml(s) { return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }
function escapeAttr(s) { return escapeHtml(s); }
function renderCard(r) {
const tags = (r.tags || []).slice(0, 3).map(t => `<span class="rtag">${escapeHtml(t.name)}</span>`).join('');
const meta = [
@ -60,13 +81,10 @@
<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>
<button class="pick-btn${onClass}" type="button" aria-label="pin to plan" data-slug="${escapeAttr(r.slug)}" data-name="${escapeAttr(r.name||'')}">${SHROOM}</button>
</a>`;
}
function escapeHtml(s) { return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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);
})();
</script>