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:
parent
1540c2f436
commit
9e62a3e17f
4 changed files with 195 additions and 59 deletions
|
|
@ -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}")
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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','a–z'), ('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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue