ux: recipe card click → modal overlay, preserves filters/scroll
Alice: '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
b981941ade
commit
8212abbfc3
2 changed files with 266 additions and 0 deletions
|
|
@ -471,6 +471,20 @@ def create_app() -> Flask:
|
||||||
def list_view():
|
def list_view():
|
||||||
return render_template("stub.html", title="list", coming="aggregated shopping list from this week's plan", active="list")
|
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>")
|
@app.get("/recipes/<slug>")
|
||||||
@require_session
|
@require_session
|
||||||
def recipe_detail(slug: str):
|
def recipe_detail(slug: str):
|
||||||
|
|
|
||||||
|
|
@ -374,11 +374,96 @@ ol li, ul li { margin: .35em 0; }
|
||||||
.recipe-card .rname { font-size: .95em; }
|
.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 */
|
/* Animations */
|
||||||
@keyframes fadeIn {
|
@keyframes fadeIn {
|
||||||
from { opacity: 0; transform: translateY(8px); }
|
from { opacity: 0; transform: translateY(8px); }
|
||||||
to { opacity: 1; transform: translateY(0); }
|
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; }
|
form { margin: 0; }
|
||||||
button { font-family: inherit; }
|
button { font-family: inherit; }
|
||||||
|
|
@ -409,5 +494,172 @@ button { font-family: inherit; }
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
</main>
|
</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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue