diff --git a/public/app.js b/public/app.js index 0cb0aa2..75f2206 100644 --- a/public/app.js +++ b/public/app.js @@ -8,6 +8,10 @@ let stopwatchSeconds = 0; let cookingActiveStep = 0; let currentUser = null; let activeTab = 'my-cookbook'; +let shoppingLists = []; +let activeShoppingListId = null; +let consolidateItems = true; +let shoppingGroupBy = 'aisle'; // Page elements caching const elements = { @@ -131,6 +135,8 @@ const elements = { fabAddRecipe: document.getElementById('mobile-header-btn-add'), mobileTabCookbook: document.getElementById('mobile-header-tab-cookbook'), mobileTabCommunity: document.getElementById('mobile-header-tab-community'), + mobileTabShopping: document.getElementById('mobile-header-tab-shopping'), + mobileShoppingTabBadge: document.getElementById('mobile-shopping-tab-badge'), mobileTabProfile: document.getElementById('mobile-header-btn-profile'), mobileProfileModal: document.getElementById('mobile-profile-modal'), mobileProfileUsername: document.getElementById('mobile-profile-username'), @@ -140,7 +146,31 @@ const elements = { mobileBtnAdmin: document.getElementById('mobile-btn-admin'), mobileBtnLogout: document.getElementById('mobile-btn-logout'), headerLogo: document.getElementById('header-logo'), - libraryViewTitle: document.getElementById('library-view-title') + libraryViewTitle: document.getElementById('library-view-title'), + + // Shopping Lists Components + tabShoppingLists: document.getElementById('tab-shopping-lists'), + shoppingTabBadge: document.getElementById('shopping-tab-badge'), + shoppingListsPanel: document.getElementById('shopping-lists-panel'), + shoppingListSelect: document.getElementById('shopping-list-select'), + btnShoppingNewList: document.getElementById('btn-shopping-new-list'), + btnShoppingRenameList: document.getElementById('btn-shopping-rename-list'), + btnShoppingDeleteList: document.getElementById('btn-shopping-delete-list'), + shoppingShareStatsBadge: document.getElementById('shopping-share-stats-badge'), + btnShoppingShare: document.getElementById('btn-shopping-share'), + btnShoppingRevokeShare: document.getElementById('btn-shopping-revoke-share'), + btnShoppingCopyText: document.getElementById('btn-shopping-copy-text'), + shoppingAddItemForm: document.getElementById('shopping-add-item-form'), + shoppingAddItemInput: document.getElementById('shopping-add-item-input'), + toggleConsolidateItems: document.getElementById('toggle-consolidate-items'), + shoppingGroupBySelect: document.getElementById('shopping-group-by'), + btnShoppingClearChecked: document.getElementById('btn-shopping-clear-checked'), + btnShoppingClearAll: document.getElementById('btn-shopping-clear-all'), + shoppingListsContent: document.getElementById('shopping-lists-content'), + shoppingEmptyState: document.getElementById('shopping-empty-state'), + detailShoppingListSelect: document.getElementById('detail-shopping-list-select'), + btnAddRecipeToList: document.getElementById('btn-add-recipe-to-list'), + settingsDefaultConsolidate: document.getElementById('settings-default-consolidate') }; // ------------------------------------------------------------- @@ -193,7 +223,34 @@ function registerEvents() { // Dashboard Tabs Switchers elements.tabMyCookbook.addEventListener('click', () => switchTab('my-cookbook')); elements.tabCommunityVault.addEventListener('click', () => switchTab('community-vault')); + elements.tabShoppingLists.addEventListener('click', () => switchTab('shopping-lists')); elements.tabUserAdmin.addEventListener('click', () => switchTab('user-admin')); + + // Shopping Lists Events + elements.shoppingListSelect.addEventListener('change', (e) => { + activeShoppingListId = e.target.value; + renderShoppingListsWorkspace(); + }); + elements.btnShoppingNewList.addEventListener('click', handleCreateShoppingList); + elements.btnShoppingRenameList.addEventListener('click', handleRenameShoppingList); + elements.btnShoppingDeleteList.addEventListener('click', handleDeleteShoppingList); + elements.btnShoppingShare.addEventListener('click', handleShareShoppingList); + elements.btnShoppingRevokeShare.addEventListener('click', handleRevokeShoppingListShare); + elements.btnShoppingCopyText.addEventListener('click', handleCopyShoppingListText); + elements.shoppingAddItemForm.addEventListener('submit', handleAddManualShoppingItem); + elements.toggleConsolidateItems.addEventListener('change', (e) => { + consolidateItems = e.target.checked; + renderShoppingListsWorkspace(); + }); + elements.shoppingGroupBySelect.addEventListener('change', (e) => { + shoppingGroupBy = e.target.value; + renderShoppingListsWorkspace(); + }); + elements.btnShoppingClearChecked.addEventListener('click', () => handleClearShoppingList(true)); + elements.btnShoppingClearAll.addEventListener('click', () => handleClearShoppingList(false)); + elements.detailShoppingListSelect.addEventListener('change', handleDetailListSelectChange); + elements.btnAddRecipeToList.addEventListener('click', handleAddRecipeToShoppingList); + elements.settingsDefaultConsolidate.addEventListener('change', handleDefaultConsolidatePreferenceChange); // Admin Action Buttons & Forms elements.btnAdminAddUser.addEventListener('click', () => openModal('admin-create-user-modal')); @@ -244,6 +301,9 @@ function registerEvents() { // Mobile UI Events elements.mobileTabCookbook.addEventListener('click', () => switchTab('my-cookbook')); elements.mobileTabCommunity.addEventListener('click', () => switchTab('community-vault')); + if (elements.mobileTabShopping) { + elements.mobileTabShopping.addEventListener('click', () => switchTab('shopping-lists')); + } elements.mobileTabProfile.addEventListener('click', openMobileProfile); elements.headerLogo.addEventListener('click', () => switchTab('my-cookbook')); elements.fabAddRecipe.addEventListener('click', handleFabClick); @@ -1055,6 +1115,7 @@ function showRecipeDetails(recipe) { // Populate Ingredients with scaler renderScaledIngredients(); + populateDetailShoppingListSelect(); // Populate Directions Steps elements.recipeDetailInstructions.innerHTML = ''; @@ -1220,6 +1281,7 @@ function initAuth() { if (token && userJson) { currentUser = JSON.parse(userJson); + applyUserDefaultConsolidationPreference(currentUser.defaultConsolidateLists); elements.authModal.classList.remove('active'); elements.authModal.style.display = 'none'; elements.userBadgeContainer.style.display = 'flex'; @@ -1234,6 +1296,10 @@ function initAuth() { elements.mobileBtnAdmin.style.display = 'none'; } + // Preload shopping lists and user preferences in background + fetchShoppingLists(); + syncUserPreferencesFromServer(); + // Reset view to My Cookbook on init switchTab('my-cookbook'); } else { @@ -1267,6 +1333,7 @@ async function handleLogin(e) { localStorage.setItem('gourmet_token', data.token); localStorage.setItem('gourmet_user', JSON.stringify(data.user)); currentUser = data.user; + applyUserDefaultConsolidationPreference(currentUser.defaultConsolidateLists); elements.authModal.classList.remove('active'); elements.authModal.style.display = 'none'; @@ -1283,6 +1350,7 @@ async function handleLogin(e) { } elements.loginForm.reset(); + fetchShoppingLists(); // Default back to My Cookbook on fresh login switchTab('my-cookbook'); @@ -1301,14 +1369,19 @@ function logout() { elements.userBadgeContainer.style.display = 'none'; elements.mobileBtnAdmin.style.display = 'none'; allRecipes = []; + shoppingLists = []; + activeShoppingListId = null; + updateShoppingTabBadges(); // Reset tabs visual state elements.tabMyCookbook.classList.add('active'); elements.tabCommunityVault.classList.remove('active'); + if (elements.tabShoppingLists) elements.tabShoppingLists.classList.remove('active'); elements.tabUserAdmin.classList.remove('btn-primary'); elements.tabUserAdmin.classList.add('btn-outline'); elements.tabUserAdmin.classList.remove('active'); elements.userAdminPanel.style.display = 'none'; + if (elements.shoppingListsPanel) elements.shoppingListsPanel.style.display = 'none'; elements.recipesGrid.style.display = 'grid'; const filters = elements.librarySearch.closest('.filter-controls'); @@ -1320,6 +1393,7 @@ function logout() { function switchTab(tabName) { elements.tabMyCookbook.classList.remove('active'); elements.tabCommunityVault.classList.remove('active'); + if (elements.tabShoppingLists) elements.tabShoppingLists.classList.remove('active'); elements.tabUserAdmin.classList.remove('active'); elements.tabUserAdmin.classList.remove('btn-primary'); elements.tabUserAdmin.classList.add('btn-outline'); @@ -1327,11 +1401,13 @@ function switchTab(tabName) { // Mobile Tab Bar sync elements.mobileTabCookbook.classList.remove('active'); elements.mobileTabCommunity.classList.remove('active'); + if (elements.mobileTabShopping) elements.mobileTabShopping.classList.remove('active'); elements.mobileTabProfile.classList.remove('active'); elements.recipesGrid.style.display = 'none'; elements.emptyState.style.display = 'none'; elements.userAdminPanel.style.display = 'none'; + if (elements.shoppingListsPanel) elements.shoppingListsPanel.style.display = 'none'; const filters = elements.librarySearch.closest('.filter-controls'); if (filters) filters.style.display = 'flex'; @@ -1342,6 +1418,8 @@ function switchTab(tabName) { elements.libraryViewTitle.textContent = 'My Cookbook'; } else if (tabName === 'community-vault') { elements.libraryViewTitle.textContent = 'Shared Cookbook'; + } else if (tabName === 'shopping-lists') { + elements.libraryViewTitle.textContent = 'Shopping Lists'; } else if (tabName === 'user-admin') { elements.libraryViewTitle.textContent = 'User Administration'; } @@ -1359,6 +1437,13 @@ function switchTab(tabName) { elements.recipesGrid.style.display = 'grid'; activeTab = 'community-vault'; fetchRecipes(); + } else if (tabName === 'shopping-lists') { + if (elements.tabShoppingLists) elements.tabShoppingLists.classList.add('active'); + if (elements.mobileTabShopping) elements.mobileTabShopping.classList.add('active'); + if (elements.shoppingListsPanel) elements.shoppingListsPanel.style.display = 'flex'; + if (filters) filters.style.display = 'none'; + activeTab = 'shopping-lists'; + fetchShoppingLists(); } else if (tabName === 'user-admin') { elements.tabUserAdmin.classList.add('btn-primary'); elements.tabUserAdmin.classList.remove('btn-outline'); @@ -1758,6 +1843,10 @@ async function openSettingsModal() { if (key && !isPlaceholder) { elements.settingsGeminiKeyInput.value = key; } + if (elements.settingsDefaultConsolidate && typeof settings.defaultConsolidateLists === 'boolean') { + elements.settingsDefaultConsolidate.checked = settings.defaultConsolidateLists; + applyUserDefaultConsolidationPreference(settings.defaultConsolidateLists); + } } catch (err) { console.error('Error loading settings:', err); elements.settingsGeminiKeyInput.placeholder = 'Failed to load settings'; @@ -1769,6 +1858,7 @@ async function handleSaveSettings(e) { elements.settingsStatusMsg.style.display = 'none'; const key = elements.settingsGeminiKeyInput.value.trim(); + const defaultConsolidate = elements.settingsDefaultConsolidate ? elements.settingsDefaultConsolidate.checked : true; try { const response = await fetch('/api/settings', { @@ -1777,7 +1867,10 @@ async function handleSaveSettings(e) { 'Content-Type': 'application/json', ...getAuthHeaders() }, - body: JSON.stringify({ geminiApiKey: key }) + body: JSON.stringify({ + geminiApiKey: key, + defaultConsolidateLists: defaultConsolidate + }) }); if (response.status === 401) { @@ -1788,6 +1881,11 @@ async function handleSaveSettings(e) { if (!response.ok) throw new Error('Failed to save settings file.'); + applyUserDefaultConsolidationPreference(defaultConsolidate); + if (elements.settingsStatusText) { + elements.settingsStatusText.textContent = 'Settings saved successfully.'; + } + // Show success status banner elements.settingsStatusMsg.style.display = 'flex'; setTimeout(() => { @@ -2073,3 +2171,752 @@ function handleFabClick() { openManualAdd(); } +// ------------------------------------------------------------- +// SHOPPING LISTS MANAGEMENT & CONSOLIDATION ENGINE +// ------------------------------------------------------------- + +const AISLE_ORDER = [ + 'Produce', + 'Meat & Seafood', + 'Dairy & Eggs', + 'Canned & Jarred', + 'Bakery & Grains', + 'Pantry & Spices', + 'Frozen', + 'Other' +]; + +const AISLE_ICONS = { + 'Produce': 'apple', + 'Meat & Seafood': 'beef', + 'Dairy & Eggs': 'milk', + 'Canned & Jarred': 'Archive', + 'Bakery & Grains': 'wheat', + 'Pantry & Spices': 'flame', + 'Frozen': 'snowflake', + 'Other': 'shopping-bag' +}; + +function applyUserDefaultConsolidationPreference(prefValue) { + const shouldConsolidate = prefValue !== false; + consolidateItems = shouldConsolidate; + if (elements.toggleConsolidateItems) { + elements.toggleConsolidateItems.checked = shouldConsolidate; + } + if (elements.settingsDefaultConsolidate) { + elements.settingsDefaultConsolidate.checked = shouldConsolidate; + } + if (currentUser) { + currentUser.defaultConsolidateLists = shouldConsolidate; + localStorage.setItem('gourmet_user', JSON.stringify(currentUser)); + } +} + +async function syncUserPreferencesFromServer() { + try { + const response = await fetch('/api/settings', { headers: getAuthHeaders() }); + if (response.ok) { + const data = await response.json(); + if (typeof data.defaultConsolidateLists === 'boolean') { + applyUserDefaultConsolidationPreference(data.defaultConsolidateLists); + } + } + } catch (err) { + console.warn('Failed to sync user preferences:', err); + } +} + +async function handleDefaultConsolidatePreferenceChange(e) { + const newPref = e.target.checked; + try { + const response = await fetch('/api/user/preferences', { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + ...getAuthHeaders() + }, + body: JSON.stringify({ defaultConsolidateLists: newPref }) + }); + if (response.status === 401) { + logout(); + return; + } + if (!response.ok) throw new Error('Failed to save default consolidation preference.'); + applyUserDefaultConsolidationPreference(newPref); + renderShoppingListsWorkspace(); + if (elements.settingsStatusText && elements.settingsStatusMsg) { + elements.settingsStatusText.textContent = 'Default consolidation preference updated.'; + elements.settingsStatusMsg.style.display = 'flex'; + setTimeout(() => { + elements.settingsStatusMsg.style.display = 'none'; + }, 3000); + } + } catch (err) { + console.error('Error updating consolidation preference:', err); + } +} + +async function fetchShoppingLists() { + if (!currentUser) return; + try { + const response = await fetch('/api/shopping-lists', { + headers: getAuthHeaders() + }); + if (response.status === 401) { + logout(); + return; + } + if (!response.ok) throw new Error('Failed to load shopping lists.'); + shoppingLists = await response.json(); + + if (!activeShoppingListId || !shoppingLists.some(l => l.id === activeShoppingListId)) { + activeShoppingListId = shoppingLists.length > 0 ? shoppingLists[0].id : null; + } + + renderShoppingListsWorkspace(); + populateDetailShoppingListSelect(); + } catch (error) { + console.error('Error loading shopping lists:', error); + } +} + +function getActiveShoppingList() { + return shoppingLists.find(l => l.id === activeShoppingListId) || shoppingLists[0] || null; +} + +function updateShoppingListInState(updatedList) { + const idx = shoppingLists.findIndex(l => l.id === updatedList.id); + if (idx !== -1) { + shoppingLists[idx] = updatedList; + } else { + shoppingLists.push(updatedList); + } + activeShoppingListId = updatedList.id; + renderShoppingListsWorkspace(); + populateDetailShoppingListSelect(); +} + +function updateShoppingTabBadges() { + const activeList = getActiveShoppingList(); + const uncheckedCount = activeList && Array.isArray(activeList.items) + ? activeList.items.filter(i => !i.checked).length + : 0; + + if (elements.shoppingTabBadge) { + if (uncheckedCount > 0) { + elements.shoppingTabBadge.textContent = uncheckedCount; + elements.shoppingTabBadge.style.display = 'inline-block'; + } else { + elements.shoppingTabBadge.style.display = 'none'; + } + } + if (elements.mobileShoppingTabBadge) { + if (uncheckedCount > 0) { + elements.mobileShoppingTabBadge.textContent = uncheckedCount; + elements.mobileShoppingTabBadge.style.display = 'flex'; + } else { + elements.mobileShoppingTabBadge.style.display = 'none'; + } + } +} + +function populateDetailShoppingListSelect() { + if (!elements.detailShoppingListSelect) return; + elements.detailShoppingListSelect.innerHTML = ''; + + shoppingLists.forEach(list => { + const opt = document.createElement('option'); + opt.value = list.id; + opt.textContent = list.name; + if (list.id === activeShoppingListId) opt.selected = true; + elements.detailShoppingListSelect.appendChild(opt); + }); + + const newOpt = document.createElement('option'); + newOpt.value = '__new__'; + newOpt.textContent = '+ Create New List...'; + elements.detailShoppingListSelect.appendChild(newOpt); +} + +async function handleDetailListSelectChange(e) { + if (e.target.value === '__new__') { + const name = prompt('Enter a name for the new shopping list:', 'Weekend Groceries'); + if (name && name.trim()) { + try { + const res = await fetch('/api/shopping-lists', { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...getAuthHeaders() }, + body: JSON.stringify({ name: name.trim() }) + }); + if (res.ok) { + const created = await res.json(); + updateShoppingListInState(created); + elements.detailShoppingListSelect.value = created.id; + return; + } + } catch (err) { + console.error('Error creating list from detail modal:', err); + } + } + populateDetailShoppingListSelect(); + } else { + activeShoppingListId = e.target.value; + renderShoppingListsWorkspace(); + } +} + +let addRecipeBtnTimeout = null; + +async function handleAddRecipeToShoppingList() { + if (!activeRecipe) return; + let targetListId = elements.detailShoppingListSelect ? elements.detailShoppingListSelect.value : activeShoppingListId; + if (!targetListId || targetListId === '__new__') { + const activeList = getActiveShoppingList(); + if (!activeList) return; + targetListId = activeList.id; + } + + // Gather unchecked ingredients from the Recipe Detail checklist (or all if all are checked) + const listElements = Array.from(elements.recipeDetailIngredients.querySelectorAll('.ingredient-checkbox-item')); + let selectedLines = listElements + .filter(li => !li.classList.contains('checked')) + .map(li => li.querySelector('.ingredient-text')?.textContent?.trim()) + .filter(Boolean); + + if (selectedLines.length === 0 && listElements.length > 0) { + selectedLines = listElements + .map(li => li.querySelector('.ingredient-text')?.textContent?.trim()) + .filter(Boolean); + } + + if (selectedLines.length === 0) return; + + try { + const response = await fetch(`/api/shopping-lists/${targetListId}/items`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...getAuthHeaders() + }, + body: JSON.stringify({ + items: selectedLines, + recipeId: activeRecipe.id, + recipeTitle: activeRecipe.title + }) + }); + + if (response.status === 401) { + logout(); + return; + } + if (!response.ok) throw new Error('Failed to add ingredients to shopping list.'); + + const updatedList = await response.json(); + updateShoppingListInState(updatedList); + + if (elements.btnAddRecipeToList) { + if (addRecipeBtnTimeout) clearTimeout(addRecipeBtnTimeout); + elements.btnAddRecipeToList.innerHTML = ` Added (${selectedLines.length})!`; + elements.btnAddRecipeToList.style.backgroundColor = 'var(--accent)'; + elements.btnAddRecipeToList.style.borderColor = 'var(--accent)'; + lucide.createIcons(); + addRecipeBtnTimeout = setTimeout(() => { + elements.btnAddRecipeToList.innerHTML = ` Add to List`; + elements.btnAddRecipeToList.style.backgroundColor = ''; + elements.btnAddRecipeToList.style.borderColor = ''; + lucide.createIcons(); + }, 2000); + } + } catch (error) { + console.error('Error adding recipe to shopping list:', error); + alert('Failed to add ingredients to shopping list.'); + } +} + +async function handleCreateShoppingList() { + const name = prompt('Enter a name for your new shopping list:', ''); + if (!name || !name.trim()) return; + + try { + const response = await fetch('/api/shopping-lists', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...getAuthHeaders() + }, + body: JSON.stringify({ name: name.trim() }) + }); + if (response.status === 401) { + logout(); + return; + } + if (!response.ok) throw new Error('Failed to create shopping list.'); + const created = await response.json(); + updateShoppingListInState(created); + } catch (err) { + console.error('Error creating shopping list:', err); + alert('Could not create shopping list.'); + } +} + +async function handleRenameShoppingList() { + const activeList = getActiveShoppingList(); + if (!activeList) return; + const newName = prompt('Rename shopping list:', activeList.name); + if (!newName || !newName.trim() || newName.trim() === activeList.name) return; + + try { + const response = await fetch(`/api/shopping-lists/${activeList.id}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + ...getAuthHeaders() + }, + body: JSON.stringify({ name: newName.trim() }) + }); + if (response.status === 401) { + logout(); + return; + } + if (!response.ok) throw new Error('Failed to rename shopping list.'); + const updated = await response.json(); + updateShoppingListInState(updated); + } catch (err) { + console.error('Error renaming shopping list:', err); + alert('Could not rename shopping list.'); + } +} + +async function handleDeleteShoppingList() { + const activeList = getActiveShoppingList(); + if (!activeList) return; + if (!confirm(`Are you sure you want to delete the shopping list "${activeList.name}" and all its items?`)) return; + + try { + const response = await fetch(`/api/shopping-lists/${activeList.id}`, { + method: 'DELETE', + headers: getAuthHeaders() + }); + if (response.status === 401) { + logout(); + return; + } + if (!response.ok) throw new Error('Failed to delete shopping list.'); + activeShoppingListId = null; + await fetchShoppingLists(); + } catch (err) { + console.error('Error deleting shopping list:', err); + alert('Could not delete shopping list.'); + } +} + +async function handleAddManualShoppingItem(e) { + e.preventDefault(); + const activeList = getActiveShoppingList(); + if (!activeList) return; + + const text = elements.shoppingAddItemInput.value.trim(); + if (!text) return; + + try { + const response = await fetch(`/api/shopping-lists/${activeList.id}/items`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...getAuthHeaders() + }, + body: JSON.stringify({ items: [text] }) + }); + if (response.status === 401) { + logout(); + return; + } + if (!response.ok) throw new Error('Failed to add item.'); + const updated = await response.json(); + elements.shoppingAddItemInput.value = ''; + updateShoppingListInState(updated); + } catch (err) { + console.error('Error adding manual item:', err); + alert('Could not add item.'); + } +} + +async function handleToggleShoppingItems(itemIds, checked) { + const activeList = getActiveShoppingList(); + if (!activeList || !Array.isArray(itemIds) || itemIds.length === 0) return; + + try { + const response = await fetch(`/api/shopping-lists/${activeList.id}/items/check`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + ...getAuthHeaders() + }, + body: JSON.stringify({ itemIds, checked }) + }); + if (response.status === 401) { + logout(); + return; + } + if (!response.ok) throw new Error('Failed to toggle item.'); + const updated = await response.json(); + updateShoppingListInState(updated); + } catch (err) { + console.error('Error toggling shopping item:', err); + } +} + +async function handleDeleteShoppingItems(itemIds) { + const activeList = getActiveShoppingList(); + if (!activeList || !Array.isArray(itemIds) || itemIds.length === 0) return; + + try { + const response = await fetch(`/api/shopping-lists/${activeList.id}/items/delete`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...getAuthHeaders() + }, + body: JSON.stringify({ itemIds }) + }); + if (response.status === 401) { + logout(); + return; + } + if (!response.ok) throw new Error('Failed to delete item.'); + const updated = await response.json(); + updateShoppingListInState(updated); + } catch (err) { + console.error('Error deleting shopping item:', err); + } +} + +async function handleClearShoppingList(onlyChecked) { + const activeList = getActiveShoppingList(); + if (!activeList) return; + if (!onlyChecked && !confirm(`Are you sure you want to clear all items from "${activeList.name}"?`)) return; + + try { + const response = await fetch(`/api/shopping-lists/${activeList.id}/clear`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...getAuthHeaders() + }, + body: JSON.stringify({ onlyChecked }) + }); + if (response.status === 401) { + logout(); + return; + } + if (!response.ok) throw new Error('Failed to clear items.'); + const updated = await response.json(); + updateShoppingListInState(updated); + } catch (err) { + console.error('Error clearing shopping list:', err); + } +} + +let shoppingShareBtnTimeout = null; +let shoppingCopyTextTimeout = null; + +async function handleShareShoppingList() { + const activeList = getActiveShoppingList(); + if (!activeList) return; + + try { + const response = await fetch(`/api/shopping-lists/${activeList.id}/share`, { + method: 'POST', + headers: getAuthHeaders() + }); + if (response.status === 401) { + logout(); + return; + } + if (!response.ok) throw new Error('Failed to generate share link.'); + const updated = await response.json(); + updateShoppingListInState(updated); + + const shareUrl = `${window.location.origin}/share-list.html?token=${updated.shareToken}`; + await copyTextToClipboard(shareUrl); + + if (elements.btnShoppingShare) { + if (shoppingShareBtnTimeout) clearTimeout(shoppingShareBtnTimeout); + elements.btnShoppingShare.innerHTML = ` Copied!`; + elements.btnShoppingShare.style.borderColor = 'var(--accent)'; + elements.btnShoppingShare.style.color = 'var(--accent)'; + lucide.createIcons(); + shoppingShareBtnTimeout = setTimeout(() => { + elements.btnShoppingShare.innerHTML = ` Share Link`; + elements.btnShoppingShare.style.borderColor = ''; + elements.btnShoppingShare.style.color = ''; + lucide.createIcons(); + }, 2000); + } + } catch (err) { + console.error('Error sharing shopping list:', err); + alert('Failed to generate or copy share link.'); + } +} + +async function handleRevokeShoppingListShare() { + const activeList = getActiveShoppingList(); + if (!activeList) return; + if (!confirm(`Disable the public share link for "${activeList.name}" and reset visitor stats?`)) return; + + try { + const response = await fetch(`/api/shopping-lists/${activeList.id}/revoke-share`, { + method: 'POST', + headers: getAuthHeaders() + }); + if (response.status === 401) { + logout(); + return; + } + if (!response.ok) throw new Error('Failed to revoke share link.'); + const updated = await response.json(); + updateShoppingListInState(updated); + } catch (err) { + console.error('Error revoking shopping list share:', err); + } +} + +async function handleCopyShoppingListText() { + const activeList = getActiveShoppingList(); + if (!activeList || !Array.isArray(activeList.items) || activeList.items.length === 0) return; + + const displayEntries = buildShoppingListDisplayEntries(activeList.items, consolidateItems); + const grouped = groupShoppingEntries(displayEntries, shoppingGroupBy); + + const lines = [`πŸ›’ ${activeList.name}`]; + for (const [groupName, entries] of grouped.entries()) { + lines.push(`\n[${groupName}]`); + entries.forEach(entry => { + const box = entry.checked ? '[x]' : '[ ]'; + lines.push(`${box} ${entry.displayText}`); + }); + } + + try { + await copyTextToClipboard(lines.join('\n')); + if (elements.btnShoppingCopyText) { + if (shoppingCopyTextTimeout) clearTimeout(shoppingCopyTextTimeout); + elements.btnShoppingCopyText.innerHTML = ` Copied!`; + elements.btnShoppingCopyText.style.borderColor = 'var(--accent)'; + elements.btnShoppingCopyText.style.color = 'var(--accent)'; + lucide.createIcons(); + shoppingCopyTextTimeout = setTimeout(() => { + elements.btnShoppingCopyText.innerHTML = ` Copy Text`; + elements.btnShoppingCopyText.style.borderColor = ''; + elements.btnShoppingCopyText.style.color = ''; + lucide.createIcons(); + }, 2000); + } + } catch (err) { + console.error('Error copying shopping list text:', err); + } +} + +function buildShoppingListDisplayEntries(items, shouldConsolidate) { + if (!shouldConsolidate) { + return items.map(item => ({ + itemIds: [item.id], + displayText: item.rawText, + category: item.category || 'Other', + sources: item.recipeTitle ? [item.recipeTitle] : [], + checked: !!item.checked + })); + } + + // Group items by normalized itemName + checked state + unit so compatible items sum cleanly + const groups = new Map(); + for (const item of items) { + const normName = (item.itemName || item.rawText || '').toLowerCase().trim(); + const normUnit = (item.unit || '').toLowerCase().trim(); + const key = `${item.checked ? '1' : '0'}::${normName}::${normUnit}`; + + if (!groups.has(key)) { + groups.set(key, { + itemIds: [item.id], + rawTexts: [item.rawText], + itemName: normName, + unit: normUnit, + totalQty: item.quantity !== null && item.quantity !== undefined ? Number(item.quantity) : null, + hasNullQty: item.quantity === null || item.quantity === undefined, + category: item.category || 'Other', + sources: item.recipeTitle ? [item.recipeTitle] : [], + checked: !!item.checked + }); + } else { + const g = groups.get(key); + g.itemIds.push(item.id); + g.rawTexts.push(item.rawText); + if (item.quantity !== null && item.quantity !== undefined && !g.hasNullQty) { + g.totalQty = (g.totalQty || 0) + Number(item.quantity); + } else { + g.hasNullQty = true; + } + if (item.recipeTitle && !g.sources.includes(item.recipeTitle)) { + g.sources.push(item.recipeTitle); + } + } + } + + const results = []; + for (const g of groups.values()) { + let displayText = g.rawTexts[0]; + if (g.itemIds.length > 1) { + if (!g.hasNullQty && g.totalQty !== null && g.totalQty > 0) { + const qtyStr = formatScaledNumber(g.totalQty); + const unitStr = g.unit ? (g.totalQty > 1 && !g.unit.endsWith('s') && ['clove', 'can', 'jar', 'bunch', 'slice', 'piece', 'head', 'stalk', 'sprig', 'stick', 'cup'].includes(g.unit) ? `${g.unit}s ` : `${g.unit} `) : ''; + const displayItem = g.itemName.charAt(0).toUpperCase() + g.itemName.slice(1); + displayText = `${qtyStr} ${unitStr}${displayItem}`; + } else { + const displayItem = g.itemName.charAt(0).toUpperCase() + g.itemName.slice(1); + displayText = `${displayItem} (Γ—${g.itemIds.length})`; + } + } + + results.push({ + itemIds: g.itemIds, + displayText, + category: g.category, + sources: g.sources, + checked: g.checked + }); + } + return results; +} + +function groupShoppingEntries(entries, mode) { + const grouped = new Map(); + if (mode === 'recipe') { + for (const entry of entries) { + const groupLabels = entry.sources.length > 0 ? [entry.sources.join(' + ')] : ['Manual / Household Staples']; + for (const label of groupLabels) { + if (!grouped.has(label)) grouped.set(label, []); + grouped.get(label).push(entry); + } + } + return grouped; + } + + // Default: Group by Grocery Aisle in store walk-through order + for (const aisle of AISLE_ORDER) { + const matching = entries.filter(e => (e.category || 'Other') === aisle); + if (matching.length > 0) { + // Sort unchecked first + matching.sort((a, b) => Number(a.checked) - Number(b.checked)); + grouped.set(aisle, matching); + } + } + return grouped; +} + +function renderShoppingListsWorkspace() { + if (!elements.shoppingListSelect) return; + + // Populate select options + elements.shoppingListSelect.innerHTML = ''; + shoppingLists.forEach(list => { + const opt = document.createElement('option'); + opt.value = list.id; + const unchecked = Array.isArray(list.items) ? list.items.filter(i => !i.checked).length : 0; + opt.textContent = `${list.name} (${unchecked})`; + if (list.id === activeShoppingListId) opt.selected = true; + elements.shoppingListSelect.appendChild(opt); + }); + + updateShoppingTabBadges(); + + const activeList = getActiveShoppingList(); + if (!activeList) { + elements.shoppingListsContent.innerHTML = ''; + elements.shoppingEmptyState.style.display = 'flex'; + return; + } + + // Share status UI + if (activeList.shareToken) { + elements.btnShoppingRevokeShare.style.display = 'inline-flex'; + elements.shoppingShareStatsBadge.style.display = 'inline-flex'; + const vCount = activeList.visitorCount || 0; + elements.shoppingShareStatsBadge.querySelector('.val').textContent = `${vCount} Unique ${vCount === 1 ? 'Visitor' : 'Visitors'}`; + } else { + elements.btnShoppingRevokeShare.style.display = 'none'; + elements.shoppingShareStatsBadge.style.display = 'none'; + } + + const items = activeList.items || []; + if (items.length === 0) { + elements.shoppingListsContent.innerHTML = ''; + elements.shoppingEmptyState.style.display = 'flex'; + lucide.createIcons(); + return; + } + + elements.shoppingEmptyState.style.display = 'none'; + elements.shoppingListsContent.innerHTML = ''; + + const entries = buildShoppingListDisplayEntries(items, consolidateItems); + const grouped = groupShoppingEntries(entries, shoppingGroupBy); + + for (const [groupName, groupEntries] of grouped.entries()) { + const card = document.createElement('div'); + card.className = 'shopping-category-card'; + + const iconName = AISLE_ICONS[groupName] || 'utensils'; + const uncheckedInGroup = groupEntries.filter(e => !e.checked).length; + + card.innerHTML = ` +
+
+ + ${groupName} +
+ ${uncheckedInGroup} / ${groupEntries.length} +
+ + `; + + const ul = card.querySelector('.shopping-items-list'); + groupEntries.forEach(entry => { + const li = document.createElement('li'); + li.className = `shopping-item-row ${entry.checked ? 'checked' : ''}`; + + const sourcesHtml = entry.sources.length > 0 + ? `
${entry.sources.map(s => `${s}`).join('')}
` + : ''; + + li.innerHTML = ` +
+
+
+ ${entry.displayText} + ${sourcesHtml} +
+
+ + `; + + li.querySelector('.shopping-item-main').addEventListener('click', () => { + handleToggleShoppingItems(entry.itemIds, !entry.checked); + }); + + li.querySelector('.shopping-item-delete').addEventListener('click', (e) => { + e.stopPropagation(); + handleDeleteShoppingItems(entry.itemIds); + }); + + ul.appendChild(li); + }); + + elements.shoppingListsContent.appendChild(card); + } + + lucide.createIcons(); +} + + diff --git a/public/index.html b/public/index.html index 12c1978..3f1df91 100644 --- a/public/index.html +++ b/public/index.html @@ -47,6 +47,10 @@ + @@ -93,6 +97,10 @@ + @@ -119,6 +127,86 @@

Insert a cooking web link above or create one manually to build your digital recipe collections.

+ + +