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