script.js 45 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136
  1. document.addEventListener('DOMContentLoaded', () => {
  2. const chatForm = document.getElementById('chat-form');
  3. const userInput = document.getElementById('user-input');
  4. const chatContainer = document.getElementById('chat-container');
  5. const sendBtn = document.getElementById('send-btn');
  6. const clearChatBtn = document.getElementById('clear-chat');
  7. let chatHistory = []; // Store conversation history
  8. // Auto-resize textarea
  9. userInput.addEventListener('input', function() {
  10. this.style.height = 'auto';
  11. this.style.height = (this.scrollHeight > 150 ? 150 : this.scrollHeight) + 'px';
  12. if (this.value.trim() === '') {
  13. sendBtn.disabled = true;
  14. } else {
  15. sendBtn.disabled = false;
  16. }
  17. });
  18. // Handle Enter key (Shift+Enter for new line)
  19. userInput.addEventListener('keydown', function(e) {
  20. if (e.key === 'Enter' && !e.shiftKey) {
  21. e.preventDefault();
  22. if (!sendBtn.disabled) {
  23. chatForm.requestSubmit();
  24. }
  25. }
  26. });
  27. clearChatBtn.addEventListener('click', () => {
  28. if (confirm('Are you sure you want to clear the chat history?')) {
  29. chatHistory = [];
  30. chatContainer.innerHTML = '';
  31. addMessage('system', 'Hello! I am LocalFoodAI, your completely local nutrition and menu assistant. How can I help you today?');
  32. }
  33. });
  34. chatForm.addEventListener('submit', async (e) => {
  35. e.preventDefault();
  36. const message = userInput.value.trim();
  37. if (!message) return;
  38. // Reset input
  39. userInput.value = '';
  40. userInput.style.height = 'auto';
  41. sendBtn.disabled = true;
  42. // Add user message to UI
  43. addMessage('user', message);
  44. chatHistory.push({ role: 'user', content: message });
  45. // Add loading indicator
  46. const loadingId = addTypingIndicator();
  47. try {
  48. const token = localStorage.getItem('localFoodToken');
  49. // Fetch response from backend
  50. const response = await fetch('/chat', {
  51. method: 'POST',
  52. headers: {
  53. 'Content-Type': 'application/json',
  54. 'Authorization': `Bearer ${token}`
  55. },
  56. body: JSON.stringify({ messages: chatHistory })
  57. });
  58. if (response.status === 401) {
  59. setLoggedOutState();
  60. addMessage('system', 'Your session has expired. Please log in again.');
  61. return;
  62. }
  63. if (!response.ok) {
  64. throw new Error(`HTTP error! status: ${response.status}`);
  65. }
  66. // Remove loading indicator
  67. removeElement(loadingId);
  68. // Create new bot message container
  69. const botMessageId = 'msg-' + Date.now();
  70. const botContentEl = addMessage('system', '', botMessageId);
  71. let botFullResponse = '';
  72. // Handle Server-Sent Events (Streaming)
  73. const reader = response.body.getReader();
  74. const decoder = new TextDecoder('utf-8');
  75. let done = false;
  76. while (!done) {
  77. const { value, done: readerDone } = await reader.read();
  78. done = readerDone;
  79. if (value) {
  80. const chunk = decoder.decode(value, { stream: true });
  81. // Split the chunk by double newline to get individual SSE messages
  82. const lines = chunk.split('\n\n');
  83. for (const line of lines) {
  84. if (line.startsWith('data: ')) {
  85. const dataStr = line.substring(6);
  86. if (dataStr.trim() === '') continue;
  87. try {
  88. const data = JSON.parse(dataStr);
  89. if (data.error) {
  90. botContentEl.innerHTML += `<br><span style="color:#f85149">Error: ${data.error}</span>`;
  91. } else if (data.content !== undefined) {
  92. botFullResponse += data.content;
  93. // Basic text to HTML conversion
  94. botContentEl.innerHTML = formatText(botFullResponse);
  95. chatContainer.scrollTop = chatContainer.scrollHeight;
  96. }
  97. } catch (err) {
  98. console.error('Error parsing SSE data:', err, dataStr);
  99. }
  100. }
  101. }
  102. }
  103. }
  104. // Save bot response to history once complete
  105. chatHistory.push({ role: 'assistant', content: botFullResponse });
  106. } catch (error) {
  107. console.error('Chat error:', error);
  108. removeElement(loadingId);
  109. addMessage('system', 'Sorry, there was an error communicating with the local LLM. Make sure the server and Ollama are running.');
  110. } finally {
  111. sendBtn.disabled = false;
  112. userInput.focus();
  113. }
  114. });
  115. function addMessage(role, content, id = null) {
  116. const msgDiv = document.createElement('div');
  117. msgDiv.className = `message ${role}`;
  118. if (id) msgDiv.id = id;
  119. const avatarDiv = document.createElement('div');
  120. avatarDiv.className = 'avatar';
  121. avatarDiv.textContent = role === 'user' ? '👤' : '🤖';
  122. const contentDiv = document.createElement('div');
  123. contentDiv.className = 'message-content';
  124. contentDiv.innerHTML = formatText(content);
  125. msgDiv.appendChild(avatarDiv);
  126. msgDiv.appendChild(contentDiv);
  127. chatContainer.appendChild(msgDiv);
  128. chatContainer.scrollTop = chatContainer.scrollHeight;
  129. return contentDiv;
  130. }
  131. function addTypingIndicator() {
  132. const id = 'typing-' + Date.now();
  133. const msgDiv = document.createElement('div');
  134. msgDiv.className = 'message system';
  135. msgDiv.id = id;
  136. const avatarDiv = document.createElement('div');
  137. avatarDiv.className = 'avatar';
  138. avatarDiv.textContent = '🤖';
  139. const contentDiv = document.createElement('div');
  140. contentDiv.className = 'message-content typing-indicator';
  141. contentDiv.innerHTML = `
  142. <div class="typing-dot"></div>
  143. <div class="typing-dot"></div>
  144. <div class="typing-dot"></div>
  145. `;
  146. msgDiv.appendChild(avatarDiv);
  147. msgDiv.appendChild(contentDiv);
  148. chatContainer.appendChild(msgDiv);
  149. chatContainer.scrollTop = chatContainer.scrollHeight;
  150. return id;
  151. }
  152. function removeElement(id) {
  153. const el = document.getElementById(id);
  154. if (el) el.remove();
  155. }
  156. function formatText(text) {
  157. if (!text) return '';
  158. // Very basic markdown parsing for bold, italics, code, and newlines
  159. let formatted = text
  160. .replace(/&/g, "&amp;")
  161. .replace(/</g, "&lt;")
  162. .replace(/>/g, "&gt;")
  163. .replace(/\n/g, "<br>")
  164. .replace(/\*\*(.*?)\*\*/g, "<strong>$1</strong>") // bold
  165. .replace(/\*(.*?)\*/g, "<em>$1</em>") // italic
  166. .replace(/`(.*?)`/g, "<code style='background:rgba(255,255,255,0.1);padding:2px 4px;border-radius:4px'>$1</code>"); // inline code
  167. return formatted;
  168. }
  169. // Authentication & Session Logic
  170. const authScreen = document.getElementById('auth-screen');
  171. const chatApp = document.getElementById('chat-app');
  172. const loginForm = document.getElementById('login-form');
  173. const registerForm = document.getElementById('register-form');
  174. const showRegisterLink = document.getElementById('show-register');
  175. const showLoginLink = document.getElementById('show-login');
  176. const loginError = document.getElementById('login-error');
  177. const regError = document.getElementById('reg-error');
  178. const regSuccess = document.getElementById('reg-success');
  179. const logoutBtn = document.getElementById('nav-logout-btn');
  180. const userGreeting = document.getElementById('user-greeting');
  181. // Check session on load
  182. const savedUser = localStorage.getItem('localFoodUser');
  183. const savedToken = localStorage.getItem('localFoodToken');
  184. if (savedUser && savedToken) {
  185. setLoggedInState(savedUser, savedToken);
  186. }
  187. async function setLoggedInState(username, token) {
  188. localStorage.setItem('localFoodUser', username);
  189. localStorage.setItem('localFoodToken', token);
  190. userGreeting.textContent = `Welcome, ${username}`;
  191. authScreen.classList.add('fade-out');
  192. setTimeout(() => {
  193. authScreen.style.display = 'none';
  194. chatApp.style.display = 'flex';
  195. chatApp.classList.add('fade-in');
  196. userInput.focus();
  197. }, 500);
  198. // Load persisted chat history and macro targets from the server
  199. await loadChatHistory();
  200. await loadMacroTargets();
  201. }
  202. async function loadChatHistory() {
  203. const token = localStorage.getItem('localFoodToken');
  204. if (!token) return;
  205. try {
  206. const response = await fetch('/api/chat/history', {
  207. headers: { 'Authorization': `Bearer ${token}` }
  208. });
  209. if (response.status === 401) {
  210. setLoggedOutState();
  211. return;
  212. }
  213. if (response.ok) {
  214. const data = await response.json();
  215. if (data.history && data.history.length > 0) {
  216. // Clear initial welcome message if we have real history
  217. chatContainer.innerHTML = '';
  218. chatHistory = []; // Reset local state
  219. data.history.forEach(msg => {
  220. addMessage(msg.role, msg.content);
  221. chatHistory.push({ role: msg.role, content: msg.content });
  222. });
  223. }
  224. }
  225. } catch (err) {
  226. console.error("Failed to load chat history:", err);
  227. }
  228. }
  229. async function loadMacroTargets() {
  230. const token = localStorage.getItem('localFoodToken');
  231. if (!token) return;
  232. const dashboard = document.getElementById('macro-dashboard');
  233. try {
  234. const response = await fetch('/api/macros/targets', {
  235. headers: { 'Authorization': `Bearer ${token}` }
  236. });
  237. if (response.ok) {
  238. const data = await response.json();
  239. document.getElementById('macro-calories').textContent = `${data.calories} kcal`;
  240. document.getElementById('macro-protein').textContent = `${data.protein_g} g`;
  241. document.getElementById('macro-carbs').textContent = `${data.carbs_g} g`;
  242. document.getElementById('macro-fat').textContent = `${data.fat_g} g`;
  243. if (dashboard) dashboard.style.display = 'flex';
  244. }
  245. } catch (err) {
  246. console.error('Failed to load macro targets:', err);
  247. }
  248. }
  249. async function setLoggedOutState() {
  250. const token = localStorage.getItem('localFoodToken');
  251. if (token) {
  252. try {
  253. await fetch('/api/logout', {
  254. method: 'POST',
  255. headers: { 'Authorization': `Bearer ${token}` }
  256. });
  257. } catch (err) {
  258. console.error("Error during logout:", err);
  259. }
  260. }
  261. // Clear chat memory and interface so the next user doesn't see old messages
  262. chatHistory = [];
  263. chatContainer.innerHTML = '';
  264. addMessage('system', 'Hello! I am LocalFoodAI, your completely local nutrition and menu assistant. How can I help you today?');
  265. localStorage.removeItem('localFoodUser');
  266. localStorage.removeItem('localFoodToken');
  267. chatApp.style.display = 'none';
  268. chatApp.classList.remove('fade-in');
  269. const dashboard = document.getElementById('macro-dashboard');
  270. if (dashboard) dashboard.style.display = 'none';
  271. authScreen.style.display = 'block';
  272. setTimeout(() => {
  273. authScreen.classList.remove('fade-out');
  274. }, 50);
  275. loginForm.reset();
  276. registerForm.reset();
  277. loginError.textContent = '';
  278. regError.textContent = '';
  279. }
  280. logoutBtn.addEventListener('click', () => {
  281. setLoggedOutState();
  282. });
  283. // Toggles
  284. showRegisterLink.addEventListener('click', (e) => {
  285. e.preventDefault();
  286. loginForm.style.display = 'none';
  287. registerForm.style.display = 'block';
  288. });
  289. showLoginLink.addEventListener('click', (e) => {
  290. e.preventDefault();
  291. registerForm.style.display = 'none';
  292. loginForm.style.display = 'block';
  293. });
  294. // Login Submission
  295. loginForm.addEventListener('submit', async (e) => {
  296. e.preventDefault();
  297. loginError.textContent = '';
  298. const submitBtn = document.getElementById('login-submit-btn');
  299. submitBtn.disabled = true;
  300. const username = document.getElementById('login-username').value;
  301. const password = document.getElementById('login-password').value;
  302. try {
  303. const response = await fetch('/api/login', {
  304. method: 'POST',
  305. headers: { 'Content-Type': 'application/json' },
  306. body: JSON.stringify({ username, password })
  307. });
  308. const data = await response.json();
  309. if (!response.ok) {
  310. loginError.textContent = data.detail || 'Login failed.';
  311. } else {
  312. setLoggedInState(data.username, data.token);
  313. }
  314. } catch (err) {
  315. loginError.textContent = 'Server error. Is the backend running?';
  316. } finally {
  317. submitBtn.disabled = false;
  318. }
  319. });
  320. // Registration Submission
  321. registerForm.addEventListener('submit', async (e) => {
  322. e.preventDefault();
  323. regError.textContent = '';
  324. regSuccess.textContent = '';
  325. const submitBtn = document.getElementById('reg-submit-btn');
  326. submitBtn.disabled = true;
  327. const username = document.getElementById('reg-username').value;
  328. const password = document.getElementById('reg-password').value;
  329. const confirmInfo = document.getElementById('reg-confirm').value;
  330. if (password !== confirmInfo) {
  331. regError.textContent = "Passwords do not match.";
  332. submitBtn.disabled = false;
  333. return;
  334. }
  335. try {
  336. const response = await fetch('/api/register', {
  337. method: 'POST',
  338. headers: { 'Content-Type': 'application/json' },
  339. body: JSON.stringify({ username, password })
  340. });
  341. const data = await response.json();
  342. if (!response.ok) {
  343. regError.textContent = data.detail || 'Registration failed.';
  344. } else {
  345. regSuccess.textContent = 'Account created! Logging in...';
  346. setTimeout(() => {
  347. setLoggedInState(data.username, data.token);
  348. }, 1000);
  349. }
  350. } catch (err) {
  351. regError.textContent = 'Server error. Please try again later.';
  352. } finally {
  353. submitBtn.disabled = false;
  354. }
  355. });
  356. // --- Food Search Module Logic ---
  357. const searchInput = document.getElementById('food-search-input');
  358. const searchDropdown = document.getElementById('search-results-dropdown');
  359. const clearSearchBtn = document.getElementById('clear-search-btn');
  360. // --- Meal Builder State & UI (US-10 Task #46) ---
  361. let currentMealItems = [];
  362. const mealBuilder = document.getElementById('meal-builder');
  363. const mealContent = document.getElementById('meal-content');
  364. const mealItemsList = document.getElementById('meal-items-list');
  365. const mealItemCount = document.getElementById('meal-item-count');
  366. const toggleMealBtn = document.getElementById('toggle-meal-btn');
  367. const emptyMealMsg = document.getElementById('empty-meal-msg');
  368. const mealBuilderFooter = document.getElementById('meal-builder-footer');
  369. const generateRecipeBtn = document.getElementById('generate-recipe-btn');
  370. const toggleMealBuilder = () => {
  371. const isCollapsed = mealContent.classList.contains('collapsed');
  372. if (isCollapsed) {
  373. mealContent.classList.remove('collapsed');
  374. toggleMealBtn.style.transform = 'rotate(180deg)';
  375. } else {
  376. mealContent.classList.add('collapsed');
  377. toggleMealBtn.style.transform = 'rotate(0deg)';
  378. }
  379. };
  380. const addItemToMeal = (food) => {
  381. // Duplicate Handling: Merge by updating grams
  382. const existingItem = currentMealItems.find(item => item.id === food.id);
  383. if (existingItem) {
  384. existingItem.amount += 100;
  385. // Directly update the DOM input for this item to avoid re-rendering the whole list
  386. const inputEl = document.querySelector(`.meal-weight-input[data-id="${food.id}"]`);
  387. if (inputEl) {
  388. inputEl.value = existingItem.amount;
  389. // Animate the row slightly to show it was updated
  390. const rowEl = inputEl.closest('.meal-item-row');
  391. if (rowEl) {
  392. rowEl.style.transform = 'scale(1.02)';
  393. rowEl.style.background = 'rgba(255, 255, 255, 0.1)';
  394. setTimeout(() => {
  395. rowEl.style.transform = '';
  396. rowEl.style.background = 'rgba(255, 255, 255, 0.05)';
  397. }, 200);
  398. }
  399. }
  400. } else {
  401. currentMealItems.push({
  402. id: food.id,
  403. name: food.name,
  404. amount: 100,
  405. base_macros: {
  406. calories: food.calories,
  407. protein_g: food.protein_g,
  408. fat_g: food.fat_g,
  409. carbs_g: food.carbs_g
  410. }
  411. });
  412. renderMealItems(); // Only re-render if it's a new item
  413. }
  414. // Ensure builder is expanded when adding an item
  415. if (mealContent.classList.contains('collapsed')) {
  416. toggleMealBuilder();
  417. }
  418. // Trigger calculation
  419. calculateMealTotals();
  420. };
  421. const removeItemFromMeal = (id) => {
  422. currentMealItems = currentMealItems.filter(item => item.id !== id);
  423. renderMealItems();
  424. calculateMealTotals();
  425. };
  426. let calculateAbortController = null;
  427. const calculateMealTotals = async () => {
  428. const activeItems = currentMealItems.filter(item => item.amount > 0);
  429. // DOM Elements
  430. const totalsBanner = document.getElementById('meal-totals-banner');
  431. const totalWeightEl = document.getElementById('meal-total-weight');
  432. const totalCalEl = document.getElementById('meal-total-cal');
  433. const totalProEl = document.getElementById('meal-total-pro');
  434. const totalFatEl = document.getElementById('meal-total-fat');
  435. const totalCarbEl = document.getElementById('meal-total-carb');
  436. if (activeItems.length === 0) {
  437. if (totalsBanner) totalsBanner.classList.remove('has-error');
  438. if (totalWeightEl) totalWeightEl.textContent = '0g';
  439. if (totalCalEl) totalCalEl.textContent = '0';
  440. if (totalProEl) totalProEl.textContent = '0';
  441. if (totalFatEl) totalFatEl.textContent = '0';
  442. if (totalCarbEl) totalCarbEl.textContent = '0';
  443. const saveBtn = document.getElementById('save-meal-btn');
  444. if (saveBtn) saveBtn.style.display = 'none';
  445. return;
  446. }
  447. // Race Condition Protection: Abort previous request
  448. if (calculateAbortController) {
  449. calculateAbortController.abort();
  450. }
  451. calculateAbortController = new AbortController();
  452. try {
  453. const token = localStorage.getItem('localFoodToken');
  454. const payload = {
  455. items: activeItems.map(item => ({
  456. food_id: item.id,
  457. amount_g: item.amount
  458. }))
  459. };
  460. const response = await fetch('/api/meal/calculate', {
  461. method: 'POST',
  462. headers: {
  463. 'Content-Type': 'application/json',
  464. 'Authorization': `Bearer ${token}`
  465. },
  466. body: JSON.stringify(payload),
  467. signal: calculateAbortController.signal
  468. });
  469. if (response.ok) {
  470. const data = await response.json();
  471. if (totalsBanner) totalsBanner.classList.remove('has-error');
  472. // Update UI with animation for "alive" feel
  473. if (totalWeightEl) totalWeightEl.textContent = `${data.total_weight_g}g`;
  474. if (totalCalEl) animateValue(totalCalEl, data.macros.calories);
  475. if (totalProEl) animateValue(totalProEl, data.macros.protein_g);
  476. if (totalFatEl) animateValue(totalFatEl, data.macros.fat_g);
  477. if (totalCarbEl) animateValue(totalCarbEl, data.macros.carbs_g);
  478. // Show Save button (Sprint 8)
  479. const saveBtn = document.getElementById('save-meal-btn');
  480. if (saveBtn) saveBtn.style.display = 'flex';
  481. } else {
  482. if (totalsBanner) totalsBanner.classList.add('has-error');
  483. }
  484. } catch (err) {
  485. if (err.name === 'AbortError') return; // Ignore expected aborts
  486. console.error('Failed to calculate meal totals:', err);
  487. if (totalsBanner) totalsBanner.classList.add('has-error');
  488. }
  489. };
  490. // Helper for smooth number transitions
  491. const animateValue = (el, targetValue) => {
  492. const startValue = parseFloat(el.textContent) || 0;
  493. const duration = 400;
  494. const startTime = performance.now();
  495. const update = (currentTime) => {
  496. const elapsed = currentTime - startTime;
  497. const progress = Math.min(elapsed / duration, 1);
  498. // Ease out quad
  499. const ease = progress * (2 - progress);
  500. const currentValue = startValue + (targetValue - startValue) * ease;
  501. el.textContent = targetValue % 1 === 0 ? Math.round(currentValue) : currentValue.toFixed(1);
  502. if (progress < 1) {
  503. requestAnimationFrame(update);
  504. } else {
  505. el.textContent = targetValue;
  506. }
  507. };
  508. requestAnimationFrame(update);
  509. };
  510. const debouncedCalculate = debounce(calculateMealTotals, 300);
  511. const updateItemAmount = (id, newAmount) => {
  512. const item = currentMealItems.find(item => item.id === id);
  513. if (item) {
  514. item.amount = parseInt(newAmount) || 0;
  515. debouncedCalculate();
  516. }
  517. };
  518. const renderMealItems = () => {
  519. mealItemsList.innerHTML = '';
  520. mealItemCount.textContent = `${currentMealItems.length} item${currentMealItems.length !== 1 ? 's' : ''}`;
  521. if (currentMealItems.length === 0) {
  522. mealItemsList.appendChild(emptyMealMsg);
  523. mealBuilderFooter.style.display = 'none';
  524. return;
  525. }
  526. mealBuilderFooter.style.display = 'flex';
  527. currentMealItems.forEach(item => {
  528. const row = document.createElement('div');
  529. row.className = 'meal-item-row';
  530. row.innerHTML = `
  531. <div class="meal-item-name" title="${item.name}">${item.name}</div>
  532. <div class="meal-item-controls">
  533. <div class="weight-input-wrapper">
  534. <input type="number" value="${item.amount}" min="0" max="5000" data-id="${item.id}" class="meal-weight-input">
  535. <span class="weight-unit">g</span>
  536. </div>
  537. <button class="remove-item-btn" data-id="${item.id}" title="Remove item">
  538. <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>
  539. </button>
  540. </div>
  541. `;
  542. // Event Listeners for rows
  543. row.querySelector('.meal-weight-input').addEventListener('input', (e) => {
  544. updateItemAmount(item.id, e.target.value);
  545. });
  546. row.querySelector('.remove-item-btn').addEventListener('click', () => {
  547. removeItemFromMeal(item.id);
  548. });
  549. mealItemsList.appendChild(row);
  550. });
  551. };
  552. if (toggleMealBtn) {
  553. toggleMealBtn.addEventListener('click', toggleMealBuilder);
  554. }
  555. if (generateRecipeBtn) {
  556. generateRecipeBtn.addEventListener('click', () => {
  557. const activeItems = currentMealItems.filter(item => item.amount > 0);
  558. if (activeItems.length === 0) return;
  559. const itemStrings = activeItems.map(item => `${item.name} (${item.amount}g)`);
  560. const promptText = `Give me a recipe that contains: ${itemStrings.join(', ')}`;
  561. userInput.value = promptText;
  562. // UI Feedback
  563. userInput.focus();
  564. userInput.style.height = 'auto';
  565. userInput.style.height = userInput.scrollHeight + 'px';
  566. sendBtn.disabled = false;
  567. // Smoothly scroll to the bottom to see the chat input
  568. window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
  569. });
  570. }
  571. // Also toggle when clicking the header
  572. document.querySelector('.meal-builder-header').addEventListener('click', (e) => {
  573. if (!e.target.closest('#toggle-meal-btn')) {
  574. toggleMealBuilder();
  575. }
  576. });
  577. function debounce(func, delay) {
  578. let timeout;
  579. return function(...args) {
  580. clearTimeout(timeout);
  581. timeout = setTimeout(() => func(...args), delay);
  582. };
  583. }
  584. const performSearch = async (query) => {
  585. if (!query) {
  586. searchDropdown.style.display = 'none';
  587. return;
  588. }
  589. searchDropdown.style.display = 'block';
  590. searchDropdown.innerHTML = '<div class="search-loading">Searching local database...</div>';
  591. try {
  592. const token = localStorage.getItem('localFoodToken');
  593. const response = await fetch(`/api/food/search?q=${encodeURIComponent(query)}`, {
  594. headers: { 'Authorization': `Bearer ${token}` }
  595. });
  596. if (response.status === 401) {
  597. setLoggedOutState();
  598. addMessage('system', 'Your session has expired. Please log in again to use the food search.');
  599. searchDropdown.style.display = 'none';
  600. return;
  601. }
  602. if (!response.ok) {
  603. throw new Error(`HTTP error! status: ${response.status}`);
  604. }
  605. const data = await response.json();
  606. if (data.results && data.results.length > 0) {
  607. searchDropdown.innerHTML = '';
  608. data.results.forEach(item => {
  609. const foodEl = document.createElement('div');
  610. foodEl.className = 'food-item';
  611. const badgeStr = item.category === "Sourced Ingredient" ? "Raw Ingredient" : item.category;
  612. foodEl.innerHTML = `
  613. <div class="food-item-header">
  614. <span class="food-name">${item.name}</span>
  615. <span class="food-badge">${badgeStr}</span>
  616. </div>
  617. <div class="food-macros">
  618. <div class="macro-tag">Cal: <span>${item.calories}</span></div>
  619. <div class="macro-tag">Pro: <span>${item.protein_g}g</span></div>
  620. <div class="macro-tag">Fat: <span>${item.fat_g}g</span></div>
  621. <div class="macro-tag">Carb: <span>${item.carbs_g}g</span></div>
  622. </div>
  623. <div class="food-item-footer">
  624. <button class="add-meal-btn" data-id="${item.id}">➕ Add to Meal</button>
  625. <button class="details-btn" data-id="${item.id}">📊 Details</button>
  626. </div>
  627. <div class="details-panel" id="details-${item.id}"></div>
  628. `;
  629. // Click on the header/name area auto-fills the chat
  630. foodEl.querySelector('.food-item-header').addEventListener('click', (e) => {
  631. e.stopPropagation();
  632. userInput.value = `Can you build a meal around ${item.name} (${item.calories} cal, ${item.protein_g}g protein)?`;
  633. userInput.focus();
  634. userInput.style.height = 'auto'; // Reset
  635. sendBtn.disabled = false;
  636. searchDropdown.style.display = 'none';
  637. searchInput.value = '';
  638. clearSearchBtn.style.display = 'none';
  639. });
  640. // Click on the add to meal button
  641. foodEl.querySelector('.add-meal-btn').addEventListener('click', (e) => {
  642. e.stopPropagation();
  643. addItemToMeal(item);
  644. // Optional: provide visual feedback
  645. const btn = e.target;
  646. const originalText = btn.textContent;
  647. btn.textContent = '✅ Added';
  648. btn.style.background = '#22c55e';
  649. setTimeout(() => {
  650. btn.textContent = originalText;
  651. btn.style.background = '';
  652. }, 1000);
  653. });
  654. // Click on the details button toggles the panel
  655. foodEl.querySelector('.details-btn').addEventListener('click', (e) => {
  656. e.stopPropagation();
  657. const panel = document.getElementById(`details-${item.id}`);
  658. toggleFoodDetails(item.id, panel, e.target);
  659. });
  660. searchDropdown.appendChild(foodEl);
  661. });
  662. } else {
  663. searchDropdown.innerHTML = '<div class="search-empty">No matching foods found.</div>';
  664. }
  665. } catch (error) {
  666. console.error('Search error:', error);
  667. searchDropdown.innerHTML = '<div class="search-empty">Service currently unavailable. Please try again.</div>';
  668. }
  669. };
  670. const toggleFoodDetails = async (foodId, panel, btn) => {
  671. const isExpanded = panel.classList.contains('expanded');
  672. if (isExpanded) {
  673. panel.classList.remove('expanded');
  674. btn.textContent = '📊 Details';
  675. return;
  676. }
  677. // If not loaded yet, fetch from API
  678. if (panel.innerHTML.trim() === '' || panel.innerHTML.includes('Loading')) {
  679. console.log(`[UI] Fetching details for food ${foodId}...`);
  680. panel.innerHTML = '<div class="search-loading">Loading details...</div>';
  681. panel.classList.add('expanded'); // Show loading state
  682. try {
  683. const token = localStorage.getItem('localFoodToken');
  684. const response = await fetch(`/api/food/${foodId}`, {
  685. headers: { 'Authorization': `Bearer ${token}` }
  686. });
  687. if (!response.ok) throw new Error("Failed to fetch details");
  688. const data = await response.json();
  689. renderFoodDetails(panel, data);
  690. } catch (err) {
  691. console.error(err);
  692. panel.innerHTML = '<div class="search-empty">Error loading details.</div>';
  693. }
  694. } else {
  695. panel.classList.add('expanded');
  696. }
  697. btn.textContent = '✖ Close';
  698. };
  699. const renderFoodDetails = (panel, data) => {
  700. panel.innerHTML = `
  701. <div class="nutrient-section">
  702. <div class="nutrient-section-title">Extended Nutrition</div>
  703. <div class="nutrient-grid">
  704. <div class="nutrient-item"><span class="nutrient-label">Fiber</span><span class="nutrient-value">${data.extended.fiber_g}g</span></div>
  705. <div class="nutrient-item"><span class="nutrient-label">Sugar</span><span class="nutrient-value">${data.extended.sugar_g}g</span></div>
  706. <div class="nutrient-item"><span class="nutrient-label">Cholesterol</span><span class="nutrient-value">${data.extended.cholesterol_mg}mg</span></div>
  707. </div>
  708. </div>
  709. <div class="nutrient-section">
  710. <div class="nutrient-section-title">Vitamins</div>
  711. <div class="nutrient-grid">
  712. <div class="nutrient-item"><span class="nutrient-label">Vitamin A</span><span class="nutrient-value">${data.vitamins.vitamin_a_iu}IU</span></div>
  713. <div class="nutrient-item"><span class="nutrient-label">Vitamin C</span><span class="nutrient-value">${data.vitamins.vitamin_c_mg}mg</span></div>
  714. </div>
  715. </div>
  716. <div class="nutrient-section">
  717. <div class="nutrient-section-title">Minerals</div>
  718. <div class="nutrient-grid">
  719. <div class="nutrient-item"><span class="nutrient-label">Calcium</span><span class="nutrient-value">${data.minerals.calcium_mg}mg</span></div>
  720. <div class="nutrient-item"><span class="nutrient-label">Iron</span><span class="nutrient-value">${data.minerals.iron_mg}mg</span></div>
  721. <div class="nutrient-item"><span class="nutrient-label">Potassium</span><span class="nutrient-value">${data.minerals.potassium_mg}mg</span></div>
  722. <div class="nutrient-item"><span class="nutrient-label">Sodium</span><span class="nutrient-value">${data.minerals.sodium_mg}mg</span></div>
  723. </div>
  724. </div>
  725. `;
  726. };
  727. const handleSearchInput = debounce((e) => {
  728. const query = e.target.value.trim();
  729. if (query.length > 0) {
  730. clearSearchBtn.style.display = 'block';
  731. performSearch(query);
  732. } else {
  733. clearSearchBtn.style.display = 'none';
  734. searchDropdown.style.display = 'none';
  735. }
  736. }, 300);
  737. if (searchInput) {
  738. searchInput.addEventListener('input', handleSearchInput);
  739. }
  740. if (clearSearchBtn) {
  741. clearSearchBtn.addEventListener('click', () => {
  742. searchInput.value = '';
  743. clearSearchBtn.style.display = 'none';
  744. searchDropdown.style.display = 'none';
  745. searchInput.focus();
  746. });
  747. }
  748. document.addEventListener('click', (e) => {
  749. if (!e.target.closest('#food-search-module')) {
  750. if (searchDropdown) searchDropdown.style.display = 'none';
  751. }
  752. });
  753. // Initialize state
  754. sendBtn.disabled = true;
  755. // --- Saved Meals Logic (Sprint 8) ---
  756. const saveMealModal = document.getElementById('save-meal-modal');
  757. const openSaveModalBtn = document.getElementById('save-meal-btn');
  758. const cancelSaveBtn = document.getElementById('cancel-save-btn');
  759. const confirmSaveBtn = document.getElementById('confirm-save-btn');
  760. const mealNameInput = document.getElementById('meal-name-input');
  761. const saveMealError = document.getElementById('save-meal-error');
  762. if (openSaveModalBtn) {
  763. openSaveModalBtn.addEventListener('click', (e) => {
  764. e.stopPropagation();
  765. if (currentMealItems.length === 0) return;
  766. saveMealModal.style.display = 'flex';
  767. mealNameInput.value = '';
  768. saveMealError.textContent = '';
  769. mealNameInput.focus();
  770. });
  771. }
  772. const closeSaveModal = () => {
  773. saveMealModal.style.display = 'none';
  774. };
  775. if (cancelSaveBtn) cancelSaveBtn.addEventListener('click', closeSaveModal);
  776. if (confirmSaveBtn) {
  777. confirmSaveBtn.addEventListener('click', async () => {
  778. const name = mealNameInput.value.trim();
  779. if (!name) {
  780. saveMealError.textContent = 'Please enter a name for your meal.';
  781. return;
  782. }
  783. confirmSaveBtn.disabled = true;
  784. confirmSaveBtn.textContent = 'Saving...';
  785. try {
  786. const token = localStorage.getItem('localFoodToken');
  787. const response = await fetch('/api/meals', {
  788. method: 'POST',
  789. headers: {
  790. 'Content-Type': 'application/json',
  791. 'Authorization': `Bearer ${token}`
  792. },
  793. body: JSON.stringify({
  794. name: name,
  795. items: currentMealItems.map(item => ({
  796. food_id: item.id,
  797. amount_g: item.amount
  798. }))
  799. })
  800. });
  801. if (response.ok) {
  802. closeSaveModal();
  803. addMessage('system', `✅ Successfully saved "${name}" to your dashboard!`);
  804. // Trigger a refresh of the dashboard if it's open
  805. if (window.refreshDashboard) window.refreshDashboard();
  806. } else {
  807. const data = await response.json();
  808. saveMealError.textContent = data.detail || 'Failed to save meal.';
  809. }
  810. } catch (err) {
  811. console.error('Save meal error:', err);
  812. saveMealError.textContent = 'Server error. Please try again.';
  813. } finally {
  814. confirmSaveBtn.disabled = false;
  815. confirmSaveBtn.textContent = 'Save to Dashboard';
  816. }
  817. });
  818. }
  819. // Close modal on outside click
  820. window.addEventListener('click', (e) => {
  821. if (e.target === saveMealModal) {
  822. closeSaveModal();
  823. }
  824. });
  825. // --- Dashboard Logic (Sprint 8) ---
  826. const dashboardOverlay = document.getElementById('dashboard-overlay');
  827. const openDashboardBtn = document.getElementById('nav-dashboard-btn');
  828. const closeDashboardBtn = document.getElementById('close-dashboard-btn');
  829. const dashboardGrid = document.getElementById('dashboard-grid');
  830. const dashboardEmptyMsg = document.getElementById('dashboard-empty-msg');
  831. const toggleDashboard = (show) => {
  832. if (show) {
  833. dashboardOverlay.style.display = 'flex';
  834. loadSavedMeals();
  835. } else {
  836. dashboardOverlay.style.display = 'none';
  837. }
  838. };
  839. if (openDashboardBtn) openDashboardBtn.addEventListener('click', () => toggleDashboard(true));
  840. if (closeDashboardBtn) closeDashboardBtn.addEventListener('click', () => toggleDashboard(false));
  841. async function loadSavedMeals() {
  842. dashboardGrid.innerHTML = '<div class="search-loading">Loading your meals...</div>';
  843. dashboardEmptyMsg.style.display = 'none';
  844. try {
  845. const token = localStorage.getItem('localFoodToken');
  846. const response = await fetch('/api/meals', {
  847. headers: { 'Authorization': `Bearer ${token}` }
  848. });
  849. if (response.ok) {
  850. const data = await response.json();
  851. renderDashboard(data.meals);
  852. } else {
  853. dashboardGrid.innerHTML = '<div class="error-text">Failed to load meals.</div>';
  854. }
  855. } catch (err) {
  856. console.error('Dashboard load error:', err);
  857. dashboardGrid.innerHTML = '<div class="error-text">Connection error.</div>';
  858. }
  859. }
  860. function renderDashboard(meals) {
  861. dashboardGrid.innerHTML = '';
  862. if (!meals || meals.length === 0) {
  863. dashboardEmptyMsg.style.display = 'block';
  864. return;
  865. }
  866. meals.forEach((meal, index) => {
  867. const card = document.createElement('div');
  868. card.className = 'meal-card';
  869. card.setAttribute('data-meal-id', meal.id);
  870. card.style.animationDelay = `${index * 0.07}s`;
  871. const dateStr = new Date(meal.created_at).toLocaleDateString(undefined, {
  872. month: 'short', day: 'numeric', year: 'numeric'
  873. });
  874. card.innerHTML = `
  875. <div class="meal-card-header">
  876. <span class="meal-card-name">${meal.name}</span>
  877. <span class="meal-card-date">${dateStr}</span>
  878. </div>
  879. <div class="meal-card-macros">
  880. <div class="card-macro kcal">
  881. <span class="card-macro-val">${Math.round(meal.total_calories)}</span>
  882. <span class="card-macro-lbl">kcal</span>
  883. </div>
  884. <div class="card-macro protein">
  885. <span class="card-macro-val">${Math.round(meal.total_protein)}g</span>
  886. <span class="card-macro-lbl">Protein</span>
  887. </div>
  888. <div class="card-macro carbs">
  889. <span class="card-macro-val">${Math.round(meal.total_carbs)}g</span>
  890. <span class="card-macro-lbl">Carbs</span>
  891. </div>
  892. <div class="card-macro fat">
  893. <span class="card-macro-val">${Math.round(meal.total_fat)}g</span>
  894. <span class="card-macro-lbl">Fat</span>
  895. </div>
  896. </div>
  897. <div class="meal-card-actions">
  898. <button class="edit-meal-btn" data-id="${meal.id}" data-name="${meal.name}" title="Rename Meal">
  899. <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>
  900. </button>
  901. <button class="delete-meal-btn" data-id="${meal.id}" title="Delete Meal">
  902. <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>
  903. </button>
  904. </div>
  905. `;
  906. // Attach listeners to new buttons
  907. card.querySelector('.edit-meal-btn').addEventListener('click', (e) => {
  908. e.stopPropagation();
  909. const newName = prompt('Enter new name for this meal:', meal.name);
  910. if (newName && newName.trim() && newName !== meal.name) {
  911. renameMeal(meal.id, newName.trim());
  912. }
  913. });
  914. card.querySelector('.delete-meal-btn').addEventListener('click', (e) => {
  915. e.stopPropagation();
  916. if (confirm(`Are you sure you want to delete "${meal.name}"?`)) {
  917. deleteMeal(meal.id);
  918. }
  919. });
  920. dashboardGrid.appendChild(card);
  921. });
  922. }
  923. async function renameMeal(id, newName) {
  924. try {
  925. const token = localStorage.getItem('localFoodToken');
  926. const response = await fetch(`/api/meals/${id}`, {
  927. method: 'PUT',
  928. headers: {
  929. 'Content-Type': 'application/json',
  930. 'Authorization': `Bearer ${token}`
  931. },
  932. body: JSON.stringify({ name: newName })
  933. });
  934. if (response.ok) {
  935. const card = document.querySelector(`.meal-card[data-meal-id="${id}"]`);
  936. if (card) {
  937. card.querySelector('.meal-card-name').textContent = newName;
  938. const editBtn = card.querySelector('.edit-meal-btn');
  939. if (editBtn) editBtn.dataset.name = newName;
  940. }
  941. } else {
  942. alert('Failed to rename meal.');
  943. }
  944. } catch (err) {
  945. console.error('Rename error:', err);
  946. }
  947. }
  948. async function deleteMeal(id) {
  949. try {
  950. const token = localStorage.getItem('localFoodToken');
  951. const response = await fetch(`/api/meals/${id}`, {
  952. method: 'DELETE',
  953. headers: { 'Authorization': `Bearer ${token}` }
  954. });
  955. if (response.ok) {
  956. const card = document.querySelector(`.meal-card[data-meal-id="${id}"]`);
  957. if (card) {
  958. card.style.transition = 'all 0.3s ease';
  959. card.style.opacity = '0';
  960. card.style.transform = 'scale(0.9)';
  961. setTimeout(() => {
  962. card.remove();
  963. if (document.querySelectorAll('.meal-card').length === 0) {
  964. dashboardEmptyMsg.style.display = 'block';
  965. }
  966. }, 300);
  967. }
  968. } else {
  969. alert('Failed to delete meal.');
  970. }
  971. } catch (err) {
  972. console.error('Delete error:', err);
  973. }
  974. }
  975. // Attach refresh helper
  976. window.refreshDashboard = loadSavedMeals;
  977. });