document.addEventListener('DOMContentLoaded', () => { const chatForm = document.getElementById('chat-form'); const userInput = document.getElementById('user-input'); const chatContainer = document.getElementById('chat-container'); const sendBtn = document.getElementById('send-btn'); const clearChatBtn = document.getElementById('clear-chat'); let chatHistory = []; // Store conversation history // Auto-resize textarea userInput.addEventListener('input', function() { this.style.height = 'auto'; this.style.height = (this.scrollHeight > 150 ? 150 : this.scrollHeight) + 'px'; if (this.value.trim() === '') { sendBtn.disabled = true; } else { sendBtn.disabled = false; } }); // Handle Enter key (Shift+Enter for new line) userInput.addEventListener('keydown', function(e) { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); if (!sendBtn.disabled) { chatForm.requestSubmit(); } } }); clearChatBtn.addEventListener('click', () => { if (confirm('Are you sure you want to clear the chat history?')) { chatHistory = []; chatContainer.innerHTML = ''; addMessage('system', 'Hello! I am LocalFoodAI, your completely local nutrition and menu assistant. How can I help you today?'); } }); chatForm.addEventListener('submit', async (e) => { e.preventDefault(); const message = userInput.value.trim(); if (!message) return; // Reset input userInput.value = ''; userInput.style.height = 'auto'; sendBtn.disabled = true; // Add user message to UI addMessage('user', message); chatHistory.push({ role: 'user', content: message }); // Add loading indicator const loadingId = addTypingIndicator(); try { const token = localStorage.getItem('localFoodToken'); // Fetch response from backend const response = await fetch('/chat', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, body: JSON.stringify({ messages: chatHistory }) }); if (response.status === 401) { setLoggedOutState(); addMessage('system', 'Your session has expired. Please log in again.'); return; } if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } // Remove loading indicator removeElement(loadingId); // Create new bot message container const botMessageId = 'msg-' + Date.now(); const botContentEl = addMessage('system', '', botMessageId); let botFullResponse = ''; // Handle Server-Sent Events (Streaming) const reader = response.body.getReader(); const decoder = new TextDecoder('utf-8'); let done = false; while (!done) { const { value, done: readerDone } = await reader.read(); done = readerDone; if (value) { const chunk = decoder.decode(value, { stream: true }); // Split the chunk by double newline to get individual SSE messages const lines = chunk.split('\n\n'); for (const line of lines) { if (line.startsWith('data: ')) { const dataStr = line.substring(6); if (dataStr.trim() === '') continue; try { const data = JSON.parse(dataStr); if (data.error) { botContentEl.innerHTML += `
Error: ${data.error}`; } else if (data.content !== undefined) { botFullResponse += data.content; // Basic text to HTML conversion botContentEl.innerHTML = formatText(botFullResponse); chatContainer.scrollTop = chatContainer.scrollHeight; } } catch (err) { console.error('Error parsing SSE data:', err, dataStr); } } } } } // Save bot response to history once complete chatHistory.push({ role: 'assistant', content: botFullResponse }); } catch (error) { console.error('Chat error:', error); removeElement(loadingId); addMessage('system', 'Sorry, there was an error communicating with the local LLM. Make sure the server and Ollama are running.'); } finally { sendBtn.disabled = false; userInput.focus(); } }); function addMessage(role, content, id = null) { const msgDiv = document.createElement('div'); msgDiv.className = `message ${role}`; if (id) msgDiv.id = id; const avatarDiv = document.createElement('div'); avatarDiv.className = 'avatar'; avatarDiv.textContent = role === 'user' ? '👤' : '🤖'; const contentDiv = document.createElement('div'); contentDiv.className = 'message-content'; contentDiv.innerHTML = formatText(content); msgDiv.appendChild(avatarDiv); msgDiv.appendChild(contentDiv); chatContainer.appendChild(msgDiv); chatContainer.scrollTop = chatContainer.scrollHeight; return contentDiv; } function addTypingIndicator() { const id = 'typing-' + Date.now(); const msgDiv = document.createElement('div'); msgDiv.className = 'message system'; msgDiv.id = id; const avatarDiv = document.createElement('div'); avatarDiv.className = 'avatar'; avatarDiv.textContent = '🤖'; const contentDiv = document.createElement('div'); contentDiv.className = 'message-content typing-indicator'; contentDiv.innerHTML = `
`; msgDiv.appendChild(avatarDiv); msgDiv.appendChild(contentDiv); chatContainer.appendChild(msgDiv); chatContainer.scrollTop = chatContainer.scrollHeight; return id; } function removeElement(id) { const el = document.getElementById(id); if (el) el.remove(); } function formatText(text) { if (!text) return ''; // Very basic markdown parsing for bold, italics, code, and newlines let formatted = text .replace(/&/g, "&") .replace(//g, ">") .replace(/\n/g, "
") .replace(/\*\*(.*?)\*\*/g, "$1") // bold .replace(/\*(.*?)\*/g, "$1") // italic .replace(/`(.*?)`/g, "$1"); // inline code return formatted; } // Authentication & Session Logic const authScreen = document.getElementById('auth-screen'); const chatApp = document.getElementById('chat-app'); const loginForm = document.getElementById('login-form'); const registerForm = document.getElementById('register-form'); const showRegisterLink = document.getElementById('show-register'); const showLoginLink = document.getElementById('show-login'); const loginError = document.getElementById('login-error'); const regError = document.getElementById('reg-error'); const regSuccess = document.getElementById('reg-success'); const logoutBtn = document.getElementById('nav-logout-btn'); const userGreeting = document.getElementById('user-greeting'); // Check session on load const savedUser = localStorage.getItem('localFoodUser'); const savedToken = localStorage.getItem('localFoodToken'); if (savedUser && savedToken) { setLoggedInState(savedUser, savedToken); } async function setLoggedInState(username, token) { localStorage.setItem('localFoodUser', username); localStorage.setItem('localFoodToken', token); userGreeting.textContent = `Welcome, ${username}`; authScreen.classList.add('fade-out'); setTimeout(() => { authScreen.style.display = 'none'; chatApp.style.display = 'flex'; chatApp.classList.add('fade-in'); userInput.focus(); }, 500); // Load persisted chat history and macro targets from the server await loadChatHistory(); await loadMacroTargets(); } async function loadChatHistory() { const token = localStorage.getItem('localFoodToken'); if (!token) return; try { const response = await fetch('/api/chat/history', { headers: { 'Authorization': `Bearer ${token}` } }); if (response.status === 401) { setLoggedOutState(); return; } if (response.ok) { const data = await response.json(); if (data.history && data.history.length > 0) { // Clear initial welcome message if we have real history chatContainer.innerHTML = ''; chatHistory = []; // Reset local state data.history.forEach(msg => { addMessage(msg.role, msg.content); chatHistory.push({ role: msg.role, content: msg.content }); }); } } } catch (err) { console.error("Failed to load chat history:", err); } } async function loadMacroTargets() { const token = localStorage.getItem('localFoodToken'); if (!token) return; const dashboard = document.getElementById('macro-dashboard'); try { const response = await fetch('/api/macros/targets', { headers: { 'Authorization': `Bearer ${token}` } }); if (response.ok) { const data = await response.json(); document.getElementById('macro-calories').textContent = `${data.calories} kcal`; document.getElementById('macro-protein').textContent = `${data.protein_g} g`; document.getElementById('macro-carbs').textContent = `${data.carbs_g} g`; document.getElementById('macro-fat').textContent = `${data.fat_g} g`; if (dashboard) dashboard.style.display = 'flex'; } } catch (err) { console.error('Failed to load macro targets:', err); } } async function setLoggedOutState() { const token = localStorage.getItem('localFoodToken'); if (token) { try { await fetch('/api/logout', { method: 'POST', headers: { 'Authorization': `Bearer ${token}` } }); } catch (err) { console.error("Error during logout:", err); } } // Clear chat memory and interface so the next user doesn't see old messages chatHistory = []; chatContainer.innerHTML = ''; addMessage('system', 'Hello! I am LocalFoodAI, your completely local nutrition and menu assistant. How can I help you today?'); localStorage.removeItem('localFoodUser'); localStorage.removeItem('localFoodToken'); chatApp.style.display = 'none'; chatApp.classList.remove('fade-in'); const dashboard = document.getElementById('macro-dashboard'); if (dashboard) dashboard.style.display = 'none'; authScreen.style.display = 'block'; setTimeout(() => { authScreen.classList.remove('fade-out'); }, 50); loginForm.reset(); registerForm.reset(); loginError.textContent = ''; regError.textContent = ''; } logoutBtn.addEventListener('click', () => { setLoggedOutState(); }); // Toggles showRegisterLink.addEventListener('click', (e) => { e.preventDefault(); loginForm.style.display = 'none'; registerForm.style.display = 'block'; }); showLoginLink.addEventListener('click', (e) => { e.preventDefault(); registerForm.style.display = 'none'; loginForm.style.display = 'block'; }); // Login Submission loginForm.addEventListener('submit', async (e) => { e.preventDefault(); loginError.textContent = ''; const submitBtn = document.getElementById('login-submit-btn'); submitBtn.disabled = true; const username = document.getElementById('login-username').value; const password = document.getElementById('login-password').value; try { const response = await fetch('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }) }); const data = await response.json(); if (!response.ok) { loginError.textContent = data.detail || 'Login failed.'; } else { setLoggedInState(data.username, data.token); } } catch (err) { loginError.textContent = 'Server error. Is the backend running?'; } finally { submitBtn.disabled = false; } }); // Registration Submission registerForm.addEventListener('submit', async (e) => { e.preventDefault(); regError.textContent = ''; regSuccess.textContent = ''; const submitBtn = document.getElementById('reg-submit-btn'); submitBtn.disabled = true; const username = document.getElementById('reg-username').value; const password = document.getElementById('reg-password').value; const confirmInfo = document.getElementById('reg-confirm').value; if (password !== confirmInfo) { regError.textContent = "Passwords do not match."; submitBtn.disabled = false; return; } try { const response = await fetch('/api/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }) }); const data = await response.json(); if (!response.ok) { regError.textContent = data.detail || 'Registration failed.'; } else { regSuccess.textContent = 'Account created! Logging in...'; setTimeout(() => { setLoggedInState(data.username, data.token); }, 1000); } } catch (err) { regError.textContent = 'Server error. Please try again later.'; } finally { submitBtn.disabled = false; } }); // --- Food Search Module Logic --- const searchInput = document.getElementById('food-search-input'); const searchDropdown = document.getElementById('search-results-dropdown'); const clearSearchBtn = document.getElementById('clear-search-btn'); // --- Meal Builder State & UI (US-10 Task #46) --- let currentMealItems = []; const mealBuilder = document.getElementById('meal-builder'); const mealContent = document.getElementById('meal-content'); const mealItemsList = document.getElementById('meal-items-list'); const mealItemCount = document.getElementById('meal-item-count'); const toggleMealBtn = document.getElementById('toggle-meal-btn'); const emptyMealMsg = document.getElementById('empty-meal-msg'); const mealBuilderFooter = document.getElementById('meal-builder-footer'); const generateRecipeBtn = document.getElementById('generate-recipe-btn'); const toggleMealBuilder = () => { const isCollapsed = mealContent.classList.contains('collapsed'); if (isCollapsed) { mealContent.classList.remove('collapsed'); toggleMealBtn.style.transform = 'rotate(180deg)'; } else { mealContent.classList.add('collapsed'); toggleMealBtn.style.transform = 'rotate(0deg)'; } }; const addItemToMeal = (food) => { // Duplicate Handling: Merge by updating grams const existingItem = currentMealItems.find(item => item.id === food.id); if (existingItem) { existingItem.amount += 100; // Directly update the DOM input for this item to avoid re-rendering the whole list const inputEl = document.querySelector(`.meal-weight-input[data-id="${food.id}"]`); if (inputEl) { inputEl.value = existingItem.amount; // Animate the row slightly to show it was updated const rowEl = inputEl.closest('.meal-item-row'); if (rowEl) { rowEl.style.transform = 'scale(1.02)'; rowEl.style.background = 'rgba(255, 255, 255, 0.1)'; setTimeout(() => { rowEl.style.transform = ''; rowEl.style.background = 'rgba(255, 255, 255, 0.05)'; }, 200); } } } else { currentMealItems.push({ id: food.id, name: food.name, amount: 100, base_macros: { calories: food.calories, protein_g: food.protein_g, fat_g: food.fat_g, carbs_g: food.carbs_g } }); renderMealItems(); // Only re-render if it's a new item } // Ensure builder is expanded when adding an item if (mealContent.classList.contains('collapsed')) { toggleMealBuilder(); } // Trigger calculation calculateMealTotals(); }; const removeItemFromMeal = (id) => { currentMealItems = currentMealItems.filter(item => item.id !== id); renderMealItems(); calculateMealTotals(); }; let calculateAbortController = null; const calculateMealTotals = async () => { const activeItems = currentMealItems.filter(item => item.amount > 0); // DOM Elements const totalsBanner = document.getElementById('meal-totals-banner'); const totalWeightEl = document.getElementById('meal-total-weight'); const totalCalEl = document.getElementById('meal-total-cal'); const totalProEl = document.getElementById('meal-total-pro'); const totalFatEl = document.getElementById('meal-total-fat'); const totalCarbEl = document.getElementById('meal-total-carb'); if (activeItems.length === 0) { if (totalsBanner) totalsBanner.classList.remove('has-error'); if (totalWeightEl) totalWeightEl.textContent = '0g'; if (totalCalEl) totalCalEl.textContent = '0'; if (totalProEl) totalProEl.textContent = '0'; if (totalFatEl) totalFatEl.textContent = '0'; if (totalCarbEl) totalCarbEl.textContent = '0'; const saveBtn = document.getElementById('save-meal-btn'); if (saveBtn) saveBtn.style.display = 'none'; return; } // Race Condition Protection: Abort previous request if (calculateAbortController) { calculateAbortController.abort(); } calculateAbortController = new AbortController(); try { const token = localStorage.getItem('localFoodToken'); const payload = { items: activeItems.map(item => ({ food_id: item.id, amount_g: item.amount })) }; const response = await fetch('/api/meal/calculate', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, body: JSON.stringify(payload), signal: calculateAbortController.signal }); if (response.ok) { const data = await response.json(); if (totalsBanner) totalsBanner.classList.remove('has-error'); // Update UI with animation for "alive" feel if (totalWeightEl) totalWeightEl.textContent = `${data.total_weight_g}g`; if (totalCalEl) animateValue(totalCalEl, data.macros.calories); if (totalProEl) animateValue(totalProEl, data.macros.protein_g); if (totalFatEl) animateValue(totalFatEl, data.macros.fat_g); if (totalCarbEl) animateValue(totalCarbEl, data.macros.carbs_g); // Show Save button (Sprint 8) const saveBtn = document.getElementById('save-meal-btn'); if (saveBtn) saveBtn.style.display = 'flex'; } else { if (totalsBanner) totalsBanner.classList.add('has-error'); } } catch (err) { if (err.name === 'AbortError') return; // Ignore expected aborts console.error('Failed to calculate meal totals:', err); if (totalsBanner) totalsBanner.classList.add('has-error'); } }; // Helper for smooth number transitions const animateValue = (el, targetValue) => { const startValue = parseFloat(el.textContent) || 0; const duration = 400; const startTime = performance.now(); const update = (currentTime) => { const elapsed = currentTime - startTime; const progress = Math.min(elapsed / duration, 1); // Ease out quad const ease = progress * (2 - progress); const currentValue = startValue + (targetValue - startValue) * ease; el.textContent = targetValue % 1 === 0 ? Math.round(currentValue) : currentValue.toFixed(1); if (progress < 1) { requestAnimationFrame(update); } else { el.textContent = targetValue; } }; requestAnimationFrame(update); }; const debouncedCalculate = debounce(calculateMealTotals, 300); const updateItemAmount = (id, newAmount) => { const item = currentMealItems.find(item => item.id === id); if (item) { item.amount = parseInt(newAmount) || 0; debouncedCalculate(); } }; const renderMealItems = () => { mealItemsList.innerHTML = ''; mealItemCount.textContent = `${currentMealItems.length} item${currentMealItems.length !== 1 ? 's' : ''}`; if (currentMealItems.length === 0) { mealItemsList.appendChild(emptyMealMsg); mealBuilderFooter.style.display = 'none'; return; } mealBuilderFooter.style.display = 'flex'; currentMealItems.forEach(item => { const row = document.createElement('div'); row.className = 'meal-item-row'; row.innerHTML = `
${item.name}
g
`; // Event Listeners for rows row.querySelector('.meal-weight-input').addEventListener('input', (e) => { updateItemAmount(item.id, e.target.value); }); row.querySelector('.remove-item-btn').addEventListener('click', () => { removeItemFromMeal(item.id); }); mealItemsList.appendChild(row); }); }; if (toggleMealBtn) { toggleMealBtn.addEventListener('click', toggleMealBuilder); } if (generateRecipeBtn) { generateRecipeBtn.addEventListener('click', () => { const activeItems = currentMealItems.filter(item => item.amount > 0); if (activeItems.length === 0) return; const itemStrings = activeItems.map(item => `${item.name} (${item.amount}g)`); const promptText = `Give me a recipe that contains: ${itemStrings.join(', ')}`; userInput.value = promptText; // UI Feedback userInput.focus(); userInput.style.height = 'auto'; userInput.style.height = userInput.scrollHeight + 'px'; sendBtn.disabled = false; // Smoothly scroll to the bottom to see the chat input window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' }); }); } // Also toggle when clicking the header document.querySelector('.meal-builder-header').addEventListener('click', (e) => { if (!e.target.closest('#toggle-meal-btn')) { toggleMealBuilder(); } }); function debounce(func, delay) { let timeout; return function(...args) { clearTimeout(timeout); timeout = setTimeout(() => func(...args), delay); }; } const performSearch = async (query) => { if (!query) { searchDropdown.style.display = 'none'; return; } searchDropdown.style.display = 'block'; searchDropdown.innerHTML = '
Searching local database...
'; try { const token = localStorage.getItem('localFoodToken'); const response = await fetch(`/api/food/search?q=${encodeURIComponent(query)}`, { headers: { 'Authorization': `Bearer ${token}` } }); if (response.status === 401) { setLoggedOutState(); addMessage('system', 'Your session has expired. Please log in again to use the food search.'); searchDropdown.style.display = 'none'; return; } if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const data = await response.json(); if (data.results && data.results.length > 0) { searchDropdown.innerHTML = ''; data.results.forEach(item => { const foodEl = document.createElement('div'); foodEl.className = 'food-item'; const badgeStr = item.category === "Sourced Ingredient" ? "Raw Ingredient" : item.category; foodEl.innerHTML = `
${item.name} ${badgeStr}
Cal: ${item.calories}
Pro: ${item.protein_g}g
Fat: ${item.fat_g}g
Carb: ${item.carbs_g}g
`; // Click on the header/name area auto-fills the chat foodEl.querySelector('.food-item-header').addEventListener('click', (e) => { e.stopPropagation(); userInput.value = `Can you build a meal around ${item.name} (${item.calories} cal, ${item.protein_g}g protein)?`; userInput.focus(); userInput.style.height = 'auto'; // Reset sendBtn.disabled = false; searchDropdown.style.display = 'none'; searchInput.value = ''; clearSearchBtn.style.display = 'none'; }); // Click on the add to meal button foodEl.querySelector('.add-meal-btn').addEventListener('click', (e) => { e.stopPropagation(); addItemToMeal(item); // Optional: provide visual feedback const btn = e.target; const originalText = btn.textContent; btn.textContent = '✅ Added'; btn.style.background = '#22c55e'; setTimeout(() => { btn.textContent = originalText; btn.style.background = ''; }, 1000); }); // Click on the details button toggles the panel foodEl.querySelector('.details-btn').addEventListener('click', (e) => { e.stopPropagation(); const panel = document.getElementById(`details-${item.id}`); toggleFoodDetails(item.id, panel, e.target); }); searchDropdown.appendChild(foodEl); }); } else { searchDropdown.innerHTML = '
No matching foods found.
'; } } catch (error) { console.error('Search error:', error); searchDropdown.innerHTML = '
Service currently unavailable. Please try again.
'; } }; const toggleFoodDetails = async (foodId, panel, btn) => { const isExpanded = panel.classList.contains('expanded'); if (isExpanded) { panel.classList.remove('expanded'); btn.textContent = '📊 Details'; return; } // If not loaded yet, fetch from API if (panel.innerHTML.trim() === '' || panel.innerHTML.includes('Loading')) { console.log(`[UI] Fetching details for food ${foodId}...`); panel.innerHTML = '
Loading details...
'; panel.classList.add('expanded'); // Show loading state try { const token = localStorage.getItem('localFoodToken'); const response = await fetch(`/api/food/${foodId}`, { headers: { 'Authorization': `Bearer ${token}` } }); if (!response.ok) throw new Error("Failed to fetch details"); const data = await response.json(); renderFoodDetails(panel, data); } catch (err) { console.error(err); panel.innerHTML = '
Error loading details.
'; } } else { panel.classList.add('expanded'); } btn.textContent = '✖ Close'; }; const renderFoodDetails = (panel, data) => { panel.innerHTML = `
Extended Nutrition
Fiber${data.extended.fiber_g}g
Sugar${data.extended.sugar_g}g
Cholesterol${data.extended.cholesterol_mg}mg
Vitamins
Vitamin A${data.vitamins.vitamin_a_iu}IU
Vitamin C${data.vitamins.vitamin_c_mg}mg
Minerals
Calcium${data.minerals.calcium_mg}mg
Iron${data.minerals.iron_mg}mg
Potassium${data.minerals.potassium_mg}mg
Sodium${data.minerals.sodium_mg}mg
`; }; const handleSearchInput = debounce((e) => { const query = e.target.value.trim(); if (query.length > 0) { clearSearchBtn.style.display = 'block'; performSearch(query); } else { clearSearchBtn.style.display = 'none'; searchDropdown.style.display = 'none'; } }, 300); if (searchInput) { searchInput.addEventListener('input', handleSearchInput); } if (clearSearchBtn) { clearSearchBtn.addEventListener('click', () => { searchInput.value = ''; clearSearchBtn.style.display = 'none'; searchDropdown.style.display = 'none'; searchInput.focus(); }); } document.addEventListener('click', (e) => { if (!e.target.closest('#food-search-module')) { if (searchDropdown) searchDropdown.style.display = 'none'; } }); // Initialize state sendBtn.disabled = true; // --- Saved Meals Logic (Sprint 8) --- const saveMealModal = document.getElementById('save-meal-modal'); const openSaveModalBtn = document.getElementById('save-meal-btn'); const cancelSaveBtn = document.getElementById('cancel-save-btn'); const confirmSaveBtn = document.getElementById('confirm-save-btn'); const mealNameInput = document.getElementById('meal-name-input'); const saveMealError = document.getElementById('save-meal-error'); if (openSaveModalBtn) { openSaveModalBtn.addEventListener('click', (e) => { e.stopPropagation(); if (currentMealItems.length === 0) return; saveMealModal.style.display = 'flex'; mealNameInput.value = ''; saveMealError.textContent = ''; mealNameInput.focus(); }); } const closeSaveModal = () => { saveMealModal.style.display = 'none'; }; if (cancelSaveBtn) cancelSaveBtn.addEventListener('click', closeSaveModal); if (confirmSaveBtn) { confirmSaveBtn.addEventListener('click', async () => { const name = mealNameInput.value.trim(); if (!name) { saveMealError.textContent = 'Please enter a name for your meal.'; return; } confirmSaveBtn.disabled = true; confirmSaveBtn.textContent = 'Saving...'; try { const token = localStorage.getItem('localFoodToken'); const response = await fetch('/api/meals', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, body: JSON.stringify({ name: name, items: currentMealItems.map(item => ({ food_id: item.id, amount_g: item.amount })) }) }); if (response.ok) { closeSaveModal(); addMessage('system', `✅ Successfully saved "${name}" to your dashboard!`); // Trigger a refresh of the dashboard if it's open if (window.refreshDashboard) window.refreshDashboard(); } else { const data = await response.json(); saveMealError.textContent = data.detail || 'Failed to save meal.'; } } catch (err) { console.error('Save meal error:', err); saveMealError.textContent = 'Server error. Please try again.'; } finally { confirmSaveBtn.disabled = false; confirmSaveBtn.textContent = 'Save to Dashboard'; } }); } // Close modal on outside click window.addEventListener('click', (e) => { if (e.target === saveMealModal) { closeSaveModal(); } }); });