diff options
Diffstat (limited to 'app/templates/meal_plan.html')
| -rw-r--r-- | app/templates/meal_plan.html | 265 |
1 files changed, 265 insertions, 0 deletions
diff --git a/app/templates/meal_plan.html b/app/templates/meal_plan.html new file mode 100644 index 0000000..3c3a932 --- /dev/null +++ b/app/templates/meal_plan.html @@ -0,0 +1,265 @@ +{% extends "base.html" %} +{% block title %}Meal Plan — Menu Planner{% endblock %} + +{% block content %} +<div class="d-flex align-items-center justify-content-between mb-4"> + <div> + <h1 class="h3 fw-bold mb-0">Meal Plan</h1> + <p class="text-muted mb-0">{{ week_start.strftime('%B %d') }} – {{ dates[-1].strftime('%B %d, %Y') }}</p> + </div> + <div class="d-flex align-items-center gap-2"> + <a href="/meal-plan?week={{ prev_week }}" class="btn btn-outline-secondary btn-sm"><i class="bi bi-chevron-left"></i></a> + <a href="/meal-plan" class="btn btn-outline-secondary btn-sm">This Week</a> + <a href="/meal-plan?week={{ next_week }}" class="btn btn-outline-secondary btn-sm"><i class="bi bi-chevron-right"></i></a> + <button onclick="window.print()" class="btn btn-outline-secondary btn-sm"> + <i class="bi bi-printer me-1"></i>Print + </button> + {% if current_user.is_authenticated %} + <button id="generateShoppingBtn" class="btn btn-success btn-sm"> + <i class="bi bi-cart-plus me-1"></i>Generate Shopping List + </button> + {% endif %} + </div> +</div> + +{% if not current_user.is_authenticated %} +<div class="alert alert-info d-flex align-items-center gap-2 mb-3"> + <i class="bi bi-info-circle-fill"></i> + <span><a href="/login">Log in</a> to add or remove meals from the plan.</span> +</div> +{% endif %} + +<!-- Plan grid --> +<div class="card shadow-sm mb-4"> + <div class="card-body p-0"> + <div class="table-responsive"> + <table class="table table-bordered mb-0 plan-table"> + <thead class="table-dark"> + <tr> + <th style="width:110px"></th> + {% for d in dates %} + <th class="text-center {% if d.isoformat() == today_str %}today-col{% endif %}"> + <div class="fw-semibold">{{ d.strftime('%A') }}</div> + <div class="small text-muted">{{ d.strftime('%b %d') }}</div> + </th> + {% endfor %} + </tr> + </thead> + <tbody> + {% for mt in meal_types %} + <tr> + <td class="meal-type-label text-capitalize fw-semibold align-middle">{{ mt }}</td> + {% for d in dates %} + {% set key = d.isoformat() + '_' + mt %} + <td class="plan-cell" data-date="{{ d.isoformat() }}" data-meal="{{ mt }}"> + {% if key in plan %} + {% set entry = plan[key] %} + <div class="planned-meal" data-meal-id="{{ entry.id }}"> + <div class="d-flex align-items-start justify-content-between gap-1"> + <div class="flex-grow-1 overflow-hidden"> + <div class="d-flex align-items-center gap-1"> + <span class="cuisine-badge">{{ cuisine_emoji.get(entry.cuisine, '') }}</span> + <span class="meal-recipe-name">{{ entry.recipe_name }}</span> + </div> + <div class="meal-macros"> + <span>{{ entry.calories_per_serving|int }} cal</span> + <span class="mx-1">·</span> + <span>{{ entry.carbs_per_serving|int }}g carbs</span> + <span class="mx-1">·</span> + <span class="fw-semibold">{{ entry.servings }} ppl</span> + </div> + </div> + {% if current_user.is_authenticated %} + <button class="btn btn-sm text-muted border-0 remove-meal p-0 flex-shrink-0" title="Remove"> + <i class="bi bi-x-lg" style="font-size:0.7rem"></i> + </button> + {% endif %} + </div> + </div> + {% else %} + {% if current_user.is_authenticated %} + <button class="btn btn-sm add-meal-btn w-100" title="Add meal"> + <i class="bi bi-plus text-muted"></i> + </button> + {% else %} + <span class="empty-slot">—</span> + {% endif %} + {% endif %} + </td> + {% endfor %} + </tr> + {% endfor %} + </tbody> + </table> + </div> + </div> +</div> + +<!-- Toast --> +<div class="position-fixed bottom-0 end-0 p-3 no-print" style="z-index:1100"> + <div id="toast" class="toast align-items-center text-bg-success border-0" role="alert"> + <div class="d-flex"> + <div class="toast-body" id="toastBody">Done!</div> + <button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button> + </div> + </div> +</div> + +<!-- Recipe picker modal --> +<div class="modal fade" id="recipeModal" tabindex="-1"> + <div class="modal-dialog modal-lg modal-dialog-scrollable"> + <div class="modal-content"> + <div class="modal-header"> + <h5 class="modal-title">Choose a Recipe</h5> + <button type="button" class="btn-close" data-bs-dismiss="modal"></button> + </div> + <div class="modal-body"> + + <!-- People / servings picker --> + <div class="d-flex align-items-center gap-3 mb-3 p-3 bg-light rounded"> + <label class="fw-semibold mb-0 text-nowrap"> + <i class="bi bi-people me-1"></i>People eating: + </label> + <div class="d-flex align-items-center gap-2"> + <button type="button" id="servingsMinus" class="btn btn-outline-secondary btn-sm px-2">−</button> + <span id="servingsDisplay" class="fw-bold fs-5 px-2">2</span> + <button type="button" id="servingsPlus" class="btn btn-outline-secondary btn-sm px-2">+</button> + </div> + <span class="text-muted small">Shopping list quantities will scale accordingly</span> + </div> + + <!-- Cuisine quick-filter --> + <div id="cuisineFilter" class="d-flex gap-1 flex-wrap mb-3"> + <button class="btn btn-sm btn-dark cf-btn" data-cuisine="all">All</button> + {% for c in cuisines %} + <button class="btn btn-sm btn-outline-secondary cf-btn" data-cuisine="{{ c }}">{{ cuisine_emoji.get(c) }} {{ c }}</button> + {% endfor %} + </div> + + <!-- Search --> + <input type="text" id="recipeSearch" class="form-control mb-3" placeholder="Search recipes…"> + + <!-- Recipe list --> + <div id="recipePickerList" class="row g-2"></div> + </div> + </div> + </div> +</div> +{% endblock %} + +{% block scripts %} +<script> +const allRecipes = {{ recipes_json | safe }}; +let activeDate = null, activeMeal = null, currentCuisine = 'all', currentServings = 2; + +const modal = new bootstrap.Modal(document.getElementById('recipeModal')); +const toast = new bootstrap.Toast(document.getElementById('toast'), {delay: 2500}); + +function renderPickerList(filter='', cuisine='all') { + const list = document.getElementById('recipePickerList'); + const filtered = allRecipes.filter(r => + (cuisine === 'all' || r.cuisine === cuisine) && + r.name.toLowerCase().includes(filter.toLowerCase()) + ); + if (!filtered.length) { + list.innerHTML = '<div class="col-12 text-center text-muted py-3">No recipes found</div>'; + return; + } + list.innerHTML = filtered.map(r => ` + <div class="col-12 col-md-6"> + <button class="btn btn-outline-secondary text-start w-100 recipe-pick-btn p-2" data-id="${r.id}"> + <div class="fw-semibold">${r.name}</div> + <div class="small text-muted">${r.cuisine} · ${Math.round(r.calories_per_serving || 0)} cal/serving</div> + </button> + </div> + `).join(''); + list.querySelectorAll('.recipe-pick-btn').forEach(btn => + btn.addEventListener('click', () => selectRecipe(parseInt(btn.dataset.id))) + ); +} + +async function selectRecipe(recipeId) { + const res = await api('/meal-plan/add', { + method: 'POST', + body: JSON.stringify({date: activeDate, meal_type: activeMeal, recipe_id: recipeId, servings: currentServings}), + }); + if (!res) return; + const data = await res.json(); + if (data.success) { modal.hide(); location.reload(); } +} + +// Open modal +document.querySelectorAll('.add-meal-btn').forEach(btn => { + btn.addEventListener('click', function() { + const cell = this.closest('.plan-cell'); + activeDate = cell.dataset.date; + activeMeal = cell.dataset.meal; + currentCuisine = 'all'; + document.getElementById('recipeSearch').value = ''; + document.querySelectorAll('.cf-btn').forEach(b => { + b.classList.toggle('btn-dark', b.dataset.cuisine === 'all'); + b.classList.toggle('btn-outline-secondary', b.dataset.cuisine !== 'all'); + }); + renderPickerList('', 'all'); + modal.show(); + }); +}); + +// Remove meal +document.querySelectorAll('.remove-meal').forEach(btn => { + btn.addEventListener('click', async function(e) { + e.stopPropagation(); + const cell = this.closest('.plan-cell'); + const res = await api('/meal-plan/remove', { + method: 'POST', + body: JSON.stringify({date: cell.dataset.date, meal_type: cell.dataset.meal}), + }); + if (res && (await res.json()).success) location.reload(); + }); +}); + +// Servings +/− +const servingsDisplay = document.getElementById('servingsDisplay'); +document.getElementById('servingsMinus').addEventListener('click', () => { + if (currentServings > 1) { currentServings--; servingsDisplay.textContent = currentServings; } +}); +document.getElementById('servingsPlus').addEventListener('click', () => { + if (currentServings < 20) { currentServings++; servingsDisplay.textContent = currentServings; } +}); + +// Cuisine filter +document.querySelectorAll('.cf-btn').forEach(btn => { + btn.addEventListener('click', function() { + document.querySelectorAll('.cf-btn').forEach(b => { + b.classList.remove('btn-dark'); b.classList.add('btn-outline-secondary'); + }); + this.classList.add('btn-dark'); this.classList.remove('btn-outline-secondary'); + currentCuisine = this.dataset.cuisine; + renderPickerList(document.getElementById('recipeSearch').value, currentCuisine); + }); +}); + +document.getElementById('recipeSearch').addEventListener('input', function() { + renderPickerList(this.value, currentCuisine); +}); + +// Generate shopping list +document.getElementById('generateShoppingBtn')?.addEventListener('click', async () => { + const week = '{{ week_start.isoformat() }}'; + const res = await api('/shopping-list/generate', { + method: 'POST', body: JSON.stringify({week}), + }); + if (!res) return; + const data = await res.json(); + if (data.success) { + document.getElementById('toastBody').textContent = 'Shopping list generated!'; + toast.show(); + setTimeout(() => window.location.href = `/shopping-list?week=${week}`, 1500); + } else { + document.getElementById('toast').className = 'toast align-items-center text-bg-danger border-0'; + document.getElementById('toastBody').textContent = data.error || 'Error'; + toast.show(); + } +}); +</script> +{% endblock %} |
