ux: recipe card click → modal overlay, preserves filters/scroll

Cobb: 'new page resets all filters. bad flow.' Right.

Modal pattern:
- Click any .recipe-card anywhere → fetches /api/recipes/<slug>.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/<slug> 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/<slug>.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
This commit is contained in:
Kayos 2026-04-28 21:30:09 -07:00
parent 9e62a3e17f
commit 3c4c0c027d
2 changed files with 266 additions and 0 deletions

View file

@ -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/<slug>.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/<slug>")
@require_session
def recipe_detail(slug: str):

View file

@ -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 %}
</main>
<div class="modal-backdrop" id="recipe-modal" role="dialog" aria-modal="true" aria-hidden="true">
<div class="modal" role="document">
<div class="modal-head">
<div class="title" id="modal-title"></div>
<button class="modal-close" type="button" id="modal-close" aria-label="close">×</button>
</div>
<div class="modal-body" id="modal-body">
<p style="color: var(--muted); text-align: center; padding: 30px 0;">summoning…</p>
</div>
<div class="modal-foot" id="modal-foot"></div>
</div>
</div>
<script>
(function(){
const modal = document.getElementById('recipe-modal');
const titleEl = document.getElementById('modal-title');
const bodyEl = document.getElementById('modal-body');
const footEl = document.getElementById('modal-foot');
const closeBtn = document.getElementById('modal-close');
let currentSlug = null;
let openedHistory = false;
function escapeHtml(s) { return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }
function setHidden(hidden) {
modal.classList.toggle('open', !hidden);
modal.setAttribute('aria-hidden', hidden ? 'true' : 'false');
document.body.style.overflow = hidden ? '' : 'hidden';
}
async function openRecipe(slug, name, pushHistory=true) {
currentSlug = slug;
titleEl.textContent = name || '…';
bodyEl.innerHTML = '<p style="color: var(--muted); text-align: center; padding: 30px 0;">summoning…</p>';
footEl.innerHTML = '';
setHidden(false);
if (pushHistory) {
history.pushState({modalSlug: slug}, '', '/recipes/' + encodeURIComponent(slug));
openedHistory = true;
}
try {
const r = await fetch('/api/recipes/' + encodeURIComponent(slug) + '.json');
if (!r.ok) throw new Error(r.status);
const data = await r.json();
renderRecipe(data.recipe, data.public_url);
} catch (e) {
bodyEl.innerHTML = '<p style="color: var(--crit);">load failed.</p>';
}
}
function renderRecipe(r, publicUrl) {
titleEl.textContent = r.name || '(untitled)';
const meta = [];
if (r.totalTime) meta.push('<span class="m">' + escapeHtml(r.totalTime) + '</span>');
if (r.prepTime) meta.push('<span class="m">prep ' + escapeHtml(r.prepTime) + '</span>');
if (r.cookTime) meta.push('<span class="m">cook ' + escapeHtml(r.cookTime) + '</span>');
if (r.recipeYield) meta.push('<span class="m">' + escapeHtml(r.recipeYield) + '</span>');
const metaHtml = meta.length ? '<div class="modal-meta">' + meta.join('') + '</div>' : '';
let html = metaHtml;
if (r.description) html += '<p style="color: var(--bone-dim); font-style: italic;">' + escapeHtml(r.description) + '</p>';
const ings = r.recipeIngredient || [];
if (ings.length) {
html += '<h3>ingredients · ' + ings.length + '</h3><ul class="ing-list">';
for (const i of ings) {
const txt = i.display || i.note || '';
html += '<li>' + escapeHtml(txt) + '</li>';
}
html += '</ul>';
}
const steps = r.recipeInstructions || [];
if (steps.length) {
html += '<h3>instructions</h3><ol class="steps">';
for (const s of steps) {
html += '<li>' + escapeHtml(s.text || '') + '</li>';
}
html += '</ol>';
}
bodyEl.innerHTML = html;
bodyEl.scrollTop = 0;
const isPicked = !!r.picked;
footEl.innerHTML = `
<button class="btn ${isPicked ? 'btn-purple' : 'btn-primary'}" type="button" id="modal-pin"
data-slug="${escapeHtml(r.slug)}" data-name="${escapeHtml(r.name||'')}">
🍄 ${isPicked ? 'pinned · unpin' : 'pin to plan'}
</button>
<a class="btn" href="${publicUrl}/recipe/${encodeURIComponent(r.slug)}" target="_blank" rel="noopener">in mealie ↗</a>
`;
}
function closeModal() {
setHidden(true);
currentSlug = null;
if (openedHistory && history.state && history.state.modalSlug) {
openedHistory = false;
history.back();
}
}
closeBtn.addEventListener('click', closeModal);
modal.addEventListener('click', (e) => { if (e.target === modal) closeModal(); });
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && modal.classList.contains('open')) closeModal();
});
window.addEventListener('popstate', (e) => {
if (modal.classList.contains('open')) {
setHidden(true);
currentSlug = null;
openedHistory = false;
}
});
// Hijack any recipe-card click anywhere on the page → open modal
document.addEventListener('click', (e) => {
const card = e.target.closest('.recipe-card');
if (!card) return;
if (e.target.closest('.pick-btn')) return; // mushroom handles its own
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) return; // open in new tab
e.preventDefault();
const slug = card.dataset.slug || (card.getAttribute('href') || '').replace('/recipes/','');
if (slug) openRecipe(slug, card.querySelector('.rname')?.textContent);
});
// Pin toggle inside the modal (event delegation on body)
footEl.addEventListener('click', async (e) => {
const btn = e.target.closest('#modal-pin');
if (!btn) return;
const slug = btn.dataset.slug;
const name = btn.dataset.name;
const isOn = btn.classList.contains('btn-purple');
btn.disabled = true;
try {
const r = await fetch('/api/picks/' + encodeURIComponent(slug), {
method: isOn ? 'DELETE' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: isOn ? null : JSON.stringify({ name })
});
if (!r.ok) throw new Error(r.status);
btn.classList.toggle('btn-purple');
btn.classList.toggle('btn-primary');
btn.innerHTML = btn.classList.contains('btn-purple')
? '🍄 pinned · unpin'
: '🍄 pin to plan';
// Sync the matching card on the page (if visible)
const card = document.querySelector('.recipe-card[data-slug="' + slug + '"]');
if (card) {
card.classList.toggle('picked');
const pickBtn = card.querySelector('.pick-btn');
if (pickBtn) pickBtn.classList.toggle('on');
}
} catch (err) {
/* leave as-is */
} finally {
btn.disabled = false;
}
});
// Direct-link fallback: if user lands on /recipes/<slug> directly (e.g. shared link),
// the server-rendered detail page handles it. No modal pop on initial load.
})();
</script>
</body>
</html>