summaryrefslogtreecommitdiffstats
path: root/app/templates
diff options
context:
space:
mode:
Diffstat (limited to 'app/templates')
-rw-r--r--app/templates/ai_chat.html241
-rw-r--r--app/templates/base.html99
-rw-r--r--app/templates/index.html116
-rw-r--r--app/templates/login.html39
-rw-r--r--app/templates/meal_plan.html265
-rw-r--r--app/templates/recipe_add.html216
-rw-r--r--app/templates/recipe_detail.html290
-rw-r--r--app/templates/recipes.html121
-rw-r--r--app/templates/shopping_list.html167
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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
+}
+
+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 &mdash; 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} &nbsp;·&nbsp; ${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.&#10;2. Second step.&#10;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 %}&nbsp;·&nbsp; {{ 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 %}