|
@@ -0,0 +1,1045 @@
|
|
|
|
|
+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';
|
|
|
|
|
+ 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);
|
|
|
|
|
+ } 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('open-save-modal-btn');
|
|
|
|
|
+ const closeSaveModalBtn = document.getElementById('close-save-modal-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 (closeSaveModalBtn) closeSaveModalBtn.addEventListener('click', closeSaveModal);
|
|
|
|
|
+ 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 implemented later
|
|
|
|
|
+ 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>
|
|
|
|
|
+ `;
|
|
|
|
|
+ dashboardGrid.appendChild(card);
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+});
|