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 (Alice 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:
Sulkta 2026-04-28 21:26:22 -07:00
parent 6539bd4049
commit b981941ade
4 changed files with 195 additions and 59 deletions

View file

@ -70,12 +70,35 @@ class Mealie:
# --- recipes ------------------------------------------------------------ # --- recipes ------------------------------------------------------------
def list_recipes(self, *, page: int = 1, per_page: int = 50, search: str | None = None) -> dict: def list_recipes(
params = {"page": page, "perPage": per_page} 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: if search:
params["search"] = 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) 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: def get_recipe(self, slug: str) -> dict:
return self._get(f"/api/recipes/{slug}") 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: def sync_user_household(sub: str) -> int | None:
"""Pull the user's Mealie household, upsert into cauldron, ensure """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() client = current_user_mealie()
if not client: if not client:
return None return None
@ -116,16 +122,30 @@ def create_app() -> Flask:
me = client.who_am_i() me = client.who_am_i()
except Exception: except Exception:
return None return None
h = me.get("household") or {}
h_id_mealie = h.get("id") h = me.get("household")
h_name = h.get("name") or h.get("slug") or "default" 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: if not h_id_mealie:
return None 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) existing = db.list_household_member_subs(local_id)
role = "member" if existing else "admin" role = "admin" if not existing else "member"
db.add_household_member(local_id, sub, role=role if sub not in existing else "member") db.add_household_member(local_id, sub, role=role)
return local_id return local_id
def current_household_id() -> int | None: def current_household_id() -> int | None:
@ -272,34 +292,60 @@ def create_app() -> Flask:
if not client: if not client:
return redirect(url_for("connect_mealie_get")) return redirect(url_for("connect_mealie_get"))
u = session["user"] 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: 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: except Exception:
data = {"items": [], "total": 0, "total_pages": 1} 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 [] items = data.get("items", []) or []
total = data.get("total", len(items)) total = data.get("total", len(items))
pages = data.get("total_pages", 1) or 1 pages = data.get("total_pages", 1) or 1
# Picked is true if ANYONE in the household pinned it (shared pool)
hid = current_household_id() hid = current_household_id()
pick_slugs = db.list_household_pick_slugs(hid) if hid else set() pick_slugs = db.list_household_pick_slugs(hid) if hid else set()
for it in items: for it in items:
it["picked"] = it.get("slug") in pick_slugs it["picked"] = it.get("slug") in pick_slugs
return render_template( 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") @app.get("/api/recipes.json")
@require_session @require_session
def recipes_json(): 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() client = current_user_mealie()
if not client: if not client:
return jsonify({"error": "not connected"}), 409 return jsonify({"error": "not connected"}), 409
u = session["user"] u = session["user"]
page = max(1, int(request.args.get("page", "1"))) page = max(1, int(request.args.get("page", "1")))
search = (request.args.get("q") or "").strip() or None 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: 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: except Exception as e:
return jsonify({"error": str(e)}), 502 return jsonify({"error": str(e)}), 502
items = data.get("items", []) or [] items = data.get("items", []) or []
@ -474,6 +520,18 @@ def create_app() -> Flask:
return app 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: def _const_eq(a: str, b: str) -> bool:
if len(a) != len(b): if len(a) != len(b):
return False return False

View file

@ -229,78 +229,118 @@ strong { color: var(--bone); font-weight: 600; }
ol, ul { padding-left: 1.4em; } ol, ul { padding-left: 1.4em; }
ol li, ul li { margin: .35em 0; } 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 { .search-row {
display: flex; gap: 10px; align-items: center; 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 { .search-row input {
flex: 1; padding: .7em .9em; flex: 1; padding: .85em 1em;
background: var(--bg-2); border: 1px solid var(--line); background: var(--bg-2); border: 1px solid var(--line);
border-radius: 4px; border-radius: 6px;
color: var(--bone); font-family: var(--sans); font-size: 14px; 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 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; } .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 */ /* Chip rows — sort + category quick filters, horizontal scroll on mobile */
.recipe-grid { .chip-row {
display: grid; grid-template-columns: 1fr; gap: 12px; 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; } } .chip-row::-webkit-scrollbar { display: none; }
@media (min-width: 900px) { .recipe-grid { grid-template-columns: 1fr 1fr 1fr; } } .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 { .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: 1px solid var(--line); background: var(--surface);
border-radius: 5px; border-radius: 8px;
text-decoration: none; color: inherit; text-decoration: none; color: inherit;
position: relative; overflow: hidden; position: relative; overflow: hidden;
transition: all .2s ease; 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 { border-color: var(--purple-dim); background: rgba(45, 29, 74, .35); }
.recipe-card.picked::before { .recipe-card.picked::before {
content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 3px; content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 3px;
background: var(--purple-bright); box-shadow: 0 0 12px var(--purple-glow); 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); border-color: var(--purple-dim); background: var(--surface-2);
transform: translateY(-1px); transform: translateY(-1px);
box-shadow: 0 4px 24px -8px var(--purple-glow); 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 { .recipe-card .rname {
color: var(--bone); font-family: var(--serif); font-weight: 600; 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:hover .rname { color: var(--purple-bright); }
.recipe-card .rmeta { .recipe-card .rmeta {
color: var(--muted); font-size: 10px; letter-spacing: .15em; color: var(--muted); font-size: 11px; letter-spacing: .15em;
text-transform: uppercase; font-family: var(--mono); margin-top: 8px; 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 { .recipe-card .rtag {
color: var(--green-bright); border: 1px solid var(--green-dim); color: var(--green-bright); border: 1px solid var(--green-dim);
background: rgba(110, 168, 72, .06); background: rgba(110, 168, 72, .06);
padding: 1px 8px; border-radius: 3px; padding: 2px 10px; border-radius: 4px;
font-size: 9px; letter-spacing: .15em; text-transform: uppercase; font-size: 10px; letter-spacing: .15em; text-transform: uppercase;
font-family: var(--mono); 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 { .pick-btn {
position: absolute; top: 10px; right: 10px; position: absolute; top: 8px; right: 8px;
width: 30px; height: 30px; width: 48px; height: 48px;
background: var(--bg-2); border: 1px solid var(--line); background: var(--bg-2); border: 1px solid var(--line);
border-radius: 50%; border-radius: 50%;
display: flex; align-items: center; justify-content: center; display: flex; align-items: center; justify-content: center;
cursor: pointer; padding: 0; cursor: pointer; padding: 0;
transition: all .2s ease; transition: all .15s ease;
z-index: 2; 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:active { transform: scale(.92); }
.pick-btn .shroom { width: 16px; height: 16px; opacity: .55; transition: opacity .2s; } .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: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 */ /* Infinite-scroll sentinel */
.scroll-sentinel { height: 24px; } .scroll-sentinel { height: 24px; }

View file

@ -5,14 +5,31 @@
<div class="page-head"> <div class="page-head">
<div class="crumb">// recipes</div> <div class="crumb">// recipes</div>
<h1>the <span class="accent">grimoire</span></h1> <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>
<div class="search-row"> <div class="search-row">
<input type="text" id="search" placeholder="search recipes…" autocomplete="off" autocapitalize="off" spellcheck="false"> <input type="text" id="search" placeholder="search…" autocomplete="off" autocapitalize="off" spellcheck="false">
<span class="count" id="count">{{ total }} total</span> <span class="count" id="count">{{ total }}</span>
</div> </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"> <div class="recipe-grid" id="grid">
{% for r in recipes %} {% for r in recipes %}
{% include "_recipe_card.html" %} {% include "_recipe_card.html" %}
@ -30,13 +47,14 @@
const search = document.getElementById('search'); const search = document.getElementById('search');
const sentinel = document.getElementById('sentinel'); const sentinel = document.getElementById('sentinel');
const initialSort = {{ sort|tojson }};
const initialCat = {{ (category or '')|tojson }};
let nextPage = {{ 2 if pages > 1 else 0 }}; let nextPage = {{ 2 if pages > 1 else 0 }};
let total = {{ total }}; let total = {{ total }};
let q = ''; let q = '';
let loading = false; let loading = false;
let aborter = null; 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"> 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="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"/> <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"/> <circle cx="12" cy="9.5" r=".7" fill="#0a0a0c"/>
</svg>`; </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) { function renderCard(r) {
const tags = (r.tags || []).slice(0, 3).map(t => `<span class="rtag">${escapeHtml(t.name)}</span>`).join(''); const tags = (r.tags || []).slice(0, 3).map(t => `<span class="rtag">${escapeHtml(t.name)}</span>`).join('');
const meta = [ const meta = [
@ -60,13 +81,10 @@
<div class="rmeta">${meta}</div> <div class="rmeta">${meta}</div>
${tags ? `<div class="rtags">${tags}</div>` : ''} ${tags ? `<div class="rtags">${tags}</div>` : ''}
</span> </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>`; </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) { async function loadMore(reset=false) {
if (loading) return; if (loading) return;
if (!reset && !nextPage) return; if (!reset && !nextPage) return;
@ -74,9 +92,11 @@
if (aborter) aborter.abort(); if (aborter) aborter.abort();
aborter = new AbortController(); aborter = new AbortController();
state.classList.remove('done', 'error'); state.classList.remove('done', 'error');
state.textContent = 'loading…'; state.textContent = '…';
const page = reset ? 1 : nextPage; 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 { try {
const r = await fetch(url, { signal: aborter.signal }); const r = await fetch(url, { signal: aborter.signal });
if (!r.ok) throw new Error(r.status); if (!r.ok) throw new Error(r.status);
@ -86,7 +106,7 @@
grid.insertAdjacentHTML('beforeend', renderCard(item)); grid.insertAdjacentHTML('beforeend', renderCard(item));
} }
total = data.total ?? total; total = data.total ?? total;
count.textContent = `${total} ${q ? 'matching' : 'total'}`; count.textContent = `${total}`;
nextPage = data.next || 0; nextPage = data.next || 0;
state.textContent = nextPage ? '' : '— end —'; state.textContent = nextPage ? '' : '— end —';
if (!nextPage) state.classList.add('done'); if (!nextPage) state.classList.add('done');
@ -100,7 +120,6 @@
} }
} }
// Pick toggle (event delegation)
grid.addEventListener('click', async (e) => { grid.addEventListener('click', async (e) => {
const btn = e.target.closest('.pick-btn'); const btn = e.target.closest('.pick-btn');
if (!btn) return; if (!btn) return;
@ -109,7 +128,6 @@
const name = btn.dataset.name; const name = btn.dataset.name;
const card = btn.closest('.recipe-card'); const card = btn.closest('.recipe-card');
const wasOn = btn.classList.contains('on'); const wasOn = btn.classList.contains('on');
// optimistic flip
btn.classList.toggle('on'); btn.classList.toggle('on');
if (card) card.classList.toggle('picked'); if (card) card.classList.toggle('picked');
try { try {
@ -120,13 +138,11 @@
}); });
if (!r.ok) throw new Error(r.status); if (!r.ok) throw new Error(r.status);
} catch (err) { } catch (err) {
// rollback on failure
btn.classList.toggle('on'); btn.classList.toggle('on');
if (card) card.classList.toggle('picked'); if (card) card.classList.toggle('picked');
} }
}); });
// Debounced search
let searchTimer = null; let searchTimer = null;
search.addEventListener('input', () => { search.addEventListener('input', () => {
clearTimeout(searchTimer); clearTimeout(searchTimer);
@ -136,10 +152,9 @@
}, 250); }, 250);
}); });
// Infinite scroll
const io = new IntersectionObserver((entries) => { const io = new IntersectionObserver((entries) => {
if (entries.some(e => e.isIntersecting)) loadMore(); if (entries.some(e => e.isIntersecting)) loadMore();
}, { rootMargin: '200px 0px' }); }, { rootMargin: '300px 0px' });
io.observe(sentinel); io.observe(sentinel);
})(); })();
</script> </script>