From 3c4c0c027d98be9eb25cd9e2114a7239559bca87 Mon Sep 17 00:00:00 2001 From: Kayos Date: Tue, 28 Apr 2026 21:30:09 -0700 Subject: [PATCH] =?UTF-8?q?ux:=20recipe=20card=20click=20=E2=86=92=20modal?= =?UTF-8?q?=20overlay,=20preserves=20filters/scroll?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cobb: 'new page resets all filters. bad flow.' Right. Modal pattern: - Click any .recipe-card anywhere → fetches /api/recipes/.json, opens a full-screen-on-mobile / centered-on-desktop modal - The underlying recipes page (search query, sort chip, category chip, scroll position) stays put - Browser back button closes the modal (history.pushState + popstate listener) - /recipes/ still works server-side as fallback for direct links and shared URLs (no auto-modal-open on page load) Implementation: - New endpoint GET /api/recipes/.json — same data the detail page used, plus a public_url field for the 'in mealie ↗' deep link - Modal HTML lives in _base.html so it's available everywhere - Click delegate at document level catches .recipe-card clicks app-wide; Mushroom button click bubbles back out (skipped) so pin toggle still works without opening the modal - Modal contains: title bar (sticky), scrollable body (recipe meta pills, description, ingredients ticked list, numbered instructions), sticky footer with pin button + 'in mealie' link - Pin button in modal syncs the matching card's picked state on close so the grid reflects the change without a refetch Mobile: - Modal slides up from bottom, takes full viewport-1 of height - Close button is 40px circle, top-right - backdrop-blur 6px, body scroll-locked while open - Tap outside the modal card → close (e.target === backdrop check) - Esc key closes - Cmd/Ctrl/Shift/middle-click still opens in new tab (don't intercept) Desktop: - Modal centered, max-width 720px, max-height 86vh - Backdrop has 30px padding so the card has breathing room --- cauldron/server.py | 14 ++ cauldron/templates/_base.html | 252 ++++++++++++++++++++++++++++++++++ 2 files changed, 266 insertions(+) diff --git a/cauldron/server.py b/cauldron/server.py index a871047..b0b274d 100644 --- a/cauldron/server.py +++ b/cauldron/server.py @@ -471,6 +471,20 @@ def create_app() -> Flask: def list_view(): return render_template("stub.html", title="list", coming="aggregated shopping list from this week's plan", active="list") + @app.get("/api/recipes/.json") + @require_session + def recipe_detail_json(slug: str): + client = current_user_mealie() + if not client: + return jsonify({"error": "not connected"}), 409 + u = session["user"] + try: + recipe = client.get_recipe(slug) + except Exception as e: + return jsonify({"error": str(e)}), 502 + recipe["picked"] = slug in db.list_household_pick_slugs(current_household_id() or 0) if current_household_id() else False + return jsonify({"recipe": recipe, "public_url": cfg.mealie_public_url}) + @app.get("/recipes/") @require_session def recipe_detail(slug: str): diff --git a/cauldron/templates/_base.html b/cauldron/templates/_base.html index 97d9833..5c75e6e 100644 --- a/cauldron/templates/_base.html +++ b/cauldron/templates/_base.html @@ -374,11 +374,96 @@ ol li, ul li { margin: .35em 0; } .recipe-card .rname { font-size: .95em; } } +/* Recipe modal */ +.modal-backdrop { + position: fixed; inset: 0; z-index: 100; + background: rgba(5, 5, 8, .82); + backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); + display: none; align-items: flex-end; justify-content: center; + animation: backdropIn .2s ease-out forwards; + overscroll-behavior: contain; +} +.modal-backdrop.open { display: flex; } +@media (min-width: 720px) { .modal-backdrop { align-items: center; padding: 30px; } } +.modal { + background: var(--surface); + border: 1px solid var(--line); + border-radius: 8px 8px 0 0; + width: 100%; max-width: 720px; + max-height: 92vh; max-height: 92dvh; + display: flex; flex-direction: column; + box-shadow: 0 -10px 60px -10px rgba(0,0,0,.7), 0 0 0 1px rgba(155,95,232,.15); + animation: modalIn .25s ease-out forwards; + overscroll-behavior: contain; +} +@media (min-width: 720px) { .modal { border-radius: 8px; max-height: 86vh; } } +.modal-head { + padding: 14px 18px; + border-bottom: 1px solid var(--line); + display: flex; align-items: center; justify-content: space-between; gap: 10px; + background: var(--bg-2); + border-radius: 8px 8px 0 0; +} +.modal-head .title { + flex: 1; min-width: 0; + color: var(--bone); font-family: var(--serif); font-weight: 600; font-size: 1.15em; + letter-spacing: .02em; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.modal-close { + width: 40px; height: 40px; border-radius: 50%; + background: var(--surface-2); border: 1px solid var(--line); + color: var(--bone-dim); font-size: 22px; line-height: 1; + cursor: pointer; display: flex; align-items: center; justify-content: center; + flex-shrink: 0; +} +.modal-close:active { transform: scale(.92); background: var(--surface-3); } +.modal-body { + padding: 18px; + overflow-y: auto; + -webkit-overflow-scrolling: touch; + flex: 1; +} +.modal-body h3 { + margin: 1.2em 0 .4em 0; padding-bottom: .3em; + border-bottom: 1px solid var(--line-soft); + color: var(--purple-bright); font-family: var(--serif); font-weight: 600; + font-size: 1em; letter-spacing: .15em; text-transform: uppercase; +} +.modal-body h3:first-child { margin-top: 0; } +.modal-body .ing-list { list-style: none; padding: 0; margin: 0; } +.modal-body .ing-list li { padding: 6px 0; border-bottom: 1px dashed var(--line-soft); color: var(--bone); } +.modal-body .ing-list li:last-child { border-bottom: none; } +.modal-body ol.steps { padding-left: 1.4em; margin: 0; } +.modal-body ol.steps li { padding: 6px 0; color: var(--bone); } +.modal-foot { + padding: 14px 18px; + border-top: 1px solid var(--line); + background: var(--bg-2); + display: flex; gap: 10px; flex-wrap: wrap; align-items: center; +} +.modal-foot .btn { flex: 1; min-width: 120px; text-align: center; } +.modal-foot .btn.full { flex: 1 0 100%; } +.modal-meta { + display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 12px; + color: var(--muted); font-size: 11px; letter-spacing: .15em; + text-transform: uppercase; font-family: var(--mono); +} +.modal-meta .m { padding: 3px 10px; border: 1px solid var(--line); border-radius: 999px; } + /* Animations */ @keyframes fadeIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } } +@keyframes backdropIn { + from { opacity: 0; } + to { opacity: 1; } +} +@keyframes modalIn { + from { opacity: 0; transform: translateY(20px); } + to { opacity: 1; transform: translateY(0); } +} form { margin: 0; } button { font-family: inherit; } @@ -409,5 +494,172 @@ button { font-family: inherit; } {% block content %}{% endblock %} + + + +