diff options
Diffstat (limited to 'static')
| -rw-r--r-- | static/app.js | 335 | ||||
| -rw-r--r-- | static/favicon.svg | 34 | ||||
| -rw-r--r-- | static/index.html | 35 | ||||
| -rw-r--r-- | static/style.css | 286 |
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, '"')}" + data-quantity="${item.quantity || 0}" + data-unit="${(item.unit || '').replace(/"/g, '"')}" + data-category="${(item.store_section || '').replace(/"/g, '"')}" + 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); +} |
