| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119 |
- 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 += `<br><span style="color:#f85149">Error: ${data.error}</span>`;
- } 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 = `
- <div class="typing-dot"></div>
- <div class="typing-dot"></div>
- <div class="typing-dot"></div>
- `;
- 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(/>/g, ">")
- .replace(/\n/g, "<br>")
- .replace(/\*\*(.*?)\*\*/g, "<strong>$1</strong>") // bold
- .replace(/\*(.*?)\*/g, "<em>$1</em>") // italic
- .replace(/`(.*?)`/g, "<code style='background:rgba(255,255,255,0.1);padding:2px 4px;border-radius:4px'>$1</code>"); // 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 = `
- <div class="meal-item-name" title="${item.name}">${item.name}</div>
- <div class="meal-item-controls">
- <div class="weight-input-wrapper">
- <input type="number" value="${item.amount}" min="0" max="5000" data-id="${item.id}" class="meal-weight-input">
- <span class="weight-unit">g</span>
- </div>
- <button class="remove-item-btn" data-id="${item.id}" title="Remove item">
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"></path></svg>
- </button>
- </div>
- `;
- // 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 = '<div class="search-loading">Searching local database...</div>';
-
- 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 = `
- <div class="food-item-header">
- <span class="food-name">${item.name}</span>
- <span class="food-badge">${badgeStr}</span>
- </div>
- <div class="food-macros">
- <div class="macro-tag">Cal: <span>${item.calories}</span></div>
- <div class="macro-tag">Pro: <span>${item.protein_g}g</span></div>
- <div class="macro-tag">Fat: <span>${item.fat_g}g</span></div>
- <div class="macro-tag">Carb: <span>${item.carbs_g}g</span></div>
- </div>
- <div class="food-item-footer">
- <button class="add-meal-btn" data-id="${item.id}">➕ Add to Meal</button>
- <button class="details-btn" data-id="${item.id}">📊 Details</button>
- </div>
- <div class="details-panel" id="details-${item.id}"></div>
- `;
-
- // 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 = '<div class="search-empty">No matching foods found.</div>';
- }
- } catch (error) {
- console.error('Search error:', error);
- searchDropdown.innerHTML = '<div class="search-empty">Service currently unavailable. Please try again.</div>';
- }
- };
- 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 = '<div class="search-loading">Loading details...</div>';
- 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 = '<div class="search-empty">Error loading details.</div>';
- }
- } else {
- panel.classList.add('expanded');
- }
-
- btn.textContent = '✖ Close';
- };
- const renderFoodDetails = (panel, data) => {
- panel.innerHTML = `
- <div class="nutrient-section">
- <div class="nutrient-section-title">Extended Nutrition</div>
- <div class="nutrient-grid">
- <div class="nutrient-item"><span class="nutrient-label">Fiber</span><span class="nutrient-value">${data.extended.fiber_g}g</span></div>
- <div class="nutrient-item"><span class="nutrient-label">Sugar</span><span class="nutrient-value">${data.extended.sugar_g}g</span></div>
- <div class="nutrient-item"><span class="nutrient-label">Cholesterol</span><span class="nutrient-value">${data.extended.cholesterol_mg}mg</span></div>
- </div>
- </div>
- <div class="nutrient-section">
- <div class="nutrient-section-title">Vitamins</div>
- <div class="nutrient-grid">
- <div class="nutrient-item"><span class="nutrient-label">Vitamin A</span><span class="nutrient-value">${data.vitamins.vitamin_a_iu}IU</span></div>
- <div class="nutrient-item"><span class="nutrient-label">Vitamin C</span><span class="nutrient-value">${data.vitamins.vitamin_c_mg}mg</span></div>
- </div>
- </div>
- <div class="nutrient-section">
- <div class="nutrient-section-title">Minerals</div>
- <div class="nutrient-grid">
- <div class="nutrient-item"><span class="nutrient-label">Calcium</span><span class="nutrient-value">${data.minerals.calcium_mg}mg</span></div>
- <div class="nutrient-item"><span class="nutrient-label">Iron</span><span class="nutrient-value">${data.minerals.iron_mg}mg</span></div>
- <div class="nutrient-item"><span class="nutrient-label">Potassium</span><span class="nutrient-value">${data.minerals.potassium_mg}mg</span></div>
- <div class="nutrient-item"><span class="nutrient-label">Sodium</span><span class="nutrient-value">${data.minerals.sodium_mg}mg</span></div>
- </div>
- </div>
- `;
- };
- 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();
- }
- });
- // --- Dashboard Logic (Sprint 8) ---
- const dashboardOverlay = document.getElementById('dashboard-overlay');
- const openDashboardBtn = document.getElementById('nav-dashboard-btn');
- const closeDashboardBtn = document.getElementById('close-dashboard-btn');
- const dashboardGrid = document.getElementById('dashboard-grid');
- const dashboardEmptyMsg = document.getElementById('dashboard-empty-msg');
- const toggleDashboard = (show) => {
- if (show) {
- dashboardOverlay.style.display = 'flex';
- loadSavedMeals();
- } else {
- dashboardOverlay.style.display = 'none';
- }
- };
- if (openDashboardBtn) openDashboardBtn.addEventListener('click', () => toggleDashboard(true));
- if (closeDashboardBtn) closeDashboardBtn.addEventListener('click', () => toggleDashboard(false));
- async function loadSavedMeals() {
- dashboardGrid.innerHTML = '<div class="search-loading">Loading your meals...</div>';
- dashboardEmptyMsg.style.display = 'none';
- try {
- const token = localStorage.getItem('localFoodToken');
- const response = await fetch('/api/meals', {
- headers: { 'Authorization': `Bearer ${token}` }
- });
- if (response.ok) {
- const data = await response.json();
- renderDashboard(data.meals);
- } else {
- dashboardGrid.innerHTML = '<div class="error-text">Failed to load meals.</div>';
- }
- } catch (err) {
- console.error('Dashboard load error:', err);
- dashboardGrid.innerHTML = '<div class="error-text">Connection error.</div>';
- }
- }
- function renderDashboard(meals) {
- dashboardGrid.innerHTML = '';
- if (!meals || meals.length === 0) {
- dashboardEmptyMsg.style.display = 'block';
- return;
- }
- meals.forEach((meal, index) => {
- const card = document.createElement('div');
- card.className = 'meal-card';
- card.style.animationDelay = `${index * 0.07}s`;
- const dateStr = new Date(meal.created_at).toLocaleDateString(undefined, {
- month: 'short', day: 'numeric', year: 'numeric'
- });
- card.innerHTML = `
- <div class="meal-card-header">
- <span class="meal-card-name">${meal.name}</span>
- <span class="meal-card-date">${dateStr}</span>
- </div>
- <div class="meal-card-macros">
- <div class="card-macro kcal">
- <span class="card-macro-val">${Math.round(meal.total_calories)}</span>
- <span class="card-macro-lbl">kcal</span>
- </div>
- <div class="card-macro protein">
- <span class="card-macro-val">${Math.round(meal.total_protein)}g</span>
- <span class="card-macro-lbl">Protein</span>
- </div>
- <div class="card-macro carbs">
- <span class="card-macro-val">${Math.round(meal.total_carbs)}g</span>
- <span class="card-macro-lbl">Carbs</span>
- </div>
- <div class="card-macro fat">
- <span class="card-macro-val">${Math.round(meal.total_fat)}g</span>
- <span class="card-macro-lbl">Fat</span>
- </div>
- </div>
- <div class="meal-card-actions">
- <button class="edit-meal-btn" data-id="${meal.id}" data-name="${meal.name}" title="Rename Meal">
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"></path><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"></path></svg>
- </button>
- <button class="delete-meal-btn" data-id="${meal.id}" title="Delete Meal">
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"></path></svg>
- </button>
- </div>
- `;
- // Attach listeners to new buttons
- card.querySelector('.edit-meal-btn').addEventListener('click', (e) => {
- e.stopPropagation();
- const newName = prompt('Enter new name for this meal:', meal.name);
- if (newName && newName.trim() && newName !== meal.name) {
- renameMeal(meal.id, newName.trim());
- }
- });
- card.querySelector('.delete-meal-btn').addEventListener('click', (e) => {
- e.stopPropagation();
- if (confirm(`Are you sure you want to delete "${meal.name}"?`)) {
- deleteMeal(meal.id);
- }
- });
- dashboardGrid.appendChild(card);
- });
- }
- async function renameMeal(id, newName) {
- try {
- const token = localStorage.getItem('localFoodToken');
- const response = await fetch(`/api/meals/${id}`, {
- method: 'PUT',
- headers: {
- 'Content-Type': 'application/json',
- 'Authorization': `Bearer ${token}`
- },
- body: JSON.stringify({ name: newName })
- });
- if (response.ok) {
- loadSavedMeals(); // Refresh
- } else {
- alert('Failed to rename meal.');
- }
- } catch (err) {
- console.error('Rename error:', err);
- }
- }
- async function deleteMeal(id) {
- try {
- const token = localStorage.getItem('localFoodToken');
- const response = await fetch(`/api/meals/${id}`, {
- method: 'DELETE',
- headers: { 'Authorization': `Bearer ${token}` }
- });
- if (response.ok) {
- loadSavedMeals(); // Refresh
- } else {
- alert('Failed to delete meal.');
- }
- } catch (err) {
- console.error('Delete error:', err);
- }
- }
- // Attach refresh helper
- window.refreshDashboard = loadSavedMeals;
- });
|