diff options
Diffstat (limited to 'static/app.js')
| -rw-r--r-- | static/app.js | 129 |
1 files changed, 108 insertions, 21 deletions
diff --git a/static/app.js b/static/app.js index 724e565..3135aa9 100644 --- a/static/app.js +++ b/static/app.js @@ -100,19 +100,17 @@ function autoCategory(name) { // ── Tab Switching ────────────────────────────────────────────────────────── function switchTab(tabName) { state.activeTab = tabName; + location.hash = tabName; - // Update button states document.querySelectorAll('.tab-btn').forEach(btn => { btn.classList.toggle('active', btn.dataset.tab === tabName); }); - // Update content visibility document.querySelectorAll('.tab-content').forEach(content => { content.classList.toggle('hidden', content.id !== `tab-${tabName}`); content.classList.toggle('active', content.id === `tab-${tabName}`); }); - // Load tab content if (tabName === 'pantry') loadPantry(); else if (tabName === 'meals') loadMeals(); else if (tabName === 'menu') loadMenu(); @@ -394,33 +392,21 @@ function renderRecipeList(recipes) { } list.innerHTML = recipes.map(recipe => { - let ingredients = recipe.ingredients || []; - if (typeof ingredients === 'string') { - try { ingredients = JSON.parse(ingredients); } catch { ingredients = []; } - } - - 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(', ') - : ''; - 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(' · '); + const description = recipe.description || ''; return ` <div class="recipe-card"> <div class="recipe-card-header"> - <div class="recipe-card-title">${recipe.name}</div> + <a class="recipe-card-title" href="/recipe.html?id=${recipe.id}">${recipe.name}</a> <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>` : ''} + ${description ? `<div class="recipe-description">${description}</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> @@ -550,6 +536,48 @@ async function makeThisMeal(mealName, mealType) { } } +async function importRecipe() { + const input = document.getElementById('import-url-input'); + const btn = document.getElementById('btn-import-recipe'); + const url = input.value.trim(); + if (!url) { showToast('Paste a recipe URL first', 'error'); return; } + + btn.disabled = true; + btn.textContent = 'Importing…'; + try { + const res = await api('POST', '/recipes/import', { url }); + input.value = ''; + showToast(`Imported: ${res.recipe.name}`); + await loadMenu(); + } catch (err) { + showToast('Import failed: ' + err.message, 'error'); + } finally { + btn.disabled = false; + btn.textContent = 'Import'; + } +} + +async function addSingleDish() { + const input = document.getElementById('add-dish-input'); + const btn = document.getElementById('btn-add-dish'); + const description = input.value.trim(); + if (!description) { showToast('Describe a dish first', 'error'); return; } + + btn.disabled = true; + btn.textContent = 'Adding…'; + try { + const res = await api('POST', '/menus/current/recipes', { description }); + input.value = ''; + showToast(`Added: ${res.recipe.name}`); + await loadMenu(); + } catch (err) { + showToast('Failed: ' + err.message, 'error'); + } finally { + btn.disabled = false; + btn.textContent = 'Add Dish'; + } +} + // ── Grocery Tab ──────────────────────────────────────────────────────────── async function loadGrocery() { try { @@ -643,6 +671,7 @@ function renderGroceryList() { ${usedIn ? ` - Used in: ${usedIn}` : ''} </div> </div> + <button class="btn btn-sm btn-danger" style="padding:0.1rem 0.4rem;font-size:0.75rem;" onclick="deleteGroceryItem('${item.name.replace(/'/g, "\\'")}')">×</button> </div> `; }).join('')} @@ -759,11 +788,67 @@ async function markPurchased() { } } +async function deleteGroceryItem(name) { + if (!state.currentGroceryId) return; + try { + await api('DELETE', `/grocery/${state.currentGroceryId}/items`, { name }); + // Remove from local state and re-render + const parsed = JSON.parse(state.currentGrocery.grocery_list?.items || state.currentGrocery.items || '[]'); + const updated = parsed.filter(i => i.name !== name); + if (state.currentGrocery.grocery_list) { + state.currentGrocery.grocery_list.items = JSON.stringify(updated); + } else { + state.currentGrocery.items = JSON.stringify(updated); + } + renderGroceryList(); + } catch (err) { + showToast('Failed to remove item: ' + err.message, 'error'); + } +} + // ── Chat Tab ─────────────────────────────────────────────────────────────── function loadChat() { renderChat(); } +function renderMarkdown(text) { + // Escape HTML first + let html = text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); + + // Fenced code blocks + html = html.replace(/```(\w*)\n?([\s\S]*?)```/g, (_, lang, code) => + `<pre><code>${code.trim()}</code></pre>` + ); + + // Inline code + html = html.replace(/`([^`]+)`/g, '<code>$1</code>'); + + // Bold + italic + html = html.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>'); + // Bold + html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>'); + // Italic + html = html.replace(/\*(.+?)\*/g, '<em>$1</em>'); + + // Headers (## and ###) + html = html.replace(/^### (.+)$/gm, '<h4>$1</h4>'); + html = html.replace(/^## (.+)$/gm, '<h3>$1</h3>'); + + // Unordered lists + html = html.replace(/^[-*] (.+)$/gm, '<li>$1</li>'); + html = html.replace(/(<li>.*<\/li>)/s, '<ul>$1</ul>'); + + // Numbered lists + html = html.replace(/^\d+\. (.+)$/gm, '<li>$1</li>'); + + // Paragraphs — double newlines + html = html.replace(/\n\n+/g, '</p><p>'); + // Single newlines outside block elements + html = html.replace(/\n/g, '<br>'); + + return `<p>${html}</p>`; +} + function renderChat() { const messagesDiv = document.getElementById('chat-messages'); if (state.chatHistory.length === 0) { @@ -773,7 +858,7 @@ function renderChat() { 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 class="chat-bubble">${msg.role === 'assistant' ? renderMarkdown(msg.content) : msg.content.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>')}</div> </div> `).join(''); @@ -925,8 +1010,10 @@ async function init() { } }); - // Load initial tab - await loadPantry(); + // Load initial tab from URL hash, defaulting to pantry + const validTabs = ['pantry', 'meals', 'menu', 'grocery', 'chat']; + const hashTab = location.hash.slice(1); + switchTab(validTabs.includes(hashTab) ? hashTab : 'pantry'); } document.addEventListener('DOMContentLoaded', init); |
