diff options
Diffstat (limited to 'app/templates')
| -rw-r--r-- | app/templates/ai_chat.html | 241 | ||||
| -rw-r--r-- | app/templates/base.html | 99 | ||||
| -rw-r--r-- | app/templates/index.html | 116 | ||||
| -rw-r--r-- | app/templates/login.html | 39 | ||||
| -rw-r--r-- | app/templates/meal_plan.html | 265 | ||||
| -rw-r--r-- | app/templates/recipe_add.html | 216 | ||||
| -rw-r--r-- | app/templates/recipe_detail.html | 290 | ||||
| -rw-r--r-- | app/templates/recipes.html | 121 | ||||
| -rw-r--r-- | app/templates/shopping_list.html | 167 |
9 files changed, 1554 insertions, 0 deletions
diff --git a/app/templates/ai_chat.html b/app/templates/ai_chat.html new file mode 100644 index 0000000..82d2604 --- /dev/null +++ b/app/templates/ai_chat.html @@ -0,0 +1,241 @@ +{% extends "base.html" %} +{% block title %}Recipe Assistant — Menu Planner{% endblock %} + +{% block content %} +<div class="ai-page d-flex flex-column" style="height: calc(100vh - 130px)"> + + <!-- Header --> + <div class="d-flex align-items-center justify-content-between mb-3 flex-shrink-0"> + <div> + <h1 class="h3 fw-bold mb-0">Recipe Assistant</h1> + <p class="text-muted small mb-0">Paste any recipe — I'll parse it, adapt it for low-carb, and add it to the library.</p> + </div> + <button id="newChatBtn" class="btn btn-outline-secondary btn-sm"> + <i class="bi bi-arrow-counterclockwise me-1"></i>New Chat + </button> + </div> + + {% if not has_api_key %} + <div class="alert alert-warning"> + <i class="bi bi-exclamation-triangle me-2"></i> + <strong>API key not configured.</strong> Set <code>ANTHROPIC_API_KEY</code> in the Docker environment to enable the recipe assistant. + </div> + {% else %} + + <!-- Chat messages --> + <div id="chatMessages" class="chat-window flex-grow-1 overflow-auto mb-3 p-3"> + <div class="message message-assistant"> + <div class="message-bubble"> + <p class="mb-1">👋 Hi! I can add recipes to the planner for you. Just paste a recipe — from a website, cookbook, or your own notes — and I'll handle the rest.</p> + <p class="mb-0 text-muted small">I'll parse ingredients, estimate nutrition, categorize it, and adapt for low-carb if needed.</p> + </div> + </div> + </div> + + <!-- Input --> + <div class="chat-input-area flex-shrink-0"> + <div id="attachBadge" class="d-none mb-1"> + <span class="badge bg-secondary"><i class="bi bi-paperclip me-1"></i><span id="attachName"></span> + <button type="button" class="btn-close btn-close-white ms-1" id="clearAttach" style="font-size:0.6rem"></button> + </span> + </div> + <div class="input-group"> + <label class="btn btn-outline-secondary" title="Attach file" style="cursor:pointer"> + <i class="bi bi-paperclip"></i> + <input type="file" id="fileInput" class="d-none" + accept=".txt,.html,.htm,.md,.json,.csv,.xml,image/*"> + </label> + <textarea id="chatInput" class="form-control" rows="3" + placeholder="Paste a recipe here, or ask me something…"></textarea> + <button id="sendBtn" class="btn btn-primary px-4"> + <span id="sendLabel"><i class="bi bi-send"></i></span> + <span id="sendSpinner" class="spinner-border spinner-border-sm d-none" role="status"></span> + </button> + </div> + <div class="d-flex justify-content-between mt-1"> + <small class="text-muted">Shift+Enter for new line · Enter to send</small> + <small class="text-muted" id="charCount"></small> + </div> + </div> + + {% endif %} +</div> +{% endblock %} + +{% block scripts %} +{% if has_api_key %} +<script> +let history = []; +let busy = false; +let attachment = null; // {name, type, text} or {name, type, b64, mime} + +const chatMessages = document.getElementById('chatMessages'); +const chatInput = document.getElementById('chatInput'); +const sendBtn = document.getElementById('sendBtn'); +const sendLabel = document.getElementById('sendLabel'); +const sendSpinner = document.getElementById('sendSpinner'); +const charCount = document.getElementById('charCount'); + +// Auto-resize textarea +chatInput.addEventListener('input', () => { + chatInput.style.height = 'auto'; + chatInput.style.height = Math.min(chatInput.scrollHeight, 200) + 'px'; + charCount.textContent = chatInput.value.length > 0 ? chatInput.value.length + ' chars' : ''; +}); + +// Enter to send (Shift+Enter = newline) +chatInput.addEventListener('keydown', e => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + send(); + } +}); + +sendBtn.addEventListener('click', send); +document.getElementById('newChatBtn').addEventListener('click', () => { + history = []; + chatMessages.innerHTML = ` + <div class="message message-assistant"> + <div class="message-bubble"> + <p class="mb-0">New chat started. Paste a recipe or ask me anything.</p> + </div> + </div>`; + chatInput.value = ''; + chatInput.style.height = 'auto'; +}); + +// File attachment +const fileInput = document.getElementById('fileInput'); +const attachBadge = document.getElementById('attachBadge'); +const attachName = document.getElementById('attachName'); +fileInput.addEventListener('change', function() { + const file = this.files[0]; + if (!file) return; + const isImage = file.type.startsWith('image/'); + const reader = new FileReader(); + reader.onload = e => { + if (isImage) { + const b64 = e.target.result.split(',')[1]; + attachment = {name: file.name, type: 'image', b64, mime: file.type}; + } else { + attachment = {name: file.name, type: 'text', text: e.target.result}; + } + attachName.textContent = file.name; + attachBadge.classList.remove('d-none'); + }; + if (isImage) reader.readAsDataURL(file); + else reader.readAsText(file); + this.value = ''; +}); +document.getElementById('clearAttach').addEventListener('click', () => { + attachment = null; + attachBadge.classList.add('d-none'); +}); + +function setLoading(on) { + busy = on; + sendBtn.disabled = on; + chatInput.disabled = on; + sendLabel.classList.toggle('d-none', on); + sendSpinner.classList.toggle('d-none', !on); +} + +function appendMessage(role, html) { + const div = document.createElement('div'); + div.className = `message message-${role}`; + div.innerHTML = `<div class="message-bubble">${html}</div>`; + chatMessages.appendChild(div); + chatMessages.scrollTop = chatMessages.scrollHeight; +} + +function appendRecipeCard(recipe) { + const div = document.createElement('div'); + div.className = 'message message-assistant'; + div.innerHTML = ` + <div class="message-bubble recipe-added-card"> + <div class="d-flex align-items-center gap-2"> + <i class="bi bi-check-circle-fill text-success fs-5"></i> + <div> + <div class="fw-semibold">${escapeHtml(recipe.name)} added!</div> + <a href="/recipes/${recipe.id}" class="small">View recipe →</a> + </div> + </div> + </div>`; + chatMessages.appendChild(div); + chatMessages.scrollTop = chatMessages.scrollHeight; +} + +function escapeHtml(s) { + return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); +} + +function mdToHtml(text) { + // Very simple markdown: **bold**, line breaks + return escapeHtml(text) + .replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>') + .replace(/\n/g, '<br>'); +} + +async function send() { + if (busy) return; + const msg = chatInput.value.trim(); + if (!msg && !attachment) return; + + const displayMsg = attachment ? `📎 ${attachment.name}${msg ? '\n' + msg : ''}` : msg; + appendMessage('user', mdToHtml(displayMsg)); + chatInput.value = ''; + chatInput.style.height = 'auto'; + charCount.textContent = ''; + const sentAttachment = attachment; + attachment = null; + attachBadge.classList.add('d-none'); + setLoading(true); + + // Thinking indicator + const thinking = document.createElement('div'); + thinking.className = 'message message-assistant thinking-msg'; + thinking.innerHTML = `<div class="message-bubble text-muted fst-italic"> + <span class="spinner-border spinner-border-sm me-2" style="width:.75rem;height:.75rem"></span>Thinking… + </div>`; + chatMessages.appendChild(thinking); + chatMessages.scrollTop = chatMessages.scrollHeight; + + try { + const payload = { message: msg, history }; + if (sentAttachment) { + if (sentAttachment.type === 'image') { + payload.image_b64 = sentAttachment.b64; + payload.image_mime = sentAttachment.mime; + payload.image_name = sentAttachment.name; + } else { + payload.file_text = sentAttachment.text; + payload.file_name = sentAttachment.name; + } + } + const res = await api('/ai/chat', { + method: 'POST', + body: JSON.stringify(payload), + }); + if (!res) return; // 401 redirect handled by api() + + const data = await res.json(); + thinking.remove(); + + if (data.error) { + appendMessage('assistant', `<span class="text-danger">${escapeHtml(data.error)}</span>`); + } else { + history = data.history; + if (data.response) appendMessage('assistant', mdToHtml(data.response)); + (data.recipes_added || []).forEach(appendRecipeCard); + } + } catch(e) { + thinking.remove(); + appendMessage('assistant', `<span class="text-danger">Network error — please try again.</span>`); + } finally { + setLoading(false); + chatInput.focus(); + } +} +</script> +{% endif %} +{% endblock %} diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..b58ef64 --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,99 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <title>{% block title %}Menu Planner{% endblock %}</title> + <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"> + <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css"> + <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}"> +</head> +<body> + +<nav class="navbar navbar-expand-lg navbar-dark sticky-top"> + <div class="container-fluid px-4"> + <a class="navbar-brand fw-bold" href="/"> + <i class="bi bi-journal-richtext me-2"></i>Menu Planner + </a> + <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navMain"> + <span class="navbar-toggler-icon"></span> + </button> + <div class="collapse navbar-collapse" id="navMain"> + <ul class="navbar-nav me-auto gap-1"> + <li class="nav-item"> + <a class="nav-link {% if request.endpoint == 'index' %}active{% endif %}" href="/"> + <i class="bi bi-house me-1"></i>Dashboard + </a> + </li> + <li class="nav-item"> + <a class="nav-link {% if request.endpoint in ['recipes','recipe_detail','add_recipe'] %}active{% endif %}" href="/recipes"> + <i class="bi bi-book me-1"></i>Recipes + </a> + </li> + <li class="nav-item"> + <a class="nav-link {% if request.endpoint == 'meal_plan' %}active{% endif %}" href="/meal-plan"> + <i class="bi bi-calendar-week me-1"></i>Meal Plan + </a> + </li> + <li class="nav-item"> + <a class="nav-link {% if request.endpoint == 'shopping_list' %}active{% endif %}" href="/shopping-list"> + <i class="bi bi-cart me-1"></i>Shopping List + </a> + </li> + </ul> + + <ul class="navbar-nav ms-auto gap-1 align-items-center"> + {% if current_user.is_authenticated %} + <li class="nav-item"> + <a class="nav-link {% if request.endpoint == 'ai_chat_page' %}active{% endif %}" href="/ai"> + <i class="bi bi-stars me-1"></i>AI Assistant + </a> + </li> + <li class="nav-item"> + <a class="nav-link {% if request.endpoint == 'add_recipe' %}active{% endif %}" href="/recipes/add"> + <i class="bi bi-plus-circle me-1"></i>Add Recipe + </a> + </li> + <li class="nav-item dropdown"> + <a class="nav-link dropdown-toggle" href="#" data-bs-toggle="dropdown"> + <i class="bi bi-person-circle me-1"></i>{{ current_user.username }} + </a> + <ul class="dropdown-menu dropdown-menu-end"> + <li><a class="dropdown-item" href="/logout"><i class="bi bi-box-arrow-right me-2"></i>Log out</a></li> + </ul> + </li> + {% else %} + <li class="nav-item"> + <a class="nav-link btn btn-sm btn-outline-light px-3 ms-1" href="/login"> + <i class="bi bi-person me-1"></i>Log in + </a> + </li> + {% endif %} + </ul> + </div> + </div> +</nav> + +<main class="container-fluid px-4 py-4"> + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} + <div class="alert alert-{{ category }} alert-dismissible fade show mb-3" role="alert"> + {{ message }} + <button type="button" class="btn-close" data-bs-dismiss="alert"></button> + </div> + {% endfor %} + {% endif %} + {% endwith %} + {% block content %}{% endblock %} +</main> + +<footer class="text-center py-3 mt-4 no-print"> + <small class="text-muted">Menu Planner — Low-carb, big flavor</small> +</footer> + +<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script> +<script src="{{ url_for('static', filename='js/main.js') }}"></script> +{% block scripts %}{% endblock %} +</body> +</html> diff --git a/app/templates/index.html b/app/templates/index.html new file mode 100644 index 0000000..c33e348 --- /dev/null +++ b/app/templates/index.html @@ -0,0 +1,116 @@ +{% extends "base.html" %} +{% block title %}Dashboard — Menu Planner{% endblock %} + +{% block content %} +<div class="d-flex align-items-center justify-content-between mb-4"> + <div> + <h1 class="h3 mb-0 fw-bold">This Week's 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 gap-2"> + <a href="/meal-plan" class="btn btn-primary"><i class="bi bi-pencil-square me-1"></i>Edit Plan</a> + <a href="/shopping-list" class="btn btn-outline-secondary"><i class="bi bi-cart me-1"></i>Shopping List</a> + </div> +</div> + +<!-- Stats row --> +<div class="row g-3 mb-4"> + <div class="col-6 col-md-3"> + <div class="stat-card"> + <div class="stat-number">{{ plan | length }}</div> + <div class="stat-label">Meals Planned</div> + </div> + </div> + <div class="col-6 col-md-3"> + <div class="stat-card"> + <div class="stat-number">{{ stat_map.get('favorited', 0) }}</div> + <div class="stat-label">Favorited Recipes</div> + </div> + </div> + <div class="col-6 col-md-3"> + <div class="stat-card"> + <div class="stat-number">{{ stat_map.get('candidate', 0) + stat_map.get('favorited', 0) }}</div> + <div class="stat-label">Active Recipes</div> + </div> + </div> + <div class="col-6 col-md-3"> + <div class="stat-card"> + <div class="stat-number">{{ 21 - (plan | length) }}</div> + <div class="stat-label">Open Slots</div> + </div> + </div> +</div> + +<!-- Weekly 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 week-table"> + <thead class="table-dark"> + <tr> + <th style="width:100px"></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="text-muted small">{{ 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">{{ mt }}</td> + {% for d in dates %} + {% set key = d.isoformat() + '_' + mt %} + <td class="plan-cell"> + {% if key in plan %} + {% set entry = plan[key] %} + <div class="plan-entry"> + <span class="cuisine-badge">{{ cuisine_emoji.get(entry.cuisine, '') }}</span> + <span class="recipe-name">{{ entry.recipe_name }}</span> + </div> + {% else %} + <span class="empty-slot">—</span> + {% endif %} + </td> + {% endfor %} + </tr> + {% endfor %} + </tbody> + </table> + </div> + </div> +</div> + +<!-- Quick links --> +<div class="row g-3"> + <div class="col-md-4"> + <a href="/recipes?status=favorited" class="quick-link-card"> + <i class="bi bi-heart-fill text-danger"></i> + <span>View Favorites</span> + <i class="bi bi-arrow-right ms-auto"></i> + </a> + </div> + <div class="col-md-4"> + <a href="/recipes?cuisine=Italian" class="quick-link-card"> + <span>🇮🇹</span> + <span>Italian Recipes</span> + <i class="bi bi-arrow-right ms-auto"></i> + </a> + </div> + <div class="col-md-4"> + <a href="/recipes?cuisine=French" class="quick-link-card"> + <span>🇫🇷</span> + <span>French Recipes</span> + <i class="bi bi-arrow-right ms-auto"></i> + </a> + </div> +</div> +{% endblock %} + +{% block scripts %} +<script> + const todayStr = '{{ today_str }}'; +</script> +{% endblock %} diff --git a/app/templates/login.html b/app/templates/login.html new file mode 100644 index 0000000..2d7254f --- /dev/null +++ b/app/templates/login.html @@ -0,0 +1,39 @@ +{% extends "base.html" %} +{% block title %}Log In — Menu Planner{% endblock %} + +{% block content %} +<div class="row justify-content-center mt-5"> + <div class="col-sm-8 col-md-5 col-lg-4"> + <div class="card shadow-sm"> + <div class="card-body p-4"> + <div class="text-center mb-4"> + <i class="bi bi-journal-richtext fs-1" style="color:var(--primary)"></i> + <h2 class="h4 fw-bold mt-2">Menu Planner</h2> + <p class="text-muted small">Log in to plan meals and manage recipes</p> + </div> + + <form method="POST" action="/login{% if request.args.get('next') %}?next={{ request.args.get('next') }}{% endif %}"> + <div class="mb-3"> + <label class="form-label fw-semibold" for="username">Username</label> + <input type="text" class="form-control" id="username" name="username" + autocomplete="username" autofocus required> + </div> + <div class="mb-4"> + <label class="form-label fw-semibold" for="password">Password</label> + <input type="password" class="form-control" id="password" name="password" + autocomplete="current-password" required> + </div> + <button type="submit" class="btn btn-primary w-100"> + <i class="bi bi-box-arrow-in-right me-2"></i>Log in + </button> + </form> + + <hr class="my-3"> + <p class="text-center text-muted small mb-0"> + <i class="bi bi-eye me-1"></i>Browsing and viewing is open to everyone. + </p> + </div> + </div> + </div> +</div> +{% endblock %} 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 %} diff --git a/app/templates/recipe_add.html b/app/templates/recipe_add.html new file mode 100644 index 0000000..f0a0538 --- /dev/null +++ b/app/templates/recipe_add.html @@ -0,0 +1,216 @@ +{% extends "base.html" %} +{% block title %}Add Recipe — Menu Planner{% endblock %} + +{% block content %} +<div class="mb-3"> + <a href="/recipes" class="btn btn-sm btn-outline-secondary"><i class="bi bi-arrow-left me-1"></i>Back to Recipes</a> +</div> + +<h1 class="h3 fw-bold mb-4">Add a New Recipe</h1> + +<form method="POST" action="/recipes/add" id="recipeForm"> + <div class="row g-4"> + + <!-- Left column: basic info + instructions --> + <div class="col-lg-7"> + + <!-- Basic info --> + <div class="card shadow-sm mb-4"> + <div class="card-header fw-semibold">Basic Information</div> + <div class="card-body"> + <div class="mb-3"> + <label class="form-label fw-semibold">Recipe Name <span class="text-danger">*</span></label> + <input type="text" name="name" class="form-control" required + value="{{ form.get('name','') }}" placeholder="e.g. Chicken Marsala"> + </div> + <div class="row g-3 mb-3"> + <div class="col-sm-6"> + <label class="form-label fw-semibold">Cuisine <span class="text-danger">*</span></label> + <input type="text" name="cuisine" class="form-control" required + list="cuisine-list" autocomplete="off" + placeholder="e.g. Italian, Mexican…" + value="{{ form.get('cuisine', '') }}"> + <datalist id="cuisine-list"> + {% for c in cuisines %}<option value="{{ c }}">{% endfor %} + </datalist> + </div> + <div class="col-sm-6"> + <label class="form-label fw-semibold">Default Servings</label> + <input type="number" name="servings" class="form-control" min="1" max="20" + value="{{ form.get('servings', 2) }}"> + <div class="form-text">Base recipe yields (shopping list scales from this)</div> + </div> + </div> + <div class="mb-0"> + <label class="form-label fw-semibold">Description</label> + <textarea name="description" class="form-control" rows="2" + placeholder="One sentence that makes this dish sound irresistible…">{{ form.get('description','') }}</textarea> + </div> + </div> + </div> + + <!-- Timing --> + <div class="card shadow-sm mb-4"> + <div class="card-header fw-semibold">Timing</div> + <div class="card-body"> + <div class="row g-3"> + <div class="col-sm-6"> + <label class="form-label fw-semibold">Prep Time (minutes)</label> + <input type="number" name="prep_time" class="form-control" min="0" + value="{{ form.get('prep_time', '') }}" placeholder="15"> + </div> + <div class="col-sm-6"> + <label class="form-label fw-semibold">Cook Time (minutes)</label> + <input type="number" name="cook_time" class="form-control" min="0" + value="{{ form.get('cook_time', '') }}" placeholder="30"> + </div> + </div> + </div> + </div> + + <!-- Instructions --> + <div class="card shadow-sm mb-4"> + <div class="card-header fw-semibold">Instructions</div> + <div class="card-body"> + <textarea name="instructions" class="form-control font-monospace" rows="10" + placeholder="1. First step. 2. Second step. 3. Third step.">{{ form.get('instructions','') }}</textarea> + <div class="form-text mt-1">Number each step: <code>1. Heat oil…</code></div> + </div> + </div> + + </div> + + <!-- Right column: nutrition + ingredients --> + <div class="col-lg-5"> + + <!-- Nutrition --> + <div class="card shadow-sm mb-4"> + <div class="card-header fw-semibold">Nutrition <span class="text-muted small fw-normal">(per serving)</span></div> + <div class="card-body"> + <div class="row g-2"> + <div class="col-6"> + <label class="form-label small fw-semibold">Calories</label> + <div class="input-group input-group-sm"> + <input type="number" name="calories_per_serving" class="form-control" + min="0" step="5" value="{{ form.get('calories_per_serving','') }}" placeholder="400"> + <span class="input-group-text">kcal</span> + </div> + </div> + <div class="col-6"> + <label class="form-label small fw-semibold">Net Carbs</label> + <div class="input-group input-group-sm"> + <input type="number" name="carbs_per_serving" class="form-control" + min="0" step="0.5" value="{{ form.get('carbs_per_serving','') }}" placeholder="8"> + <span class="input-group-text">g</span> + </div> + </div> + <div class="col-6"> + <label class="form-label small fw-semibold">Protein</label> + <div class="input-group input-group-sm"> + <input type="number" name="protein_per_serving" class="form-control" + min="0" step="0.5" value="{{ form.get('protein_per_serving','') }}" placeholder="40"> + <span class="input-group-text">g</span> + </div> + </div> + <div class="col-6"> + <label class="form-label small fw-semibold">Fat</label> + <div class="input-group input-group-sm"> + <input type="number" name="fat_per_serving" class="form-control" + min="0" step="0.5" value="{{ form.get('fat_per_serving','') }}" placeholder="20"> + <span class="input-group-text">g</span> + </div> + </div> + </div> + </div> + </div> + + <!-- Ingredients --> + <div class="card shadow-sm"> + <div class="card-header d-flex align-items-center justify-content-between fw-semibold"> + Ingredients + <button type="button" id="addIngBtn" class="btn btn-sm btn-outline-primary"> + <i class="bi bi-plus"></i> Add Row + </button> + </div> + <div class="card-body p-2"> + <table class="table table-sm mb-0" id="ingTable"> + <thead class="table-light"> + <tr> + <th style="width:70px">Qty</th> + <th style="width:85px">Unit</th> + <th>Ingredient</th> + <th style="width:120px">Category</th> + <th style="width:30px"></th> + </tr> + </thead> + <tbody id="ingBody"> + <!-- 3 starter rows --> + </tbody> + </table> + </div> + </div> + + </div> + </div> + + <div class="d-flex gap-2 mt-4 justify-content-end"> + <a href="/recipes" class="btn btn-outline-secondary">Cancel</a> + <button type="submit" class="btn btn-primary px-4"> + <i class="bi bi-check-circle me-1"></i>Save Recipe + </button> + </div> +</form> +{% endblock %} + +{% block scripts %} +<script> +const UNITS = {{ units | tojson }}; +const CATS = {{ categories | tojson }}; + +function unitOptions(selected='whole') { + return UNITS.map(u => `<option value="${u}"${u===selected?' selected':''}>${u}</option>`).join(''); +} +function catOptions(selected='Produce') { + return CATS.map(c => `<option value="${c}"${c===selected?' selected':''}>${c}</option>`).join(''); +} + +function newRow(qty='', unit='whole', name='', cat='Produce') { + const tr = document.createElement('tr'); + tr.className = 'ing-row'; + tr.innerHTML = ` + <td><input type="number" name="ing_qty" class="form-control form-control-sm" min="0" step="0.25" value="${qty}" placeholder="1"></td> + <td><select name="ing_unit" class="form-select form-select-sm">${unitOptions(unit)}</select></td> + <td><input type="text" name="ing_name" class="form-control form-control-sm" value="${name}" placeholder="Ingredient…"></td> + <td><select name="ing_category" class="form-select form-select-sm">${catOptions(cat)}</select></td> + <td><button type="button" class="btn btn-sm btn-link text-danger p-0 remove-row" title="Remove"><i class="bi bi-x-lg"></i></button></td> + `; + return tr; +} + +const body = document.getElementById('ingBody'); + +// Seed 4 empty rows +for (let i = 0; i < 4; i++) body.appendChild(newRow()); + +document.getElementById('addIngBtn').addEventListener('click', () => { + body.appendChild(newRow()); + body.lastElementChild.querySelector('input[name="ing_name"]').focus(); +}); + +body.addEventListener('click', e => { + const btn = e.target.closest('.remove-row'); + if (btn) { + const rows = body.querySelectorAll('.ing-row'); + if (rows.length > 1) btn.closest('tr').remove(); + } +}); + +// Don't submit empty ingredient rows +document.getElementById('recipeForm').addEventListener('submit', function() { + body.querySelectorAll('.ing-row').forEach(row => { + const name = row.querySelector('input[name="ing_name"]').value.trim(); + if (!name) row.querySelectorAll('input, select').forEach(el => el.disabled = true); + }); +}); +</script> +{% endblock %} diff --git a/app/templates/recipe_detail.html b/app/templates/recipe_detail.html new file mode 100644 index 0000000..65aa0b0 --- /dev/null +++ b/app/templates/recipe_detail.html @@ -0,0 +1,290 @@ +{% extends "base.html" %} +{% block title %}{{ recipe.name }} — Menu Planner{% endblock %} + +{% block content %} +<div class="mb-3 d-flex gap-2"> + <a href="/recipes" class="btn btn-sm btn-outline-secondary"><i class="bi bi-arrow-left me-1"></i>Back to Recipes</a> + <button onclick="window.print()" class="btn btn-sm btn-outline-secondary"><i class="bi bi-printer me-1"></i>Print</button> + <button id="shareBtn" class="btn btn-sm btn-outline-secondary"><i class="bi bi-link-45deg me-1"></i>Copy Link</button> +</div> + +<div class="row g-4"> + <!-- Left: recipe info --> + <div class="col-lg-8"> + <div class="card shadow-sm"> + <div class="card-body"> + <div class="d-flex align-items-start justify-content-between mb-3"> + <div> + <span class="cuisine-pill mb-2 d-inline-block">{{ cuisine_emoji.get(recipe.cuisine, '') }} {{ recipe.cuisine }}</span> + <h1 class="h2 fw-bold mb-1">{{ recipe.name }}</h1> + <p class="text-muted">{{ recipe.description }}</p> + {% if recipe.added_by %} + <p class="text-muted small mb-0"><i class="bi bi-person me-1"></i>Added by {{ recipe.added_by }}</p> + {% endif %} + </div> + <div class="ms-3 text-center"> + {% if recipe.status == 'favorited' %} + <i class="bi bi-heart-fill text-danger fs-3"></i><br> + <small class="text-muted">Favorited</small> + {% endif %} + </div> + </div> + + <!-- Nutrition + time badges --> + <div class="d-flex flex-wrap gap-2 mb-4"> + <span class="info-badge bg-warning-subtle text-warning-emphasis"> + <i class="bi bi-lightning-charge-fill"></i> {{ recipe.calories_per_serving|int }} cal / serving + </span> + <span class="info-badge bg-info-subtle text-info-emphasis"> + <i class="bi bi-circle-half"></i> {{ recipe.carbs_per_serving|int }}g net carbs + </span> + {% if recipe.protein_per_serving %} + <span class="info-badge bg-success-subtle text-success-emphasis"> + <i class="bi bi-droplet-fill"></i> {{ recipe.protein_per_serving|int }}g protein + </span> + {% endif %} + {% if recipe.fat_per_serving %} + <span class="info-badge bg-secondary-subtle text-secondary-emphasis"> + <i class="bi bi-droplet"></i> {{ recipe.fat_per_serving|int }}g fat + </span> + {% endif %} + <span class="info-badge bg-light text-dark border"> + <i class="bi bi-clock"></i> Prep: {{ recipe.prep_time }} min + </span> + <span class="info-badge bg-light text-dark border"> + <i class="bi bi-fire"></i> Cook: {{ recipe.cook_time }} min + </span> + <span class="info-badge bg-light text-dark border"> + <i class="bi bi-people"></i> {{ recipe.servings }} servings + </span> + </div> + + <!-- Rating --> + <div class="mb-4 d-flex align-items-center gap-2"> + <span class="text-muted small">Your rating:</span> + <span class="star-rating" id="starRating" data-rating="{{ recipe.rating or 0 }}" {% if not current_user.is_authenticated %}title="Log in to rate"{% endif %}> + {% for i in range(1, 6) %} + <i class="bi bi-star{% if recipe.rating and recipe.rating >= i %}-fill{% endif %} star" + data-value="{{ i }}" {% if current_user.is_authenticated %}style="cursor:pointer"{% endif %}></i> + {% endfor %} + </span> + {% if recipe.rating %}<span class="text-muted small">({{ recipe.rating }}/5)</span>{% endif %} + </div> + + <!-- Ingredients --> + <h4 class="fw-semibold mb-3"><i class="bi bi-basket me-2"></i>Ingredients</h4> + {% set categories = ingredients | map(attribute='category') | list | unique | list %} + {% for cat in ['Meat & Poultry','Seafood','Dairy & Eggs','Produce','Pantry','Spices & Herbs'] %} + {% set cat_ings = ingredients | selectattr('category', 'equalto', cat) | list %} + {% if cat_ings %} + <div class="mb-3"> + <h6 class="text-muted text-uppercase small fw-bold mb-2">{{ cat }}</h6> + <ul class="ingredient-list"> + {% for ing in cat_ings %} + <li> + <span class="ing-qty"> + {% if ing.quantity == ing.quantity|int %}{{ ing.quantity|int }}{% else %}{{ ing.quantity }}{% endif %} + {{ ing.unit }} + </span> + {{ ing.name }} + </li> + {% endfor %} + </ul> + </div> + {% endif %} + {% endfor %} + + <!-- Instructions --> + <h4 class="fw-semibold mt-4 mb-3"><i class="bi bi-list-ol me-2"></i>Instructions</h4> + <div class="instructions-block"> + {% for line in recipe.instructions.strip().split('\n') %} + {% if line.strip() %} + <div class="instruction-step">{{ line.strip() }}</div> + {% endif %} + {% endfor %} + </div> + </div> + </div> + + <!-- Comments --> + <div class="card shadow-sm mt-4" id="comments"> + <div class="card-header fw-semibold"><i class="bi bi-chat-left-text me-2"></i>Comments + {% if comments %}<span class="badge bg-secondary ms-1">{{ comments|length }}</span>{% endif %} + </div> + <div class="card-body"> + {% for c in comments %} + <div class="mb-3 pb-3 {% if not loop.last %}border-bottom{% endif %}"> + <div class="d-flex justify-content-between align-items-start"> + <div> + <span class="fw-semibold">{{ c.username }}</span> + <span class="text-muted small ms-2">{{ c.created_at[:16].replace('T',' ') }}</span> + </div> + {% if current_user.is_authenticated and current_user.username == c.username %} + <form method="POST" action="/recipes/{{ recipe.id }}/comments/{{ c.id }}/delete" class="no-print"> + <button type="submit" class="btn btn-sm btn-link text-danger p-0" title="Delete"> + <i class="bi bi-trash"></i> + </button> + </form> + {% endif %} + </div> + <p class="mb-0 mt-1">{{ c.body }}</p> + </div> + {% else %} + <p class="text-muted mb-3 small">No comments yet.</p> + {% endfor %} + + {% if current_user.is_authenticated %} + <form method="POST" action="/recipes/{{ recipe.id }}/comments" class="no-print"> + <div class="mb-2"> + <textarea name="body" class="form-control" rows="2" placeholder="Add a comment…" required></textarea> + </div> + <button type="submit" class="btn btn-sm btn-primary">Post Comment</button> + </form> + {% else %} + <p class="text-muted small mb-0"><a href="/login">Log in</a> to leave a comment.</p> + {% endif %} + </div> + </div> + </div> + + <!-- Right: actions --> + <div class="col-lg-4 no-print"> + {% if current_user.is_authenticated %} + <div class="card shadow-sm mb-3"> + <div class="card-body"> + <h5 class="fw-semibold mb-3">Add to Meal Plan</h5> + <div class="mb-2"> + <label class="form-label small text-muted">Date</label> + <input type="date" id="mealDate" class="form-control"> + </div> + <div class="row g-2 mb-3"> + <div class="col-7"> + <label class="form-label small text-muted">Meal</label> + <select id="mealType" class="form-select"> + <option value="breakfast">Breakfast</option> + <option value="lunch">Lunch</option> + <option value="dinner" selected>Dinner</option> + </select> + </div> + <div class="col-5"> + <label class="form-label small text-muted">People</label> + <input type="number" id="mealServings" class="form-control" min="1" max="20" value="2"> + </div> + </div> + <button id="addToMealPlan" class="btn btn-primary w-100"> + <i class="bi bi-plus-circle me-1"></i>Add to Plan + </button> + <div id="addResult" class="mt-2 d-none"></div> + </div> + </div> + + <div class="card shadow-sm"> + <div class="card-body"> + <h5 class="fw-semibold mb-3">Recipe Status</h5> + <div class="d-flex flex-column gap-2"> + <button class="btn {% if recipe.status=='favorited' %}btn-danger{% else %}btn-outline-danger{% endif %} status-action" data-status="favorited"> + <i class="bi bi-heart{% if recipe.status=='favorited' %}-fill{% endif %} me-2"></i> + {% if recipe.status=='favorited' %}Favorited{% else %}Add to Favorites{% endif %} + </button> + <button class="btn {% if recipe.status=='candidate' %}btn-success{% else %}btn-outline-success{% endif %} status-action" data-status="candidate"> + <i class="bi bi-check-circle{% if recipe.status=='candidate' %}-fill{% endif %} me-2"></i> + {% if recipe.status=='candidate' %}Candidate{% else %}Mark as Candidate{% endif %} + </button> + <button class="btn {% if recipe.status=='ignored' %}btn-secondary{% else %}btn-outline-secondary{% endif %} status-action" data-status="ignored"> + <i class="bi bi-eye-slash{% if recipe.status=='ignored' %}-fill{% endif %} me-2"></i> + {% if recipe.status=='ignored' %}Ignored{% else %}Ignore Recipe{% endif %} + </button> + </div> + </div> + </div> + {% else %} + <div class="card shadow-sm"> + <div class="card-body text-center text-muted py-4"> + <i class="bi bi-lock fs-2 mb-2 d-block"></i> + <p class="mb-2 small">Log in to add this to your meal plan or update its status.</p> + <a href="/login" class="btn btn-primary btn-sm">Log in</a> + </div> + </div> + {% endif %} + </div> +</div> +{% endblock %} + +{% block scripts %} +<script> +const recipeId = {{ recipe.id }}; + +// Default date to today +document.getElementById('mealDate').value = new Date().toISOString().split('T')[0]; + +document.getElementById('addToMealPlan').addEventListener('click', async () => { + const mealDate = document.getElementById('mealDate').value; + const mealType = document.getElementById('mealType').value; + const servings = parseInt(document.getElementById('mealServings').value) || 2; + if (!mealDate) return; + const res = await api('/meal-plan/add', { + method: 'POST', + body: JSON.stringify({date: mealDate, meal_type: mealType, recipe_id: recipeId, servings}), + }); + if (!res) return; + const data = await res.json(); + const el = document.getElementById('addResult'); + el.classList.remove('d-none','alert-danger'); + if (data.success) { + el.className = 'mt-2 alert alert-success py-2 small'; + el.textContent = `Added to ${mealType} on ${mealDate} for ${servings} people`; + } else { + el.className = 'mt-2 alert alert-danger py-2 small'; + el.textContent = data.error || 'Error adding to plan'; + } +}); + +document.querySelectorAll('.status-action').forEach(btn => { + btn.addEventListener('click', async function() { + const res = await api(`/recipes/${recipeId}/status`, { + method: 'POST', + body: JSON.stringify({status: this.dataset.status}), + }); + if (res && (await res.json()).success) location.reload(); + }); +}); + +// Share / copy link +document.getElementById('shareBtn').addEventListener('click', function() { + navigator.clipboard.writeText(window.location.href).then(() => { + this.innerHTML = '<i class="bi bi-check2 me-1"></i>Copied!'; + setTimeout(() => { this.innerHTML = '<i class="bi bi-link-45deg me-1"></i>Copy Link'; }, 2000); + }); +}); + +// Star rating +const starWidget = document.getElementById('starRating'); +if (starWidget) { + const stars = starWidget.querySelectorAll('.star'); + const isAuth = {{ 'true' if current_user.is_authenticated else 'false' }}; + if (isAuth) { + stars.forEach(star => { + star.addEventListener('mouseover', function() { + const val = parseInt(this.dataset.value); + stars.forEach(s => { + s.className = 'bi star ' + (parseInt(s.dataset.value) <= val ? 'bi-star-fill' : 'bi-star'); + }); + }); + star.addEventListener('mouseout', function() { + const cur = parseInt(starWidget.dataset.rating) || 0; + stars.forEach(s => { + s.className = 'bi star ' + (parseInt(s.dataset.value) <= cur ? 'bi-star-fill' : 'bi-star'); + }); + }); + star.addEventListener('click', async function() { + const val = parseInt(this.dataset.value); + const res = await api(`/recipes/${recipeId}/rate`, { + method: 'POST', body: JSON.stringify({rating: val}), + }); + if (res) location.reload(); + }); + }); + } +} +</script> +{% endblock %} diff --git a/app/templates/recipes.html b/app/templates/recipes.html new file mode 100644 index 0000000..92a13bb --- /dev/null +++ b/app/templates/recipes.html @@ -0,0 +1,121 @@ +{% extends "base.html" %} +{% block title %}Recipes — Menu Planner{% endblock %} + +{% block content %} +<div class="d-flex align-items-center justify-content-between mb-3"> + <h1 class="h3 fw-bold mb-0">Recipe Library</h1> + <span class="badge bg-secondary fs-6">{{ recipes | length }} recipes</span> +</div> + +<!-- Filters --> +<div class="card shadow-sm mb-4"> + <div class="card-body"> + <form method="GET" class="row g-2 align-items-end"> + <div class="col-md-5"> + <input type="text" name="search" class="form-control" placeholder="Search recipes…" value="{{ search }}"> + </div> + <div class="col-md-3"> + <select name="cuisine" class="form-select"> + <option value="all" {% if cuisine=='all' %}selected{% endif %}>All Cuisines</option> + {% for c in cuisines %} + <option value="{{ c }}" {% if cuisine==c %}selected{% endif %}>{{ cuisine_emoji[c] }} {{ c }}</option> + {% endfor %} + </select> + </div> + <div class="col-md-2"> + <select name="status" class="form-select"> + <option value="active" {% if status=='active' %}selected{% endif %}>Active</option> + <option value="favorited" {% if status=='favorited' %}selected{% endif %}>Favorites</option> + <option value="candidate" {% if status=='candidate' %}selected{% endif %}>Candidates</option> + <option value="ignored" {% if status=='ignored' %}selected{% endif %}>Ignored</option> + <option value="all" {% if status=='all' %}selected{% endif %}>All</option> + </select> + </div> + <div class="col-md-2"> + <select name="sort" class="form-select"> + <option value="name" {% if sort=='name' %}selected{% endif %}>Sort: Name</option> + <option value="total" {% if sort=='total' %}selected{% endif %}>Sort: Total Time</option> + <option value="prep" {% if sort=='prep' %}selected{% endif %}>Sort: Prep Time</option> + <option value="cook" {% if sort=='cook' %}selected{% endif %}>Sort: Cook Time</option> + <option value="rating" {% if sort=='rating' %}selected{% endif %}>Sort: Rating</option> + </select> + </div> + <div class="col-md-2"> + <button type="submit" class="btn btn-primary w-100"><i class="bi bi-funnel me-1"></i>Filter</button> + </div> + </form> + </div> +</div> + +<!-- Cuisine tabs shortcut --> +<div class="mb-3 d-flex gap-2 flex-wrap"> + <a href="/recipes?cuisine=all&status={{ status }}" class="btn btn-sm {% if cuisine=='all' %}btn-dark{% else %}btn-outline-secondary{% endif %}">All</a> + {% for c in cuisines %} + <a href="/recipes?cuisine={{ c }}&status={{ status }}" class="btn btn-sm {% if cuisine==c %}btn-dark{% else %}btn-outline-secondary{% endif %}"> + {{ cuisine_emoji[c] }} {{ c }} + </a> + {% endfor %} +</div> + +{% if recipes %} +<div class="row g-3"> + {% for r in recipes %} + <div class="col-sm-6 col-lg-4 col-xl-3"> + <div class="recipe-card card h-100 shadow-sm {% if r.status == 'ignored' %}opacity-50{% endif %}"> + <div class="card-body d-flex flex-column"> + <div class="d-flex align-items-start justify-content-between mb-2"> + <span class="cuisine-pill">{{ cuisine_emoji.get(r.cuisine, '') }} {{ r.cuisine }}</span> + {% if r.status == 'favorited' %} + <i class="bi bi-heart-fill text-danger"></i> + {% elif r.status == 'ignored' %} + <i class="bi bi-eye-slash text-muted"></i> + {% endif %} + </div> + <h5 class="card-title mb-1">{{ r.name }}</h5> + <p class="card-text text-muted small flex-grow-1">{{ r.description[:100] }}{% if r.description|length > 100 %}…{% endif %}</p> + {% if r.added_by %}<p class="text-muted small mb-1"><i class="bi bi-person me-1"></i>{{ r.added_by }}</p>{% endif %} + {% if r.rating %} + <div class="mb-1" style="color:#f59e0b;font-size:0.85rem"> + {% for i in range(1,6) %}<i class="bi bi-star{% if r.rating >= i %}-fill{% endif %}"></i>{% endfor %} + </div> + {% endif %} + <div class="nutrition-row mt-2 mb-3"> + <span class="nutri-badge"><i class="bi bi-lightning-charge"></i> {{ r.calories_per_serving|int }} cal</span> + <span class="nutri-badge"><i class="bi bi-circle-half"></i> {{ r.carbs_per_serving|int }}g carbs</span> + <span class="nutri-badge"><i class="bi bi-clock"></i> {{ r.prep_time + r.cook_time }} min</span> + </div> + <div class="d-flex gap-1 mt-auto"> + <a href="/recipes/{{ r.id }}" class="btn btn-sm btn-primary flex-grow-1">View</a> + <button class="btn btn-sm btn-outline-danger status-btn" data-id="{{ r.id }}" data-status="favorited" title="Favorite"> + <i class="bi bi-heart{% if r.status=='favorited' %}-fill{% endif %}"></i> + </button> + <button class="btn btn-sm btn-outline-secondary status-btn" data-id="{{ r.id }}" data-status="{% if r.status=='ignored' %}candidate{% else %}ignored{% endif %}" title="{% if r.status=='ignored' %}Restore{% else %}Ignore{% endif %}"> + <i class="bi bi-{% if r.status=='ignored' %}eye{% else %}eye-slash{% endif %}"></i> + </button> + </div> + </div> + </div> + </div> + {% endfor %} +</div> +{% else %} +<div class="text-center py-5"> + <i class="bi bi-search fs-1 text-muted"></i> + <p class="text-muted mt-2">No recipes found. Try adjusting the filters.</p> +</div> +{% endif %} +{% endblock %} + +{% block scripts %} +<script> +document.querySelectorAll('.status-btn').forEach(btn => { + btn.addEventListener('click', async function() { + const res = await api(`/recipes/${this.dataset.id}/status`, { + method: 'POST', + body: JSON.stringify({status: this.dataset.status}), + }); + if (res && (await res.json()).success) location.reload(); + }); +}); +</script> +{% endblock %} diff --git a/app/templates/shopping_list.html b/app/templates/shopping_list.html new file mode 100644 index 0000000..e6cfaaf --- /dev/null +++ b/app/templates/shopping_list.html @@ -0,0 +1,167 @@ +{% extends "base.html" %} +{% block title %}Shopping List — Menu Planner{% endblock %} + +{% block content %} +<div class="d-flex align-items-center justify-content-between mb-4 no-print"> + <div> + <h1 class="h3 fw-bold mb-0">Shopping List</h1> + <p class="text-muted mb-0"> + Week of {{ week_start.strftime('%B %d, %Y') }} + {% if total %} · {{ checked }}/{{ total }} checked{% endif %} + </p> + </div> + <div class="d-flex gap-2 align-items-center"> + <a href="/shopping-list?week={{ prev_week }}" class="btn btn-outline-secondary btn-sm"><i class="bi bi-chevron-left"></i></a> + <a href="/shopping-list" class="btn btn-outline-secondary btn-sm">This Week</a> + <a href="/shopping-list?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 ms-2"> + <i class="bi bi-printer me-1"></i>Print + </button> + {% if current_user.is_authenticated %} + <button id="regenerateBtn" class="btn btn-success btn-sm"> + <i class="bi bi-arrow-clockwise me-1"></i>Regenerate + </button> + {% if total %} + <button id="clearBtn" class="btn btn-outline-danger btn-sm"> + <i class="bi bi-trash me-1"></i>Clear + </button> + {% endif %} + {% endif %}{# end is_authenticated #} + </div> +</div> + +{% if categories %} + +<!-- Progress bar --> +{% if total %} +<div class="mb-4 no-print"> + <div class="d-flex justify-content-between small text-muted mb-1"> + <span>{{ checked }} of {{ total }} items collected</span> + <span>{{ (checked / total * 100)|int }}%</span> + </div> + <div class="progress" style="height:8px"> + <div class="progress-bar bg-success" style="width: {{ (checked / total * 100)|int }}%"></div> + </div> +</div> +{% endif %} + +<div class="row g-4"> + {% for cat_name, items in categories %} + <div class="col-md-6 col-xl-4"> + <div class="card shadow-sm h-100"> + <div class="card-header fw-semibold d-flex align-items-center gap-2"> + {% if cat_name == 'Meat & Poultry' %}<i class="bi bi-egg-fried text-warning"></i> + {% elif cat_name == 'Seafood' %}<i class="bi bi-water text-info"></i> + {% elif cat_name == 'Dairy & Eggs' %}<i class="bi bi-cup-straw text-warning"></i> + {% elif cat_name == 'Produce' %}<i class="bi bi-flower1 text-success"></i> + {% elif cat_name == 'Pantry' %}<i class="bi bi-archive text-secondary"></i> + {% elif cat_name == 'Spices & Herbs' %}<i class="bi bi-asterisk text-danger"></i> + {% else %}<i class="bi bi-box text-muted"></i>{% endif %} + {{ cat_name }} + <span class="badge bg-secondary ms-auto">{{ items|length }}</span> + </div> + <ul class="list-group list-group-flush"> + {% for item in items %} + <li class="list-group-item shopping-item {% if item.checked %}checked{% endif %}" data-id="{{ item.id }}"> + <div class="d-flex align-items-center gap-2"> + <input type="checkbox" class="form-check-input flex-shrink-0 shop-check" id="item{{ item.id }}" + {% if item.checked %}checked{% endif %}> + <label for="item{{ item.id }}" class="mb-0 flex-grow-1 check-label"> + <span class="ing-qty fw-semibold me-1"> + {% if item.quantity == item.quantity|int %}{{ item.quantity|int }}{% else %}{{ item.quantity }}{% endif %} + {{ item.unit }} + </span> + {{ item.ingredient_name }} + </label> + {% if item.recipe_sources %} + <span class="text-muted small d-none d-sm-inline source-hint" title="{{ item.recipe_sources }}"> + <i class="bi bi-book"></i> + </span> + {% endif %} + </div> + {% if item.recipe_sources %} + <div class="small text-muted mt-1 ps-4 source-text">{{ item.recipe_sources }}</div> + {% endif %} + </li> + {% endfor %} + </ul> + </div> + </div> + {% endfor %} +</div> + +{% else %} +<div class="text-center py-5"> + <i class="bi bi-cart-x fs-1 text-muted"></i> + <p class="text-muted mt-3">No shopping list yet for this week.</p> + <p class="text-muted small">Add meals to your <a href="/meal-plan">meal plan</a> first, then click Regenerate.</p> + <button id="regenerateBtn2" class="btn btn-success mt-2"> + <i class="bi bi-arrow-clockwise me-1"></i>Generate from Meal Plan + </button> +</div> +{% endif %} + +<!-- 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> +{% endblock %} + +{% block scripts %} +<script> +const weekStart = '{{ week_start.isoformat() }}'; +const toast = new bootstrap.Toast(document.getElementById('toast'), {delay: 2000}); + +// Checkbox toggle +document.querySelectorAll('.shop-check').forEach(cb => { + cb.addEventListener('change', async function() { + const item = this.closest('.shopping-item'); + const res = await api(`/shopping-list/${item.dataset.id}/check`, { + method: 'POST', body: JSON.stringify({checked: this.checked}), + }); + if (res) { + item.classList.toggle('checked', this.checked); + updateProgress(); + } + }); +}); + +function updateProgress() { + const all = document.querySelectorAll('.shop-check').length; + const done = document.querySelectorAll('.shop-check:checked').length; + const bar = document.querySelector('.progress-bar'); + if (bar) bar.style.width = all ? `${Math.round(done/all*100)}%` : '0%'; +} + +async function regenerate() { + const res = await api('/shopping-list/generate', { + method: 'POST', body: JSON.stringify({week: weekStart}), + }); + if (!res) return; + const data = await res.json(); + if (data.success) { + location.reload(); + } else { + document.getElementById('toastBody').textContent = data.error || 'No meals planned for this week.'; + document.getElementById('toast').className = 'toast align-items-center text-bg-danger border-0'; + toast.show(); + } +} + +document.getElementById('regenerateBtn')?.addEventListener('click', regenerate); +document.getElementById('regenerateBtn2')?.addEventListener('click', regenerate); + +document.getElementById('clearBtn')?.addEventListener('click', async () => { + if (!confirm('Clear the shopping list for this week?')) return; + const res = await api('/shopping-list/clear', { + method: 'POST', body: JSON.stringify({week: weekStart}), + }); + if (res) location.reload(); +}); +</script> +{% endblock %} |
