summaryrefslogtreecommitdiff
path: root/static/app.js
diff options
context:
space:
mode:
Diffstat (limited to 'static/app.js')
-rw-r--r--static/app.js129
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
+
+ // 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').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);