summaryrefslogtreecommitdiff
path: root/static
diff options
context:
space:
mode:
authorTyler Hoang <tyler@tylerhoang.xyz>2026-05-08 03:24:36 -0700
committerTyler Hoang <tyler@tylerhoang.xyz>2026-05-08 03:24:36 -0700
commit3de7c5eed5ba262abf0d746211e33800db6d66df (patch)
tree6fddb5381fb178423eac34894add5b611babe300 /static
parentf361e7599d9a11ad3397b7b6bffee151ab9bdde9 (diff)
Add recipe suggestions, chat tab, and major workflow improvements
- Replace 7-day grid menu with browsable recipe suggestion cards (swap, remove, make this) - Add Chat tab: conversational AI with full pantry/menu/grocery context - Grocery list works without a menu (pantry-only mode) - Individual grocery checkboxes auto-add items to pantry - Swap modal with optional preference input - User notes textarea on menu and grocery generation - Clear button for menu and grocery list - LLM notes/summary displayed after generation, persisted to DB - Favicon linked in HTML - Category dropdown styled for dark theme - System prompt configurable via SYSTEM_PROMPT in .env - Fix startup error (ollama_timeout default), DB migration for menu_plans.notes - Simplify: batch N+1 queries, extract _current_monday(), merge chat sync fns, asyncio.get_running_loop(), fix currentGroceryId bug, cap chat history Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'static')
-rw-r--r--static/app.js335
-rw-r--r--static/favicon.svg34
-rw-r--r--static/index.html35
-rw-r--r--static/style.css286
4 files changed, 557 insertions, 133 deletions
diff --git a/static/app.js b/static/app.js
index 678b59b..8f39fb8 100644
--- a/static/app.js
+++ b/static/app.js
@@ -9,8 +9,11 @@ const state = {
ollamaStatus: 'unknown',
currentGroceryId: null,
editingIngredientId: null,
+ chatHistory: [],
};
+let pendingSwap = null;
+
// ── API Utilities ──────────────────────────────────────────────────────────
async function api(method, path, body = null) {
const opts = { method, headers: { 'Content-Type': 'application/json' } };
@@ -103,6 +106,7 @@ function switchTab(tabName) {
else if (tabName === 'meals') loadMeals();
else if (tabName === 'menu') loadMenu();
else if (tabName === 'grocery') loadGrocery();
+ else if (tabName === 'chat') loadChat();
}
// ── Pantry Tab ─────────────────────────────────────────────────────────────
@@ -380,92 +384,139 @@ async function loadMenu() {
});
if (!res) {
- document.getElementById('menu-grid').innerHTML = '';
+ document.getElementById('recipe-list').innerHTML = '';
document.getElementById('menu-empty').classList.remove('hidden');
document.getElementById('menu-notes').classList.add('hidden');
+ document.getElementById('btn-clear-menu').classList.add('hidden');
return;
}
- state.currentMenu = res;
+ let planIds = [];
+ try {
+ const parsed = typeof res.plan === 'string' ? JSON.parse(res.plan) : res.plan;
+ planIds = Array.isArray(parsed) ? parsed : [];
+ } catch { planIds = []; }
+
+ const allRecipes = await api('GET', '/recipes');
+ const recipeMap = Object.fromEntries(allRecipes.map(r => [r.id, r]));
+ const planRecipes = planIds.map(id => recipeMap[id]).filter(Boolean);
+
+ state.currentMenu = { ...res, recipes: planRecipes };
document.getElementById('menu-empty').classList.add('hidden');
+ document.getElementById('btn-clear-menu').classList.remove('hidden');
if (res.notes) {
document.getElementById('menu-notes').textContent = res.notes;
document.getElementById('menu-notes').classList.remove('hidden');
+ } else {
+ document.getElementById('menu-notes').classList.add('hidden');
}
- renderMenuGrid();
+ renderRecipeList(planRecipes);
} catch (err) {
showToast(err.message, 'error');
}
}
-function renderMenuGrid() {
- const grid = document.getElementById('menu-grid');
- if (!state.currentMenu) return;
-
- const days = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
- const mealTypes = ['breakfast', 'lunch', 'dinner'];
-
- let plan = state.currentMenu.plan;
- if (typeof plan === 'string') {
- try {
- plan = JSON.parse(plan);
- } catch {
- plan = {};
- }
+function renderRecipeList(recipes) {
+ const list = document.getElementById('recipe-list');
+ if (!recipes || recipes.length === 0) {
+ list.innerHTML = '';
+ return;
}
- let weekPlan = state.currentMenu.week_plan;
- if (typeof weekPlan === 'string') {
- try {
- weekPlan = JSON.parse(weekPlan);
- } catch {
- weekPlan = {};
+ list.innerHTML = recipes.map(recipe => {
+ let ingredients = recipe.ingredients || [];
+ if (typeof ingredients === 'string') {
+ try { ingredients = JSON.parse(ingredients); } catch { ingredients = []; }
}
- }
- // Header row
- let html = '<div class="menu-grid-header"></div>';
- days.forEach(day => {
- html += `<div class="menu-grid-header">${day}</div>`;
- });
+ const ingredientText = Array.isArray(ingredients)
+ ? ingredients.map(i => {
+ if (typeof i === 'string') return i;
+ return [i.quantity, i.unit, i.name].filter(Boolean).join(' ');
+ }).join(', ')
+ : '';
- // Meal type rows
- mealTypes.forEach(mealType => {
- days.forEach(day => {
- const dayKey = day.toLowerCase();
- const dayPlan = plan[dayKey] || {};
- const recipeId = dayPlan[mealType];
+ const mealType = recipe.meal_type || 'dinner';
+ const typeDisplay = mealType.charAt(0).toUpperCase() + mealType.slice(1);
+ const time = recipe.time_minutes || recipe.estimated_time_minutes;
+ const serves = recipe.serves || recipe.servings;
+ const meta = [time ? `${time} min` : '', serves ? `serves ${serves}` : ''].filter(Boolean).join(' · ');
- let recipeName = '—';
- let recipeTime = '';
- let repeatWarning = false;
+ return `
+ <div class="recipe-card">
+ <div class="recipe-card-header">
+ <div class="recipe-card-title">${recipe.name}</div>
+ <span class="recipe-type-badge ${mealType}">${typeDisplay}</span>
+ </div>
+ ${meta ? `<div class="recipe-card-meta">${meta}</div>` : ''}
+ ${ingredientText ? `<div class="recipe-card-ingredients"><strong>Ingredients:</strong> ${ingredientText}</div>` : ''}
+ ${recipe.instructions ? `<div class="recipe-card-instructions">${recipe.instructions}</div>` : ''}
+ <div class="recipe-card-actions">
+ <button class="btn btn-sm btn-primary" onclick="makeThisMeal('${recipe.name.replace(/'/g, "\\'")}', '${mealType}')">Make This</button>
+ <button class="btn btn-sm btn-secondary" onclick="swapRecipe(${recipe.id}, '${mealType}', this)">Swap</button>
+ <button class="btn btn-sm btn-danger" onclick="removeRecipe(${recipe.id}, this)">Remove</button>
+ </div>
+ </div>
+ `;
+ }).join('');
+}
- if (weekPlan && weekPlan[dayKey] && weekPlan[dayKey][mealType]) {
- const recipe = weekPlan[dayKey][mealType];
- recipeName = recipe.name || recipe.recipe_name || '—';
- recipeTime = recipe.time_minutes ? `${recipe.time_minutes} min` : '';
- repeatWarning = recipe.repeat_warning || false;
- }
+async function removeRecipe(recipeId, btn) {
+ btn.disabled = true;
+ try {
+ await api('DELETE', `/menus/current/recipes/${recipeId}`, null);
+ state.currentMenu.recipes = state.currentMenu.recipes.filter(r => r.id !== recipeId);
+ renderRecipeList(state.currentMenu.recipes);
+ if (state.currentMenu.recipes.length === 0) {
+ document.getElementById('menu-empty').classList.remove('hidden');
+ document.getElementById('btn-clear-menu').classList.add('hidden');
+ }
+ } catch (err) {
+ showToast(err.message, 'error');
+ btn.disabled = false;
+ }
+}
- const typeDisplay = mealType.charAt(0).toUpperCase() + mealType.slice(1);
- const warningClass = repeatWarning ? 'repeat-warning' : '';
+function swapRecipe(recipeId, mealType, btn) {
+ pendingSwap = { recipeId, mealType, btn };
+ const recipe = state.currentMenu.recipes.find(r => r.id === recipeId);
+ document.getElementById('swap-modal-subtitle').textContent = recipe ? `Replacing: ${recipe.name}` : '';
+ document.getElementById('swap-notes').value = '';
+ document.getElementById('swap-modal-overlay').classList.remove('hidden');
+ document.getElementById('swap-notes').focus();
+}
- html += `
- <div class="menu-grid-cell ${warningClass}">
- <div>
- <div class="menu-meal-type">${typeDisplay}</div>
- <div class="menu-meal-name">${recipeName}</div>
- ${recipeTime ? `<div class="menu-time">${recipeTime}</div>` : ''}
- </div>
- ${recipeName !== '—' ? `<button class="btn btn-sm btn-primary" onclick="makeThisMeal('${recipeName.replace(/'/g, "\\'")}', '${mealType}')">Make This</button>` : ''}
- </div>
- `;
- });
- });
+function cancelSwap() {
+ document.getElementById('swap-modal-overlay').classList.add('hidden');
+ pendingSwap = null;
+}
- grid.innerHTML = html;
+async function confirmSwap() {
+ if (!pendingSwap) return;
+ const { recipeId, btn } = pendingSwap;
+ const userNotes = document.getElementById('swap-notes').value.trim();
+
+ document.getElementById('swap-modal-overlay').classList.add('hidden');
+ btn.disabled = true;
+ btn.textContent = '...';
+ pendingSwap = null;
+
+ try {
+ const res = await api('POST', `/menus/current/recipes/${recipeId}/swap`,
+ userNotes ? { user_notes: userNotes } : {}
+ );
+ const newRecipe = res.recipe;
+ state.currentMenu.recipes = state.currentMenu.recipes.map(r =>
+ r.id === recipeId ? newRecipe : r
+ );
+ renderRecipeList(state.currentMenu.recipes);
+ } catch (err) {
+ showToast(err.message, 'error');
+ btn.disabled = false;
+ btn.textContent = 'Swap';
+ }
}
async function generateMenu() {
@@ -476,16 +527,18 @@ async function generateMenu() {
spinner.classList.remove('hidden');
try {
- const res = await api('POST', '/menus/generate', {});
- state.currentMenu = res;
+ const userNotes = document.getElementById('menu-user-notes').value.trim();
+ const res = await api('POST', '/menus/generate', userNotes ? { user_notes: userNotes } : {});
+ state.currentMenu = { ...res.menu_plan, recipes: res.recipes };
document.getElementById('menu-empty').classList.add('hidden');
+ document.getElementById('btn-clear-menu').classList.remove('hidden');
if (res.notes) {
document.getElementById('menu-notes').textContent = res.notes;
document.getElementById('menu-notes').classList.remove('hidden');
}
- renderMenuGrid();
+ renderRecipeList(res.recipes);
showToast('Menu generated!');
} catch (err) {
if (err.message.includes('503')) {
@@ -499,6 +552,20 @@ async function generateMenu() {
}
}
+async function clearMenu() {
+ try {
+ await api('DELETE', '/menus/current', null);
+ state.currentMenu = null;
+ document.getElementById('recipe-list').innerHTML = '';
+ document.getElementById('menu-empty').classList.remove('hidden');
+ document.getElementById('menu-notes').classList.add('hidden');
+ document.getElementById('btn-clear-menu').classList.add('hidden');
+ showToast('Menu cleared');
+ } catch (err) {
+ showToast(err.message, 'error');
+ }
+}
+
async function makeThisMeal(mealName, mealType) {
try {
const now = new Date();
@@ -528,12 +595,21 @@ async function loadGrocery() {
document.getElementById('grocery-total').classList.add('hidden');
document.getElementById('grocery-empty').classList.remove('hidden');
document.getElementById('btn-mark-purchased').classList.add('hidden');
+ document.getElementById('btn-clear-grocery').classList.add('hidden');
return;
}
state.currentGrocery = res;
state.currentGroceryId = res.id;
document.getElementById('grocery-empty').classList.add('hidden');
+ document.getElementById('btn-clear-grocery').classList.remove('hidden');
+
+ if (res.notes) {
+ document.getElementById('grocery-notes').textContent = res.notes;
+ document.getElementById('grocery-notes').classList.remove('hidden');
+ } else {
+ document.getElementById('grocery-notes').classList.add('hidden');
+ }
if (!res.is_purchased) {
document.getElementById('btn-mark-purchased').classList.remove('hidden');
@@ -582,7 +658,12 @@ function renderGroceryList() {
const usedIn = item.used_in ? item.used_in.join(', ') : '';
return `
<div class="grocery-item">
- <input type="checkbox">
+ <input type="checkbox"
+ data-name="${item.name.replace(/"/g, '&quot;')}"
+ data-quantity="${item.quantity || 0}"
+ data-unit="${(item.unit || '').replace(/"/g, '&quot;')}"
+ data-category="${(item.store_section || '').replace(/"/g, '&quot;')}"
+ onchange="checkGroceryItem(this)">
<div class="grocery-item-details">
<div class="grocery-item-name">${item.name}</div>
<div class="grocery-item-meta">
@@ -610,6 +691,25 @@ function renderGroceryList() {
}
}
+async function checkGroceryItem(checkbox) {
+ if (!checkbox.checked) return;
+ checkbox.disabled = true;
+
+ try {
+ await api('POST', '/pantry', {
+ name: checkbox.dataset.name,
+ quantity: parseFloat(checkbox.dataset.quantity) || 1,
+ unit: checkbox.dataset.unit,
+ category: checkbox.dataset.category || null,
+ });
+ checkbox.closest('.grocery-item').classList.add('grocery-item-checked');
+ } catch (err) {
+ showToast('Failed to add to pantry: ' + err.message, 'error');
+ checkbox.checked = false;
+ checkbox.disabled = false;
+ }
+}
+
async function generateGrocery() {
const btn = document.getElementById('btn-generate-grocery');
const spinner = document.getElementById('grocery-spinner');
@@ -618,17 +718,23 @@ async function generateGrocery() {
spinner.classList.remove('hidden');
try {
- const res = await api('POST', '/grocery/generate', {});
+ const userNotes = document.getElementById('grocery-user-notes').value.trim();
+ const res = await api('POST', '/grocery/generate', userNotes ? { user_notes: userNotes } : {});
state.currentGrocery = res;
- state.currentGroceryId = res.id;
+ state.currentGroceryId = res.grocery_list.id;
document.getElementById('grocery-empty').classList.add('hidden');
document.getElementById('btn-mark-purchased').classList.remove('hidden');
+ document.getElementById('btn-clear-grocery').classList.remove('hidden');
+
+ if (res.shopping_notes) {
+ document.getElementById('grocery-notes').textContent = res.shopping_notes;
+ document.getElementById('grocery-notes').classList.remove('hidden');
+ }
+
renderGroceryList();
showToast('Grocery list generated!');
} catch (err) {
- if (err.message.includes('not found') || err.message.includes('No menu')) {
- showToast('Generate a weekly menu first', 'error');
- } else if (err.message.includes('503')) {
+ if (err.message.includes('503')) {
showToast('Ollama is offline or not responding', 'error');
} else {
showToast(err.message, 'error');
@@ -639,6 +745,23 @@ async function generateGrocery() {
}
}
+async function clearGrocery() {
+ try {
+ await api('DELETE', '/grocery/current', null);
+ state.currentGrocery = null;
+ state.currentGroceryId = null;
+ document.getElementById('grocery-sections').innerHTML = '';
+ document.getElementById('grocery-total').classList.add('hidden');
+ document.getElementById('grocery-empty').classList.remove('hidden');
+ document.getElementById('grocery-notes').classList.add('hidden');
+ document.getElementById('btn-mark-purchased').classList.add('hidden');
+ document.getElementById('btn-clear-grocery').classList.add('hidden');
+ showToast('Grocery list cleared');
+ } catch (err) {
+ showToast(err.message, 'error');
+ }
+}
+
async function markPurchased() {
try {
await api('PUT', `/grocery/${state.currentGroceryId}/purchased`, {});
@@ -650,6 +773,71 @@ async function markPurchased() {
}
}
+// ── Chat Tab ───────────────────────────────────────────────────────────────
+function loadChat() {
+ renderChat();
+}
+
+function renderChat() {
+ const messagesDiv = document.getElementById('chat-messages');
+ if (state.chatHistory.length === 0) {
+ messagesDiv.innerHTML = '<div class="chat-welcome">Hi! I\'m Commis, your personal chef assistant. Ask me anything about your pantry, recipes, or meal planning.</div>';
+ return;
+ }
+
+ messagesDiv.innerHTML = state.chatHistory.map(msg => `
+ <div class="chat-message chat-message-${msg.role}">
+ <div class="chat-bubble">${msg.content.replace(/\n/g, '<br>')}</div>
+ </div>
+ `).join('');
+
+ messagesDiv.scrollTop = messagesDiv.scrollHeight;
+}
+
+async function sendChatMessage() {
+ const input = document.getElementById('chat-input');
+ const btn = document.getElementById('btn-send-chat');
+ const message = input.value.trim();
+ if (!message) return;
+
+ input.value = '';
+ input.disabled = true;
+ btn.disabled = true;
+
+ state.chatHistory.push({ role: 'user', content: message });
+ renderChat();
+
+ // Show typing indicator
+ const messagesDiv = document.getElementById('chat-messages');
+ const typingEl = document.createElement('div');
+ typingEl.className = 'chat-message chat-message-assistant';
+ typingEl.id = 'chat-typing';
+ typingEl.innerHTML = '<div class="chat-bubble chat-typing-indicator">...</div>';
+ messagesDiv.appendChild(typingEl);
+ messagesDiv.scrollTop = messagesDiv.scrollHeight;
+
+ try {
+ const res = await api('POST', '/ai/chat', {
+ message,
+ history: state.chatHistory.slice(-20).slice(0, -1),
+ });
+
+ document.getElementById('chat-typing')?.remove();
+ state.chatHistory.push({ role: 'assistant', content: res.response });
+ renderChat();
+ } catch (err) {
+ document.getElementById('chat-typing')?.remove();
+ state.chatHistory.pop();
+ showToast(err.message, 'error');
+ input.value = message;
+ renderChat();
+ } finally {
+ input.disabled = false;
+ btn.disabled = false;
+ input.focus();
+ }
+}
+
// ── Initialization ─────────────────────────────────────────────────────────
async function init() {
// Check Ollama status
@@ -745,11 +933,22 @@ async function init() {
// Set up menu form
document.getElementById('btn-generate-menu').addEventListener('click', generateMenu);
+ document.getElementById('btn-clear-menu').addEventListener('click', clearMenu);
// Set up grocery form
document.getElementById('btn-generate-grocery').addEventListener('click', generateGrocery);
+ document.getElementById('btn-clear-grocery').addEventListener('click', clearGrocery);
document.getElementById('btn-mark-purchased').addEventListener('click', markPurchased);
+ // Set up chat
+ document.getElementById('btn-send-chat').addEventListener('click', sendChatMessage);
+ document.getElementById('chat-input').addEventListener('keydown', e => {
+ if (e.key === 'Enter' && !e.shiftKey) {
+ e.preventDefault();
+ sendChatMessage();
+ }
+ });
+
// Load initial tab
await loadPantry();
}
diff --git a/static/favicon.svg b/static/favicon.svg
new file mode 100644
index 0000000..7122059
--- /dev/null
+++ b/static/favicon.svg
@@ -0,0 +1,34 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
+ <!-- Background circle -->
+ <circle cx="32" cy="32" r="32" fill="#FF6B35"/>
+
+ <!-- Chef hat (white puff top) -->
+ <ellipse cx="32" cy="18" rx="13" ry="11" fill="white"/>
+ <!-- Hat brim -->
+ <rect x="19" y="24" width="26" height="7" rx="3" fill="white"/>
+
+ <!-- Hat shine -->
+ <ellipse cx="27" cy="14" rx="4" ry="3" fill="rgba(255,255,255,0.4)"/>
+
+ <!-- Face -->
+ <circle cx="32" cy="42" r="13" fill="#FDDBB4"/>
+
+ <!-- Rosy cheeks -->
+ <circle cx="24" cy="44" r="3.5" fill="#FFB3A7" opacity="0.7"/>
+ <circle cx="40" cy="44" r="3.5" fill="#FFB3A7" opacity="0.7"/>
+
+ <!-- Eyes (cute closed/happy) -->
+ <path d="M27 40 Q28.5 38 30 40" stroke="#5C3317" stroke-width="1.8" fill="none" stroke-linecap="round"/>
+ <path d="M34 40 Q35.5 38 37 40" stroke="#5C3317" stroke-width="1.8" fill="none" stroke-linecap="round"/>
+
+ <!-- Smile -->
+ <path d="M27 46 Q32 51 37 46" stroke="#5C3317" stroke-width="1.8" fill="none" stroke-linecap="round"/>
+
+ <!-- Little nose dot -->
+ <circle cx="32" cy="43.5" r="1" fill="#C68642"/>
+
+ <!-- Tiny sparkle top right -->
+ <g fill="white" opacity="0.9">
+ <polygon points="56,10 57,13 60,13 57.5,15 58.5,18 56,16 53.5,18 54.5,15 52,13 55,13" transform="scale(0.6) translate(38, 4)"/>
+ </g>
+</svg>
diff --git a/static/index.html b/static/index.html
index a9ae018..d0e0243 100644
--- a/static/index.html
+++ b/static/index.html
@@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Commis</title>
<link rel="stylesheet" href="style.css">
+ <link rel="icon" href="favicon.svg" type="image/svg+xml">
</head>
<body>
<header>
@@ -17,6 +18,7 @@
<button class="tab-btn" data-tab="meals">Meals</button>
<button class="tab-btn" data-tab="menu">This Week's Menu</button>
<button class="tab-btn" data-tab="grocery">Grocery List</button>
+ <button class="tab-btn" data-tab="chat">Chat</button>
</nav>
<main>
@@ -96,12 +98,14 @@
<div class="tab-header">
<h2>This Week's Menu</h2>
<div class="header-actions">
+ <textarea id="menu-user-notes" class="ai-notes-input" placeholder="Any preferences or instructions for the AI (e.g. vegetarian, 2 people, quick breakfasts)..." rows="2"></textarea>
<button class="btn btn-primary" id="btn-generate-menu">✨ Generate Menu</button>
+ <button class="btn btn-danger hidden" id="btn-clear-menu">Clear</button>
<div id="menu-spinner" class="spinner hidden"></div>
</div>
</div>
<div id="menu-notes" class="info-banner hidden"></div>
- <div id="menu-grid" class="menu-grid"></div>
+ <div id="recipe-list" class="recipe-list"></div>
<div id="menu-empty" class="empty-state hidden">No menu generated yet. Click "Generate Menu" to create one.</div>
</div>
@@ -110,17 +114,44 @@
<div class="tab-header">
<h2>Grocery List</h2>
<div class="header-actions">
+ <textarea id="grocery-user-notes" class="ai-notes-input" placeholder="Any preferences or instructions for the AI (e.g. budget $50, no red meat, stock up on breakfast items)..." rows="2"></textarea>
<button class="btn btn-primary" id="btn-generate-grocery">✨ Generate List</button>
+ <button class="btn btn-danger hidden" id="btn-clear-grocery">Clear</button>
<button class="btn btn-success hidden" id="btn-mark-purchased">✓ Mark All Purchased</button>
<div id="grocery-spinner" class="spinner hidden"></div>
</div>
</div>
+ <div id="grocery-notes" class="info-banner hidden"></div>
<div id="grocery-total" class="hidden"></div>
<div id="grocery-sections"></div>
- <div id="grocery-empty" class="empty-state hidden">No grocery list yet. Generate a menu first, then generate the grocery list.</div>
+ <div id="grocery-empty" class="empty-state hidden">No grocery list yet. Click "Generate List" to create one.</div>
+ </div>
+
+ <!-- Chat Tab -->
+ <div id="tab-chat" class="tab-content hidden">
+ <div class="chat-container">
+ <div id="chat-messages" class="chat-messages"></div>
+ <div class="chat-input-area">
+ <textarea id="chat-input" class="chat-input" placeholder="Ask Commis anything about your kitchen..." rows="2"></textarea>
+ <button class="btn btn-primary" id="btn-send-chat">Send</button>
+ </div>
+ </div>
</div>
</main>
+ <!-- Swap Recipe Modal -->
+ <div id="swap-modal-overlay" class="modal-overlay hidden" onclick="cancelSwap()">
+ <div class="modal" onclick="event.stopPropagation()">
+ <div class="modal-title">Swap Recipe</div>
+ <div id="swap-modal-subtitle" class="modal-subtitle"></div>
+ <textarea id="swap-notes" class="ai-notes-input" placeholder="Any preferences? e.g. vegetarian, something with chicken, quick and easy... (optional)" rows="3"></textarea>
+ <div class="modal-actions">
+ <button class="btn btn-secondary" onclick="cancelSwap()">Cancel</button>
+ <button class="btn btn-primary" onclick="confirmSwap()">Find Replacement</button>
+ </div>
+ </div>
+ </div>
+
<div id="toast-container"></div>
<script src="app.js"></script>
</body>
diff --git a/static/style.css b/static/style.css
index 9d90898..4cc033b 100644
--- a/static/style.css
+++ b/static/style.css
@@ -270,6 +270,16 @@ main {
border-color: #16a34a;
}
+.btn-secondary {
+ background-color: var(--surface2);
+ color: var(--text);
+ border: 1px solid var(--border);
+}
+
+.btn-secondary:hover {
+ border-color: var(--text-muted);
+}
+
.btn-sm {
padding: 0.5rem 1rem;
font-size: 0.8rem;
@@ -354,97 +364,87 @@ tbody tr.expiry-warn {
font-size: 0.9rem;
}
-/* ── Menu Grid ──────────────────────────────────────────────────────────── */
-.menu-grid {
+/* ── Recipe List ───────────────────────────────────────────────────────── */
+.recipe-list {
display: grid;
- grid-template-columns: repeat(8, 1fr);
- gap: 0.5rem;
- margin-bottom: 2rem;
+ grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
+ gap: 1rem;
+ padding: 1rem 0;
}
-.menu-grid-cell {
+.recipe-card {
background-color: var(--surface);
border: 1px solid var(--border);
border-radius: 0.5rem;
- padding: 1rem;
- min-height: 120px;
+ padding: 1.25rem;
display: flex;
flex-direction: column;
- justify-content: space-between;
- transition: all 0.2s ease;
+ gap: 0.5rem;
+ transition: border-color 0.2s ease;
}
-.menu-grid-cell:hover {
+.recipe-card:hover {
border-color: var(--accent);
}
-.menu-grid-cell.repeat-warning {
- border: 2px solid var(--warning);
+.recipe-card-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: 0.5rem;
}
-.menu-grid-header {
- background-color: var(--surface2);
+.recipe-card-title {
font-weight: 600;
- text-align: center;
- padding: 1rem;
- border-radius: 0.5rem;
- border: 1px solid var(--border);
+ font-size: 1rem;
+ color: var(--text);
}
-.menu-meal-type {
- font-size: 0.75rem;
- font-weight: 600;
- text-transform: uppercase;
- color: var(--text-muted);
- margin-bottom: 0.5rem;
+.recipe-type-badge {
+ font-size: 0.7rem;
+ padding: 0.2rem 0.6rem;
+ border-radius: 9999px;
+ font-weight: 500;
+ white-space: nowrap;
+ flex-shrink: 0;
}
-.menu-meal-name {
- font-weight: 600;
- margin-bottom: 0.5rem;
- word-break: break-word;
-}
+.recipe-type-badge.breakfast { background-color: rgba(251, 191, 36, 0.2); color: #fbbf24; }
+.recipe-type-badge.lunch { background-color: rgba(52, 211, 153, 0.2); color: #34d399; }
+.recipe-type-badge.dinner { background-color: rgba(139, 92, 246, 0.2); color: #8b5cf6; }
+.recipe-type-badge.snack { background-color: rgba(251, 146, 60, 0.2); color: #fb923c; }
-.menu-time {
- display: inline-block;
- font-size: 0.75rem;
- background-color: var(--surface2);
- padding: 0.25rem 0.5rem;
- border-radius: 0.25rem;
- margin-bottom: 0.75rem;
+.recipe-card-meta {
+ font-size: 0.8rem;
color: var(--text-muted);
}
-.menu-actions {
- display: flex;
- gap: 0.5rem;
- flex-wrap: wrap;
+.recipe-card-ingredients {
+ font-size: 0.85rem;
+ color: var(--text-muted);
+ line-height: 1.5;
}
-@media (max-width: 768px) {
- .menu-grid {
- grid-template-columns: 1fr;
- }
-
- .menu-grid-header {
- display: none;
- }
+.recipe-card-instructions {
+ font-size: 0.85rem;
+ color: var(--text);
+ line-height: 1.6;
+ border-top: 1px solid var(--border);
+ padding-top: 0.5rem;
+ margin-top: 0.25rem;
+ white-space: pre-line;
+}
- .menu-grid-cell {
- position: relative;
- }
+.recipe-card .btn {
+ align-self: flex-start;
+ margin-top: 0.5rem;
+}
- .menu-grid-cell::before {
- content: attr(data-day);
- position: absolute;
- top: 0.5rem;
- right: 0.5rem;
- font-size: 0.75rem;
- color: var(--text-muted);
- background-color: var(--surface2);
- padding: 0.25rem 0.5rem;
- border-radius: 0.25rem;
- }
+.recipe-card-actions {
+ display: flex;
+ gap: 0.5rem;
+ flex-wrap: wrap;
+ margin-top: 0.5rem;
}
/* ── Grocery List ───────────────────────────────────────────────────────── */
@@ -826,3 +826,163 @@ tbody tr.expiry-warn {
min-width: unset;
}
}
+
+.ai-notes-input {
+ width: 100%;
+ padding: 0.6rem 0.75rem;
+ background-color: var(--surface2);
+ border: 1px solid var(--border);
+ border-radius: 0.375rem;
+ color: var(--text);
+ font-size: 0.85rem;
+ resize: vertical;
+ font-family: inherit;
+ margin-bottom: 0.5rem;
+}
+
+.ai-notes-input:focus {
+ outline: none;
+ border-color: var(--accent);
+}
+
+.ai-notes-input::placeholder {
+ color: var(--text-muted);
+}
+
+.grocery-item-checked {
+ opacity: 0.45;
+}
+
+.grocery-item-checked .grocery-item-name {
+ text-decoration: line-through;
+}
+
+.modal-overlay {
+ position: fixed;
+ inset: 0;
+ background-color: rgba(0, 0, 0, 0.6);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 1000;
+}
+
+.modal {
+ background-color: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: 0.75rem;
+ padding: 1.5rem;
+ width: 100%;
+ max-width: 420px;
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+}
+
+.modal-title {
+ font-size: 1.1rem;
+ font-weight: 600;
+ color: var(--text);
+}
+
+.modal-subtitle {
+ font-size: 0.85rem;
+ color: var(--text-muted);
+ margin-top: -0.5rem;
+}
+
+.modal-actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 0.75rem;
+}
+
+.chat-container {
+ display: flex;
+ flex-direction: column;
+ height: calc(100vh - 180px);
+}
+
+.chat-messages {
+ flex: 1;
+ overflow-y: auto;
+ padding: 1rem 0;
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+}
+
+.chat-welcome {
+ color: var(--text-muted);
+ font-style: italic;
+ text-align: center;
+ padding: 2rem;
+}
+
+.chat-message {
+ display: flex;
+}
+
+.chat-message-user {
+ justify-content: flex-end;
+}
+
+.chat-message-assistant {
+ justify-content: flex-start;
+}
+
+.chat-bubble {
+ max-width: 75%;
+ padding: 0.75rem 1rem;
+ border-radius: 1rem;
+ font-size: 0.9rem;
+ line-height: 1.6;
+ word-break: break-word;
+}
+
+.chat-message-user .chat-bubble {
+ background-color: var(--accent);
+ color: white;
+ border-bottom-right-radius: 0.25rem;
+}
+
+.chat-message-assistant .chat-bubble {
+ background-color: var(--surface2);
+ color: var(--text);
+ border-bottom-left-radius: 0.25rem;
+}
+
+.chat-typing-indicator {
+ color: var(--text-muted);
+ font-style: italic;
+}
+
+.chat-input-area {
+ display: flex;
+ gap: 0.75rem;
+ align-items: flex-end;
+ padding-top: 1rem;
+ border-top: 1px solid var(--border);
+}
+
+.chat-input {
+ flex: 1;
+ padding: 0.75rem;
+ background-color: var(--surface2);
+ border: 1px solid var(--border);
+ border-radius: 0.5rem;
+ color: var(--text);
+ font-size: 0.9rem;
+ resize: none;
+ font-family: inherit;
+ line-height: 1.5;
+}
+
+.chat-input:focus {
+ outline: none;
+ border-color: var(--accent);
+}
+
+.chat-input::placeholder {
+ color: var(--text-muted);
+}