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:
parent
9e62a3e17f
commit
3c4c0c027d
2 changed files with 266 additions and 0 deletions
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue