feat: add multiple shareable shopping lists with smart consolidation toggle and default user setting

This commit is contained in:
Chris 2026-10-29 17:46:43 -04:00
parent 72b965743e
commit dc6f500045
6 changed files with 2517 additions and 14 deletions

View file

@ -8,6 +8,10 @@ let stopwatchSeconds = 0;
let cookingActiveStep = 0; let cookingActiveStep = 0;
let currentUser = null; let currentUser = null;
let activeTab = 'my-cookbook'; let activeTab = 'my-cookbook';
let shoppingLists = [];
let activeShoppingListId = null;
let consolidateItems = true;
let shoppingGroupBy = 'aisle';
// Page elements caching // Page elements caching
const elements = { const elements = {
@ -131,6 +135,8 @@ const elements = {
fabAddRecipe: document.getElementById('mobile-header-btn-add'), fabAddRecipe: document.getElementById('mobile-header-btn-add'),
mobileTabCookbook: document.getElementById('mobile-header-tab-cookbook'), mobileTabCookbook: document.getElementById('mobile-header-tab-cookbook'),
mobileTabCommunity: document.getElementById('mobile-header-tab-community'), 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'), mobileTabProfile: document.getElementById('mobile-header-btn-profile'),
mobileProfileModal: document.getElementById('mobile-profile-modal'), mobileProfileModal: document.getElementById('mobile-profile-modal'),
mobileProfileUsername: document.getElementById('mobile-profile-username'), mobileProfileUsername: document.getElementById('mobile-profile-username'),
@ -140,7 +146,31 @@ const elements = {
mobileBtnAdmin: document.getElementById('mobile-btn-admin'), mobileBtnAdmin: document.getElementById('mobile-btn-admin'),
mobileBtnLogout: document.getElementById('mobile-btn-logout'), mobileBtnLogout: document.getElementById('mobile-btn-logout'),
headerLogo: document.getElementById('header-logo'), 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 // Dashboard Tabs Switchers
elements.tabMyCookbook.addEventListener('click', () => switchTab('my-cookbook')); elements.tabMyCookbook.addEventListener('click', () => switchTab('my-cookbook'));
elements.tabCommunityVault.addEventListener('click', () => switchTab('community-vault')); elements.tabCommunityVault.addEventListener('click', () => switchTab('community-vault'));
elements.tabShoppingLists.addEventListener('click', () => switchTab('shopping-lists'));
elements.tabUserAdmin.addEventListener('click', () => switchTab('user-admin')); 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 // Admin Action Buttons & Forms
elements.btnAdminAddUser.addEventListener('click', () => openModal('admin-create-user-modal')); elements.btnAdminAddUser.addEventListener('click', () => openModal('admin-create-user-modal'));
@ -244,6 +301,9 @@ function registerEvents() {
// Mobile UI Events // Mobile UI Events
elements.mobileTabCookbook.addEventListener('click', () => switchTab('my-cookbook')); elements.mobileTabCookbook.addEventListener('click', () => switchTab('my-cookbook'));
elements.mobileTabCommunity.addEventListener('click', () => switchTab('community-vault')); elements.mobileTabCommunity.addEventListener('click', () => switchTab('community-vault'));
if (elements.mobileTabShopping) {
elements.mobileTabShopping.addEventListener('click', () => switchTab('shopping-lists'));
}
elements.mobileTabProfile.addEventListener('click', openMobileProfile); elements.mobileTabProfile.addEventListener('click', openMobileProfile);
elements.headerLogo.addEventListener('click', () => switchTab('my-cookbook')); elements.headerLogo.addEventListener('click', () => switchTab('my-cookbook'));
elements.fabAddRecipe.addEventListener('click', handleFabClick); elements.fabAddRecipe.addEventListener('click', handleFabClick);
@ -1055,6 +1115,7 @@ function showRecipeDetails(recipe) {
// Populate Ingredients with scaler // Populate Ingredients with scaler
renderScaledIngredients(); renderScaledIngredients();
populateDetailShoppingListSelect();
// Populate Directions Steps // Populate Directions Steps
elements.recipeDetailInstructions.innerHTML = ''; elements.recipeDetailInstructions.innerHTML = '';
@ -1220,6 +1281,7 @@ function initAuth() {
if (token && userJson) { if (token && userJson) {
currentUser = JSON.parse(userJson); currentUser = JSON.parse(userJson);
applyUserDefaultConsolidationPreference(currentUser.defaultConsolidateLists);
elements.authModal.classList.remove('active'); elements.authModal.classList.remove('active');
elements.authModal.style.display = 'none'; elements.authModal.style.display = 'none';
elements.userBadgeContainer.style.display = 'flex'; elements.userBadgeContainer.style.display = 'flex';
@ -1234,6 +1296,10 @@ function initAuth() {
elements.mobileBtnAdmin.style.display = 'none'; elements.mobileBtnAdmin.style.display = 'none';
} }
// Preload shopping lists and user preferences in background
fetchShoppingLists();
syncUserPreferencesFromServer();
// Reset view to My Cookbook on init // Reset view to My Cookbook on init
switchTab('my-cookbook'); switchTab('my-cookbook');
} else { } else {
@ -1267,6 +1333,7 @@ async function handleLogin(e) {
localStorage.setItem('gourmet_token', data.token); localStorage.setItem('gourmet_token', data.token);
localStorage.setItem('gourmet_user', JSON.stringify(data.user)); localStorage.setItem('gourmet_user', JSON.stringify(data.user));
currentUser = data.user; currentUser = data.user;
applyUserDefaultConsolidationPreference(currentUser.defaultConsolidateLists);
elements.authModal.classList.remove('active'); elements.authModal.classList.remove('active');
elements.authModal.style.display = 'none'; elements.authModal.style.display = 'none';
@ -1283,6 +1350,7 @@ async function handleLogin(e) {
} }
elements.loginForm.reset(); elements.loginForm.reset();
fetchShoppingLists();
// Default back to My Cookbook on fresh login // Default back to My Cookbook on fresh login
switchTab('my-cookbook'); switchTab('my-cookbook');
@ -1301,14 +1369,19 @@ function logout() {
elements.userBadgeContainer.style.display = 'none'; elements.userBadgeContainer.style.display = 'none';
elements.mobileBtnAdmin.style.display = 'none'; elements.mobileBtnAdmin.style.display = 'none';
allRecipes = []; allRecipes = [];
shoppingLists = [];
activeShoppingListId = null;
updateShoppingTabBadges();
// Reset tabs visual state // Reset tabs visual state
elements.tabMyCookbook.classList.add('active'); elements.tabMyCookbook.classList.add('active');
elements.tabCommunityVault.classList.remove('active'); elements.tabCommunityVault.classList.remove('active');
if (elements.tabShoppingLists) elements.tabShoppingLists.classList.remove('active');
elements.tabUserAdmin.classList.remove('btn-primary'); elements.tabUserAdmin.classList.remove('btn-primary');
elements.tabUserAdmin.classList.add('btn-outline'); elements.tabUserAdmin.classList.add('btn-outline');
elements.tabUserAdmin.classList.remove('active'); elements.tabUserAdmin.classList.remove('active');
elements.userAdminPanel.style.display = 'none'; elements.userAdminPanel.style.display = 'none';
if (elements.shoppingListsPanel) elements.shoppingListsPanel.style.display = 'none';
elements.recipesGrid.style.display = 'grid'; elements.recipesGrid.style.display = 'grid';
const filters = elements.librarySearch.closest('.filter-controls'); const filters = elements.librarySearch.closest('.filter-controls');
@ -1320,6 +1393,7 @@ function logout() {
function switchTab(tabName) { function switchTab(tabName) {
elements.tabMyCookbook.classList.remove('active'); elements.tabMyCookbook.classList.remove('active');
elements.tabCommunityVault.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('active');
elements.tabUserAdmin.classList.remove('btn-primary'); elements.tabUserAdmin.classList.remove('btn-primary');
elements.tabUserAdmin.classList.add('btn-outline'); elements.tabUserAdmin.classList.add('btn-outline');
@ -1327,11 +1401,13 @@ function switchTab(tabName) {
// Mobile Tab Bar sync // Mobile Tab Bar sync
elements.mobileTabCookbook.classList.remove('active'); elements.mobileTabCookbook.classList.remove('active');
elements.mobileTabCommunity.classList.remove('active'); elements.mobileTabCommunity.classList.remove('active');
if (elements.mobileTabShopping) elements.mobileTabShopping.classList.remove('active');
elements.mobileTabProfile.classList.remove('active'); elements.mobileTabProfile.classList.remove('active');
elements.recipesGrid.style.display = 'none'; elements.recipesGrid.style.display = 'none';
elements.emptyState.style.display = 'none'; elements.emptyState.style.display = 'none';
elements.userAdminPanel.style.display = 'none'; elements.userAdminPanel.style.display = 'none';
if (elements.shoppingListsPanel) elements.shoppingListsPanel.style.display = 'none';
const filters = elements.librarySearch.closest('.filter-controls'); const filters = elements.librarySearch.closest('.filter-controls');
if (filters) filters.style.display = 'flex'; if (filters) filters.style.display = 'flex';
@ -1342,6 +1418,8 @@ function switchTab(tabName) {
elements.libraryViewTitle.textContent = 'My Cookbook'; elements.libraryViewTitle.textContent = 'My Cookbook';
} else if (tabName === 'community-vault') { } else if (tabName === 'community-vault') {
elements.libraryViewTitle.textContent = 'Shared Cookbook'; elements.libraryViewTitle.textContent = 'Shared Cookbook';
} else if (tabName === 'shopping-lists') {
elements.libraryViewTitle.textContent = 'Shopping Lists';
} else if (tabName === 'user-admin') { } else if (tabName === 'user-admin') {
elements.libraryViewTitle.textContent = 'User Administration'; elements.libraryViewTitle.textContent = 'User Administration';
} }
@ -1359,6 +1437,13 @@ function switchTab(tabName) {
elements.recipesGrid.style.display = 'grid'; elements.recipesGrid.style.display = 'grid';
activeTab = 'community-vault'; activeTab = 'community-vault';
fetchRecipes(); 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') { } else if (tabName === 'user-admin') {
elements.tabUserAdmin.classList.add('btn-primary'); elements.tabUserAdmin.classList.add('btn-primary');
elements.tabUserAdmin.classList.remove('btn-outline'); elements.tabUserAdmin.classList.remove('btn-outline');
@ -1758,6 +1843,10 @@ async function openSettingsModal() {
if (key && !isPlaceholder) { if (key && !isPlaceholder) {
elements.settingsGeminiKeyInput.value = key; elements.settingsGeminiKeyInput.value = key;
} }
if (elements.settingsDefaultConsolidate && typeof settings.defaultConsolidateLists === 'boolean') {
elements.settingsDefaultConsolidate.checked = settings.defaultConsolidateLists;
applyUserDefaultConsolidationPreference(settings.defaultConsolidateLists);
}
} catch (err) { } catch (err) {
console.error('Error loading settings:', err); console.error('Error loading settings:', err);
elements.settingsGeminiKeyInput.placeholder = 'Failed to load settings'; elements.settingsGeminiKeyInput.placeholder = 'Failed to load settings';
@ -1769,6 +1858,7 @@ async function handleSaveSettings(e) {
elements.settingsStatusMsg.style.display = 'none'; elements.settingsStatusMsg.style.display = 'none';
const key = elements.settingsGeminiKeyInput.value.trim(); const key = elements.settingsGeminiKeyInput.value.trim();
const defaultConsolidate = elements.settingsDefaultConsolidate ? elements.settingsDefaultConsolidate.checked : true;
try { try {
const response = await fetch('/api/settings', { const response = await fetch('/api/settings', {
@ -1777,7 +1867,10 @@ async function handleSaveSettings(e) {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
...getAuthHeaders() ...getAuthHeaders()
}, },
body: JSON.stringify({ geminiApiKey: key }) body: JSON.stringify({
geminiApiKey: key,
defaultConsolidateLists: defaultConsolidate
})
}); });
if (response.status === 401) { if (response.status === 401) {
@ -1788,6 +1881,11 @@ async function handleSaveSettings(e) {
if (!response.ok) throw new Error('Failed to save settings file.'); 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 // Show success status banner
elements.settingsStatusMsg.style.display = 'flex'; elements.settingsStatusMsg.style.display = 'flex';
setTimeout(() => { setTimeout(() => {
@ -2073,3 +2171,752 @@ function handleFabClick() {
openManualAdd(); 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 = `<i data-lucide="check"></i> <span>Added (${selectedLines.length})!</span>`;
elements.btnAddRecipeToList.style.backgroundColor = 'var(--accent)';
elements.btnAddRecipeToList.style.borderColor = 'var(--accent)';
lucide.createIcons();
addRecipeBtnTimeout = setTimeout(() => {
elements.btnAddRecipeToList.innerHTML = `<i data-lucide="shopping-cart"></i> <span>Add to List</span>`;
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 = `<i data-lucide="check"></i> <span>Copied!</span>`;
elements.btnShoppingShare.style.borderColor = 'var(--accent)';
elements.btnShoppingShare.style.color = 'var(--accent)';
lucide.createIcons();
shoppingShareBtnTimeout = setTimeout(() => {
elements.btnShoppingShare.innerHTML = `<i data-lucide="share-2"></i> <span>Share Link</span>`;
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 = `<i data-lucide="check"></i> <span>Copied!</span>`;
elements.btnShoppingCopyText.style.borderColor = 'var(--accent)';
elements.btnShoppingCopyText.style.color = 'var(--accent)';
lucide.createIcons();
shoppingCopyTextTimeout = setTimeout(() => {
elements.btnShoppingCopyText.innerHTML = `<i data-lucide="copy"></i> <span>Copy Text</span>`;
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 = `
<div class="shopping-category-header">
<div class="shopping-category-title">
<i data-lucide="${iconName.toLowerCase()}"></i>
<span>${groupName}</span>
</div>
<span class="shopping-category-count">${uncheckedInGroup} / ${groupEntries.length}</span>
</div>
<ul class="shopping-items-list"></ul>
`;
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
? `<div class="shopping-item-sources">${entry.sources.map(s => `<span class="shopping-source-tag">${s}</span>`).join('')}</div>`
: '';
li.innerHTML = `
<div class="shopping-item-main">
<div class="checkbox-custom"><i data-lucide="check" style="width:12px;height:12px;"></i></div>
<div class="shopping-item-text-wrap">
<span class="shopping-item-text">${entry.displayText}</span>
${sourcesHtml}
</div>
</div>
<button type="button" class="shopping-item-delete" title="Remove Item">
<i data-lucide="x"></i>
</button>
`;
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();
}

View file

@ -47,6 +47,10 @@
<button class="mobile-header-tab" id="mobile-header-tab-community" title="Shared Cookbook"> <button class="mobile-header-tab" id="mobile-header-tab-community" title="Shared Cookbook">
<i data-lucide="globe"></i> <i data-lucide="globe"></i>
</button> </button>
<button class="mobile-header-tab" id="mobile-header-tab-shopping" title="Shopping Lists" style="position: relative;">
<i data-lucide="shopping-cart"></i>
<span class="shopping-badge-mobile" id="mobile-shopping-tab-badge" style="display: none;">0</span>
</button>
<button class="mobile-header-btn-add" id="mobile-header-btn-add" title="Add Recipe"> <button class="mobile-header-btn-add" id="mobile-header-btn-add" title="Add Recipe">
<i data-lucide="plus"></i> <i data-lucide="plus"></i>
</button> </button>
@ -93,6 +97,10 @@
<button class="dashboard-tab-btn" id="tab-community-vault"> <button class="dashboard-tab-btn" id="tab-community-vault">
<i data-lucide="globe"></i> <span>Shared Cookbook</span> <i data-lucide="globe"></i> <span>Shared Cookbook</span>
</button> </button>
<button class="dashboard-tab-btn" id="tab-shopping-lists">
<i data-lucide="shopping-cart"></i> <span>Shopping Lists</span>
<span class="shopping-badge" id="shopping-tab-badge" style="display: none;">0</span>
</button>
</div> </div>
<!-- Search and Filters --> <!-- Search and Filters -->
@ -119,6 +127,86 @@
<p>Insert a cooking web link above or create one manually to build your digital recipe collections.</p> <p>Insert a cooking web link above or create one manually to build your digital recipe collections.</p>
</div> </div>
<!-- Shopping Lists Workspace Panel -->
<div id="shopping-lists-panel" style="display: none;" class="shopping-panel-container">
<!-- Top Selector & List Management Bar -->
<div class="shopping-header-bar">
<div class="shopping-list-selector-group">
<label for="shopping-list-select" class="shopping-select-label">
<i data-lucide="clipboard-list"></i> <span>Active List:</span>
</label>
<select id="shopping-list-select" class="shopping-select"></select>
<button type="button" class="btn btn-outline btn-sm" id="btn-shopping-new-list" title="Create New List">
<i data-lucide="plus"></i> <span>New List</span>
</button>
<button type="button" class="btn btn-outline btn-sm" id="btn-shopping-rename-list" title="Rename Active List">
<i data-lucide="edit-2"></i> <span>Rename</span>
</button>
<button type="button" class="btn btn-outline btn-sm btn-danger-text" id="btn-shopping-delete-list" title="Delete Active List">
<i data-lucide="trash-2"></i>
</button>
</div>
<div class="shopping-share-group">
<span id="shopping-share-stats-badge" class="shopping-stats-pill" style="display: none;">
<i data-lucide="eye"></i> <span class="val">0 Visitors</span>
</span>
<button type="button" class="btn btn-outline btn-sm" id="btn-shopping-share" title="Share Shopping List Link">
<i data-lucide="share-2"></i> <span>Share Link</span>
</button>
<button type="button" class="btn btn-outline btn-sm btn-danger-text" id="btn-shopping-revoke-share" style="display: none;" title="Disable Public Share Link">
<i data-lucide="link-2-off"></i> <span>Revoke Link</span>
</button>
<button type="button" class="btn btn-outline btn-sm" id="btn-shopping-copy-text" title="Copy Formatted List to Clipboard">
<i data-lucide="copy"></i> <span>Copy Text</span>
</button>
</div>
</div>
<!-- Quick-Add Manual Item & View Controls -->
<div class="shopping-controls-row">
<form id="shopping-add-item-form" class="shopping-add-form">
<div class="input-wrapper">
<i data-lucide="plus-circle" class="input-icon"></i>
<input type="text" id="shopping-add-item-input" placeholder="Add an item (e.g., 2 lbs ground beef, Whole milk, Paper towels)..." autocomplete="off">
</div>
<button type="submit" class="btn btn-primary">
<i data-lucide="plus"></i> <span>Add Item</span>
</button>
</form>
<div class="shopping-view-toggles">
<label class="shopping-consolidate-toggle" title="Combine duplicate ingredients and sum compatible quantities across recipes">
<input type="checkbox" id="toggle-consolidate-items" checked>
<span class="toggle-track"><span class="toggle-thumb"></span></span>
<span class="toggle-label">Consolidate Ingredients</span>
</label>
<select id="shopping-group-by" class="shopping-select shopping-select-sm" title="Group Items By">
<option value="aisle">Group by Aisle</option>
<option value="recipe">Group by Recipe</option>
</select>
<button type="button" class="btn btn-outline btn-sm" id="btn-shopping-clear-checked" title="Remove Checked Items">
<i data-lucide="check-check"></i> <span>Clear Checked</span>
</button>
<button type="button" class="btn btn-outline btn-sm btn-danger-text" id="btn-shopping-clear-all" title="Clear Entire List">
<i data-lucide="eraser"></i> <span>Clear All</span>
</button>
</div>
</div>
<!-- Grouped Checklist Cards -->
<div id="shopping-lists-content" class="shopping-aisles-grid"></div>
<!-- Empty State for Shopping List -->
<div class="empty-state" id="shopping-empty-state" style="display: none;">
<div class="empty-icon"><i data-lucide="shopping-basket"></i></div>
<h3>This shopping list is empty</h3>
<p>Type an item above or click "Add to Shopping List" inside any recipe to populate your groceries.</p>
</div>
</div>
<!-- User Admin Management Console --> <!-- User Admin Management Console -->
<div id="user-admin-panel" style="display: none;" class="admin-panel-container"> <div id="user-admin-panel" style="display: none;" class="admin-panel-container">
<!-- Admin Console Sub-Tabs --> <!-- Admin Console Sub-Tabs -->
@ -331,8 +419,18 @@
<div class="detail-split-layout"> <div class="detail-split-layout">
<!-- Left: Ingredients Checklist --> <!-- Left: Ingredients Checklist -->
<div class="detail-ingredients-column"> <div class="detail-ingredients-column">
<h3>Ingredients</h3> <div class="detail-ingredients-header">
<p class="section-subtext">Check off items as you gather them</p> <div>
<h3>Ingredients</h3>
<p class="section-subtext">Check off items you already have at home</p>
</div>
<div class="detail-add-to-list-bar">
<select id="detail-shopping-list-select" class="shopping-select shopping-select-sm" title="Select Shopping List"></select>
<button type="button" class="btn btn-primary btn-sm" id="btn-add-recipe-to-list" title="Add unchecked ingredients to selected shopping list">
<i data-lucide="shopping-cart"></i> <span>Add to List</span>
</button>
</div>
</div>
<ul class="ingredients-check-list" id="recipe-detail-ingredients"> <ul class="ingredients-check-list" id="recipe-detail-ingredients">
<!-- Checkbox elements populated via JS --> <!-- Checkbox elements populated via JS -->
</ul> </ul>
@ -571,7 +669,24 @@
</form> </form>
</div> </div>
<!-- SECTION 2: CULINARY OPTIONS (COMING SOON) --> <!-- SECTION 2: SHOPPING LIST PREFERENCES -->
<div class="settings-section" style="margin-bottom: 24px;">
<div class="settings-section-title" style="display: flex; align-items: center; gap: 8px; font-weight: 600; font-size: 1.05rem; border-bottom: 1px solid var(--border-color); padding-bottom: 8px; margin-bottom: 12px; color: var(--text-main);">
<i data-lucide="shopping-cart" style="color: var(--primary); width: 18px; height: 18px;"></i>
<span>Shopping List Preferences</span>
</div>
<p style="font-size: 0.85rem; line-height: 1.4; color: var(--text-muted); margin-bottom: 12px;">
Choose the default behavior for the ingredient consolidation toggle when opening your shopping lists.
</p>
<div style="display: flex; flex-direction: column; gap: 8px;">
<label class="checkbox-container" style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
<input type="checkbox" id="settings-default-consolidate" checked style="cursor: pointer;">
<span class="checkbox-label" style="font-size: 0.85rem; color: var(--text-main);">Consolidate duplicate ingredients by default</span>
</label>
</div>
</div>
<!-- SECTION 3: CULINARY OPTIONS (COMING SOON) -->
<div class="settings-section" style="opacity: 0.55; margin-bottom: 24px;"> <div class="settings-section" style="opacity: 0.55; margin-bottom: 24px;">
<div class="settings-section-title" style="display: flex; align-items: center; justify-content: space-between; font-weight: 600; font-size: 1.05rem; border-bottom: 1px solid var(--border-color); padding-bottom: 8px; margin-bottom: 12px; color: var(--text-main);"> <div class="settings-section-title" style="display: flex; align-items: center; justify-content: space-between; font-weight: 600; font-size: 1.05rem; border-bottom: 1px solid var(--border-color); padding-bottom: 8px; margin-bottom: 12px; color: var(--text-main);">
<div style="display: flex; align-items: center; gap: 8px;"> <div style="display: flex; align-items: center; gap: 8px;">

140
public/share-list.html Normal file
View file

@ -0,0 +1,140 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ForkFolio – Shared Shopping List</title>
<!-- Google Fonts Outfit & Inter -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&family=Outfit:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<!-- Lucide Icons -->
<script src="https://unpkg.com/lucide@latest"></script>
<!-- Custom Styling -->
<link rel="stylesheet" href="style.css">
<style>
.share-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 5%;
border-bottom: 1px solid var(--border-color);
background-color: var(--bg-nav);
backdrop-filter: var(--glass-blur);
position: sticky;
top: 0;
z-index: 100;
}
.share-container {
max-width: 1100px;
margin: 0 auto;
padding: 30px 20px 80px 20px;
}
.list-not-found {
display: none;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 100px 20px;
color: var(--text-muted);
}
.list-not-found i,
.list-not-found svg {
width: 64px;
height: 64px;
margin-bottom: 16px;
color: var(--danger);
}
.list-not-found h2 {
color: var(--text-main);
font-size: 2rem;
margin-bottom: 8px;
}
.shared-list-banner {
background-color: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-lg);
padding: 24px 28px;
margin-bottom: 24px;
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 16px;
box-shadow: var(--shadow-sm);
}
</style>
</head>
<body class="dark-theme">
<!-- Top Navigation -->
<header class="share-header">
<div class="header-logo" style="cursor: pointer;" onclick="window.location.href='/'">
<div class="logo-icon"><i data-lucide="cooking-pot"></i></div>
<span class="logo-text">Fork<span>Folio</span></span>
</div>
<div class="header-actions">
<a href="/" class="btn btn-primary" title="Go to ForkFolio">
<i data-lucide="chef-hat"></i> <span>Open ForkFolio</span>
</a>
<button class="theme-toggle" id="theme-toggle" title="Toggle Theme">
<i data-lucide="sun" class="sun-icon"></i>
<i data-lucide="moon" class="moon-icon"></i>
</button>
</div>
</header>
<!-- Not Found Fallback -->
<div class="list-not-found" id="list-not-found-block">
<i data-lucide="alert-triangle"></i>
<h2>Shopping List Not Found</h2>
<p>This shared shopping list link may have been revoked or no longer exists.</p>
<a href="/" class="btn btn-outline" style="margin-top: 20px;">
<i data-lucide="home"></i> Back to ForkFolio
</a>
</div>
<!-- Shared Shopping List Main Content -->
<main class="share-container" id="share-list-block" style="display: none;">
<section class="shared-list-banner">
<div>
<div style="display: flex; align-items: center; gap: 10px; margin-bottom: 6px;">
<i data-lucide="shopping-cart" style="color: var(--primary); width: 24px; height: 24px;"></i>
<h1 id="shared-list-title" style="font-size: 1.8rem; color: var(--text-main);">Weekly Groceries</h1>
</div>
<span class="creator-badge" style="font-size: 0.85rem; padding: 4px 10px; background: rgba(245, 158, 11, 0.15); border: 1px dashed rgba(245, 158, 11, 0.3); border-radius: 6px; color: #f59e0b; display: inline-flex; align-items: center; gap: 6px;">
<i data-lucide="user" style="width: 12px; height: 12px;"></i> Shared by <span id="shared-list-creator">@chef</span>
</span>
</div>
<div class="shopping-view-toggles">
<label class="shopping-consolidate-toggle">
<input type="checkbox" id="shared-toggle-consolidate" checked>
<span class="toggle-track"><span class="toggle-thumb"></span></span>
<span class="toggle-label">Consolidate Ingredients</span>
</label>
<select id="shared-group-by" class="shopping-select shopping-select-sm">
<option value="aisle">Group by Aisle</option>
<option value="recipe">Group by Recipe</option>
</select>
<button type="button" class="btn btn-outline btn-sm" id="btn-shared-copy-text">
<i data-lucide="copy"></i> <span>Copy Text</span>
</button>
</div>
</section>
<div id="shared-list-content" class="shopping-aisles-grid"></div>
<div class="empty-state" id="shared-list-empty" style="display: none;">
<div class="empty-icon"><i data-lucide="shopping-basket"></i></div>
<h3>This shopping list is currently empty</h3>
<p>No items have been added to this list yet.</p>
</div>
</main>
<script src="share-list.js"></script>
</body>
</html>

316
public/share-list.js Normal file
View file

@ -0,0 +1,316 @@
let sharedListData = null;
let sharedShareToken = null;
let consolidateItems = true;
let groupByMode = 'aisle';
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'
};
window.addEventListener('DOMContentLoaded', () => {
initTheme();
lucide.createIcons();
document.getElementById('theme-toggle').addEventListener('click', toggleTheme);
document.getElementById('shared-toggle-consolidate').addEventListener('change', (e) => {
consolidateItems = e.target.checked;
renderSharedList();
});
document.getElementById('shared-group-by').addEventListener('change', (e) => {
groupByMode = e.target.value;
renderSharedList();
});
document.getElementById('btn-shared-copy-text').addEventListener('click', handleCopySharedText);
const params = new URLSearchParams(window.location.search);
sharedShareToken = params.get('token');
if (!sharedShareToken) {
showNotFound();
return;
}
loadSharedShoppingList();
});
function initTheme() {
const savedTheme = localStorage.getItem('gourmet-theme') || 'dark';
if (savedTheme === 'dark') {
document.body.classList.add('dark-theme');
} else {
document.body.classList.remove('dark-theme');
}
}
function toggleTheme() {
document.body.classList.toggle('dark-theme');
const isDark = document.body.classList.contains('dark-theme');
localStorage.setItem('gourmet-theme', isDark ? 'dark' : 'light');
}
function showNotFound() {
document.getElementById('share-list-block').style.display = 'none';
document.getElementById('list-not-found-block').style.display = 'flex';
lucide.createIcons();
}
async function loadSharedShoppingList() {
try {
const res = await fetch(`/api/share/shopping-lists/${encodeURIComponent(sharedShareToken)}`);
if (!res.ok) {
showNotFound();
return;
}
sharedListData = await res.json();
document.title = `${sharedListData.name} – Shared Shopping List | ForkFolio`;
document.getElementById('shared-list-title').textContent = sharedListData.name;
document.getElementById('shared-list-creator').textContent = `@${sharedListData.createdBy || 'chef'}`;
document.getElementById('share-list-block').style.display = 'block';
renderSharedList();
} catch (err) {
console.error('Failed to load shared shopping list:', err);
showNotFound();
}
}
function formatScaledNumber(num) {
if (num % 1 === 0) return num.toString();
const rounded = Math.round(num * 100) / 100;
const decimal = rounded % 1;
const integer = Math.floor(rounded);
if (Math.abs(decimal - 0.25) < 0.05) return (integer > 0 ? integer + ' ' : '') + '1/4';
if (Math.abs(decimal - 0.5) < 0.05) return (integer > 0 ? integer + ' ' : '') + '1/2';
if (Math.abs(decimal - 0.75) < 0.05) return (integer > 0 ? integer + ' ' : '') + '3/4';
if (Math.abs(decimal - 0.33) < 0.05) return (integer > 0 ? integer + ' ' : '') + '1/3';
if (Math.abs(decimal - 0.66) < 0.05) return (integer > 0 ? integer + ' ' : '') + '2/3';
return rounded.toString();
}
function buildDisplayEntries(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
}));
}
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 groupEntries(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;
}
for (const aisle of AISLE_ORDER) {
const matching = entries.filter(e => (e.category || 'Other') === aisle);
if (matching.length > 0) {
matching.sort((a, b) => Number(a.checked) - Number(b.checked));
grouped.set(aisle, matching);
}
}
return grouped;
}
function renderSharedList() {
const contentEl = document.getElementById('shared-list-content');
const emptyEl = document.getElementById('shared-list-empty');
if (!sharedListData || !Array.isArray(sharedListData.items) || sharedListData.items.length === 0) {
contentEl.innerHTML = '';
emptyEl.style.display = 'flex';
lucide.createIcons();
return;
}
emptyEl.style.display = 'none';
contentEl.innerHTML = '';
const entries = buildDisplayEntries(sharedListData.items, consolidateItems);
const grouped = groupEntries(entries, groupByMode);
for (const [groupName, groupEntries] of grouped.entries()) {
const card = document.createElement('div');
card.className = 'shopping-category-card';
const iconName = AISLE_ICONS[groupName] || 'utensils';
const uncheckedCount = groupEntries.filter(e => !e.checked).length;
card.innerHTML = `
<div class="shopping-category-header">
<div class="shopping-category-title">
<i data-lucide="${iconName.toLowerCase()}"></i>
<span>${groupName}</span>
</div>
<span class="shopping-category-count">${uncheckedCount} / ${groupEntries.length}</span>
</div>
<ul class="shopping-items-list"></ul>
`;
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
? `<div class="shopping-item-sources">${entry.sources.map(s => `<span class="shopping-source-tag">${s}</span>`).join('')}</div>`
: '';
li.innerHTML = `
<div class="shopping-item-main">
<div class="checkbox-custom"><i data-lucide="check" style="width:12px;height:12px;"></i></div>
<div class="shopping-item-text-wrap">
<span class="shopping-item-text">${entry.displayText}</span>
${sourcesHtml}
</div>
</div>
`;
li.addEventListener('click', () => toggleSharedItem(entry.itemIds, !entry.checked));
ul.appendChild(li);
});
contentEl.appendChild(card);
}
lucide.createIcons();
}
async function toggleSharedItem(itemIds, checked) {
try {
const res = await fetch(`/api/share/shopping-lists/${encodeURIComponent(sharedShareToken)}/items/check`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ itemIds, checked })
});
if (res.ok) {
sharedListData = await res.json();
renderSharedList();
}
} catch (err) {
console.error('Error toggling shared shopping list item:', err);
}
}
let copyTimeout = null;
async function handleCopySharedText() {
if (!sharedListData || !Array.isArray(sharedListData.items)) return;
const entries = buildDisplayEntries(sharedListData.items, consolidateItems);
const grouped = groupEntries(entries, groupByMode);
const lines = [`🛒 ${sharedListData.name}`];
for (const [groupName, groupEntries] of grouped.entries()) {
lines.push(`\n[${groupName}]`);
groupEntries.forEach(entry => {
lines.push(`${entry.checked ? '[x]' : '[ ]'} ${entry.displayText}`);
});
}
const text = lines.join('\n');
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(text);
} else {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
}
const btn = document.getElementById('btn-shared-copy-text');
if (btn) {
if (copyTimeout) clearTimeout(copyTimeout);
btn.innerHTML = `<i data-lucide="check"></i> <span>Copied!</span>`;
btn.style.borderColor = 'var(--accent)';
btn.style.color = 'var(--accent)';
lucide.createIcons();
copyTimeout = setTimeout(() => {
btn.innerHTML = `<i data-lucide="copy"></i> <span>Copy Text</span>`;
btn.style.borderColor = '';
btn.style.color = '';
lucide.createIcons();
}, 2000);
}
}

View file

@ -2414,3 +2414,416 @@ h1, h2, h3, h4, h5, h6 {
gap: 8px !important; gap: 8px !important;
} }
} }
/* =============================================================
SHOPPING LISTS WORKSPACE & CONTROLS
============================================================= */
.shopping-badge {
background-color: var(--primary);
color: var(--text-white);
font-size: 0.72rem;
font-weight: 700;
padding: 2px 7px;
border-radius: var(--border-radius-full);
margin-left: 4px;
line-height: 1.2;
}
.shopping-badge-mobile {
position: absolute;
top: -4px;
right: -4px;
background-color: var(--primary);
color: var(--text-white);
font-size: 0.65rem;
font-weight: 700;
min-width: 16px;
height: 16px;
padding: 0 4px;
border-radius: var(--border-radius-full);
display: flex;
align-items: center;
justify-content: center;
}
.btn-sm {
padding: 7px 13px;
font-size: 0.85rem;
border-radius: var(--border-radius-sm);
}
.btn-danger-text:hover {
border-color: var(--danger);
color: var(--danger);
background-color: rgba(239, 68, 68, 0.1);
}
.shopping-panel-container {
display: flex;
flex-direction: column;
gap: 20px;
}
.shopping-header-bar {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 14px;
padding: 16px 20px;
background-color: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-md);
}
.shopping-list-selector-group,
.shopping-share-group {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
}
.shopping-select-label {
display: flex;
align-items: center;
gap: 6px;
font-weight: 600;
font-size: 0.9rem;
color: var(--text-muted);
}
.shopping-select-label i,
.shopping-select-label svg {
width: 16px;
height: 16px;
color: var(--primary);
}
.shopping-select {
background-color: var(--bg-app);
color: var(--text-main);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm);
padding: 8px 12px;
font-family: var(--font-body);
font-size: 0.92rem;
font-weight: 600;
outline: none;
cursor: pointer;
}
.shopping-select:focus {
border-color: var(--primary);
}
.shopping-select-sm {
padding: 6px 10px;
font-size: 0.85rem;
font-weight: 500;
}
.shopping-stats-pill {
background: rgba(99, 102, 241, 0.12);
border: 1px dashed rgba(99, 102, 241, 0.3);
color: #6366f1;
padding: 5px 10px;
border-radius: var(--border-radius-full);
font-size: 0.8rem;
font-weight: 600;
display: inline-flex;
align-items: center;
gap: 6px;
}
.shopping-stats-pill i,
.shopping-stats-pill svg {
width: 14px;
height: 14px;
}
.shopping-controls-row {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 14px;
}
.shopping-add-form {
display: flex;
align-items: center;
gap: 10px;
flex: 1;
min-width: 280px;
background-color: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-md);
padding: 6px 6px 6px 14px;
}
.shopping-add-form .input-wrapper {
display: flex;
align-items: center;
gap: 10px;
flex: 1;
}
.shopping-add-form input {
background: transparent;
border: none;
outline: none;
color: var(--text-main);
width: 100%;
font-size: 0.95rem;
padding: 8px 0;
}
.shopping-view-toggles {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
}
.shopping-consolidate-toggle {
display: inline-flex;
align-items: center;
gap: 8px;
cursor: pointer;
padding: 6px 12px;
border-radius: var(--border-radius-sm);
background-color: var(--bg-card);
border: 1px solid var(--border-color);
user-select: none;
}
.shopping-consolidate-toggle input {
display: none;
}
.shopping-consolidate-toggle .toggle-track {
width: 34px;
height: 18px;
background-color: var(--border-color);
border-radius: var(--border-radius-full);
position: relative;
transition: background-color var(--transition-fast);
}
.shopping-consolidate-toggle .toggle-thumb {
width: 14px;
height: 14px;
background-color: var(--text-white);
border-radius: 50%;
position: absolute;
top: 2px;
left: 2px;
transition: transform var(--transition-fast);
}
.shopping-consolidate-toggle input:checked + .toggle-track {
background-color: var(--primary);
}
.shopping-consolidate-toggle input:checked + .toggle-track .toggle-thumb {
transform: translateX(16px);
}
.shopping-consolidate-toggle .toggle-label {
font-size: 0.85rem;
font-weight: 600;
color: var(--text-main);
}
.shopping-aisles-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
gap: 20px;
align-items: start;
}
.shopping-category-card {
background-color: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-md);
overflow: hidden;
box-shadow: var(--shadow-sm);
}
.shopping-category-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background-color: var(--bg-app);
border-bottom: 1px solid var(--border-color);
}
.shopping-category-title {
display: flex;
align-items: center;
gap: 8px;
font-family: var(--font-heading);
font-size: 1rem;
font-weight: 700;
color: var(--text-main);
}
.shopping-category-title i,
.shopping-category-title svg {
width: 16px;
height: 16px;
color: var(--primary);
}
.shopping-category-count {
font-size: 0.75rem;
font-weight: 600;
color: var(--text-muted);
background-color: var(--bg-card);
padding: 2px 8px;
border-radius: var(--border-radius-full);
border: 1px solid var(--border-color);
}
.shopping-items-list {
list-style: none;
margin: 0;
padding: 6px 0;
}
.shopping-item-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 10px 16px;
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
cursor: pointer;
transition: background-color var(--transition-fast);
}
.shopping-item-row:last-child {
border-bottom: none;
}
.shopping-item-row:hover {
background-color: rgba(255, 255, 255, 0.03);
}
.shopping-item-main {
display: flex;
align-items: flex-start;
gap: 10px;
flex: 1;
}
.shopping-item-row.checked .shopping-item-text {
text-decoration: line-through;
color: var(--text-muted);
opacity: 0.6;
}
.shopping-item-row.checked .checkbox-custom {
background-color: var(--accent);
border-color: var(--accent);
color: var(--text-white);
}
.shopping-item-text-wrap {
display: flex;
flex-direction: column;
gap: 3px;
}
.shopping-item-text {
font-size: 0.95rem;
color: var(--text-main);
line-height: 1.35;
}
.shopping-item-sources {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.shopping-source-tag {
font-size: 0.7rem;
color: var(--primary);
background: rgba(251, 146, 60, 0.12);
border: 1px solid rgba(251, 146, 60, 0.25);
padding: 1px 6px;
border-radius: 4px;
font-weight: 500;
}
.shopping-item-delete {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
padding: 4px;
border-radius: 4px;
opacity: 0.5;
transition: all var(--transition-fast);
}
.shopping-item-delete:hover {
opacity: 1;
color: var(--danger);
background-color: rgba(239, 68, 68, 0.1);
}
.shopping-item-delete i,
.shopping-item-delete svg {
width: 15px;
height: 15px;
display: block;
}
.detail-ingredients-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 12px;
}
.detail-add-to-list-bar {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
@media (max-width: 768px) {
.shopping-aisles-grid {
grid-template-columns: 1fr;
}
.shopping-header-bar {
flex-direction: column;
align-items: stretch;
}
.shopping-list-selector-group,
.shopping-share-group {
width: 100%;
justify-content: space-between;
}
.shopping-select {
flex: 1;
}
.detail-ingredients-header {
flex-direction: column;
align-items: stretch;
}
.detail-add-to-list-bar {
width: 100%;
}
.detail-add-to-list-bar select {
flex: 1;
}
}

690
server.js
View file

@ -37,10 +37,21 @@ db.prepare(`
passwordHash TEXT NOT NULL, passwordHash TEXT NOT NULL,
isAdmin INTEGER NOT NULL DEFAULT 0, isAdmin INTEGER NOT NULL DEFAULT 0,
isRecipeManager INTEGER NOT NULL DEFAULT 0, isRecipeManager INTEGER NOT NULL DEFAULT 0,
defaultConsolidateLists INTEGER NOT NULL DEFAULT 1,
createdAt TEXT NOT NULL createdAt TEXT NOT NULL
) )
`).run(); `).run();
// Ensure defaultConsolidateLists column exists on existing users table
try {
const userCols = db.pragma('table_info(users)');
if (!userCols.some(c => c.name === 'defaultConsolidateLists')) {
db.prepare('ALTER TABLE users ADD COLUMN defaultConsolidateLists INTEGER NOT NULL DEFAULT 1').run();
}
} catch (colErr) {
console.error('Failed to check/add defaultConsolidateLists column:', colErr);
}
db.prepare(` db.prepare(`
CREATE TABLE IF NOT EXISTS recipes ( CREATE TABLE IF NOT EXISTS recipes (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
@ -80,6 +91,45 @@ db.prepare(`
) )
`).run(); `).run();
db.prepare(`
CREATE TABLE IF NOT EXISTS shopping_lists (
id TEXT PRIMARY KEY,
userId TEXT NOT NULL,
createdBy TEXT NOT NULL,
name TEXT NOT NULL,
shareToken TEXT UNIQUE,
createdAt TEXT NOT NULL,
updatedAt TEXT,
FOREIGN KEY (userId) REFERENCES users (id) ON DELETE CASCADE
)
`).run();
db.prepare(`
CREATE TABLE IF NOT EXISTS shopping_list_items (
id TEXT PRIMARY KEY,
listId TEXT NOT NULL,
rawText TEXT NOT NULL,
itemName TEXT NOT NULL,
quantity REAL,
unit TEXT DEFAULT '',
category TEXT NOT NULL DEFAULT 'Other',
recipeId TEXT,
recipeTitle TEXT,
checked INTEGER NOT NULL DEFAULT 0,
createdAt TEXT NOT NULL,
FOREIGN KEY (listId) REFERENCES shopping_lists (id) ON DELETE CASCADE
)
`).run();
db.prepare(`
CREATE TABLE IF NOT EXISTS shopping_list_shares_visits (
shareToken TEXT NOT NULL,
visitorIp TEXT NOT NULL,
visitedAt TEXT NOT NULL,
PRIMARY KEY (shareToken, visitorIp)
)
`).run();
// Initialize single settings record if empty // Initialize single settings record if empty
const settingsCount = db.prepare('SELECT COUNT(*) as count FROM settings').get().count; const settingsCount = db.prepare('SELECT COUNT(*) as count FROM settings').get().count;
if (settingsCount === 0) { if (settingsCount === 0) {
@ -398,7 +448,8 @@ app.post('/api/auth/register', requireAuth, (req, res) => {
id, id,
username: cleanUsername, username: cleanUsername,
isAdmin: false, isAdmin: false,
isRecipeManager: false isRecipeManager: false,
defaultConsolidateLists: true
} }
}); });
} catch (error) { } catch (error) {
@ -427,11 +478,13 @@ app.post('/api/auth/login', (req, res) => {
} }
const token = crypto.randomBytes(32).toString('hex'); const token = crypto.randomBytes(32).toString('hex');
const defaultConsolidateLists = user.defaultConsolidateLists !== 0;
activeSessions.set(token, { activeSessions.set(token, {
id: user.id, id: user.id,
username: user.username, username: user.username,
isAdmin: user.isAdmin === 1, isAdmin: user.isAdmin === 1,
isRecipeManager: user.isRecipeManager === 1 isRecipeManager: user.isRecipeManager === 1,
defaultConsolidateLists
}); });
res.json({ res.json({
@ -440,7 +493,8 @@ app.post('/api/auth/login', (req, res) => {
id: user.id, id: user.id,
username: user.username, username: user.username,
isAdmin: user.isAdmin === 1, isAdmin: user.isAdmin === 1,
isRecipeManager: user.isRecipeManager === 1 isRecipeManager: user.isRecipeManager === 1,
defaultConsolidateLists
} }
}); });
} catch (error) { } catch (error) {
@ -1230,29 +1284,647 @@ async function fetchWithPuppeteer(url) {
} }
} }
// API: Load System Settings // API: Load System Settings & User Preferences
app.get('/api/settings', requireAuth, (req, res) => { app.get('/api/settings', requireAuth, (req, res) => {
try { try {
const settings = db.prepare('SELECT geminiApiKey FROM settings LIMIT 1').get(); const settings = db.prepare('SELECT geminiApiKey FROM settings LIMIT 1').get();
res.json({ geminiApiKey: settings ? settings.geminiApiKey : '' }); const userRow = db.prepare('SELECT defaultConsolidateLists FROM users WHERE id = ?').get(req.user.id);
const defaultConsolidateLists = userRow ? userRow.defaultConsolidateLists !== 0 : true;
res.json({
geminiApiKey: settings ? settings.geminiApiKey : '',
defaultConsolidateLists
});
} catch (e) { } catch (e) {
console.error('Failed to load settings:', e); console.error('Failed to load settings:', e);
res.status(500).json({ error: 'Failed to read settings' }); res.status(500).json({ error: 'Failed to read settings' });
} }
}); });
// API: Save System Settings // API: Save System Settings & User Preferences
app.post('/api/settings', requireAuth, (req, res) => { app.post('/api/settings', requireAuth, (req, res) => {
const { geminiApiKey } = req.body; const { geminiApiKey, defaultConsolidateLists } = req.body;
try { try {
db.prepare('UPDATE settings SET geminiApiKey = ?').run(geminiApiKey || ''); if (geminiApiKey !== undefined) {
res.json({ geminiApiKey: geminiApiKey || '' }); db.prepare('UPDATE settings SET geminiApiKey = ?').run(geminiApiKey || '');
}
if (typeof defaultConsolidateLists === 'boolean') {
db.prepare('UPDATE users SET defaultConsolidateLists = ? WHERE id = ?').run(defaultConsolidateLists ? 1 : 0, req.user.id);
req.user.defaultConsolidateLists = defaultConsolidateLists;
}
const settings = db.prepare('SELECT geminiApiKey FROM settings LIMIT 1').get();
const userRow = db.prepare('SELECT defaultConsolidateLists FROM users WHERE id = ?').get(req.user.id);
res.json({
geminiApiKey: settings ? settings.geminiApiKey : '',
defaultConsolidateLists: userRow ? userRow.defaultConsolidateLists !== 0 : true
});
} catch (e) { } catch (e) {
console.error('Failed to save settings:', e); console.error('Failed to save settings:', e);
res.status(500).json({ error: 'Failed to save settings' }); res.status(500).json({ error: 'Failed to save settings' });
} }
}); });
// API: Update User Preferences directly
app.put('/api/user/preferences', requireAuth, (req, res) => {
const { defaultConsolidateLists } = req.body;
if (typeof defaultConsolidateLists !== 'boolean') {
return res.status(400).json({ error: 'defaultConsolidateLists must be a boolean.' });
}
try {
db.prepare('UPDATE users SET defaultConsolidateLists = ? WHERE id = ?').run(defaultConsolidateLists ? 1 : 0, req.user.id);
req.user.defaultConsolidateLists = defaultConsolidateLists;
res.json({ success: true, defaultConsolidateLists });
} catch (e) {
console.error('Failed to update user preferences:', e);
res.status(500).json({ error: 'Failed to update preferences.' });
}
});
// -------------------------------------------------------------
// SHOPPING LIST PARSER, CLASSIFIER & API ENDPOINTS
// -------------------------------------------------------------
const UNIT_ALIASES = {
tablespoon: 'tbsp', tablespoons: 'tbsp', tbsp: 'tbsp', tbs: 'tbsp', tb: 'tbsp',
teaspoon: 'tsp', teaspoons: 'tsp', tsp: 'tsp',
cup: 'cup', cups: 'cup', c: 'cup',
ounce: 'oz', ounces: 'oz', oz: 'oz',
pound: 'lb', pounds: 'lb', lb: 'lb', lbs: 'lb',
gram: 'g', grams: 'g', g: 'g',
kilogram: 'kg', kilograms: 'kg', kg: 'kg',
milliliter: 'ml', milliliters: 'ml', ml: 'ml',
liter: 'l', liters: 'l', l: 'l',
clove: 'clove', cloves: 'clove',
can: 'can', cans: 'can',
jar: 'jar', jars: 'jar',
bunch: 'bunch', bunches: 'bunch',
pinch: 'pinch', pinches: 'pinch',
dash: 'dash', dashes: 'dash',
slice: 'slice', slices: 'slice',
piece: 'piece', pieces: 'piece',
head: 'head', heads: 'head',
stalk: 'stalk', stalks: 'stalk',
sprig: 'sprig', sprigs: 'sprig',
package: 'pkg', packages: 'pkg', pkg: 'pkg', pkgs: 'pkg',
stick: 'stick', sticks: 'stick'
};
const AISLE_KEYWORDS = [
{
category: 'Produce',
keywords: [
'onion', 'shallot', 'scallion', 'garlic', 'ginger', 'potato', 'sweet potato', 'carrot', 'celery',
'bell pepper', 'jalapeno', 'chili pepper', 'poblano', 'habanero', 'serrano', 'tomato', 'cherry tomato',
'lettuce', 'spinach', 'kale', 'arugula', 'cabbage', 'broccoli', 'cauliflower', 'zucchini', 'squash',
'cucumber', 'mushroom', 'avocado', 'corn', 'asparagus', 'green bean', 'pea', 'eggplant', 'radish',
'beet', 'turnip', 'leek', 'fennel', 'artichoke', 'cilantro', 'parsley', 'basil', 'mint', 'rosemary',
'thyme', 'dill', 'chive', 'sage', 'apple', 'banana', 'lemon', 'lime', 'orange', 'grapefruit',
'berry', 'strawberry', 'blueberry', 'raspberry', 'blackberry', 'grape', 'peach', 'pear', 'mango',
'pineapple', 'watermelon', 'melon', 'pomegranate', 'fig', 'date'
]
},
{
category: 'Meat & Seafood',
keywords: [
'beef', 'ground beef', 'steak', 'chuck', 'sirloin', 'brisket', 'roast', 'pork', 'bacon', 'sausage',
'ham', 'prosciutto', 'pancetta', 'chorizo', 'pork chop', 'tenderloin', 'chicken', 'turkey', 'duck',
'lamb', 'veal', 'salmon', 'tuna', 'cod', 'halibut', 'tilapia', 'trout', 'mahimahi', 'sea bass',
'shrimp', 'prawn', 'scallop', 'crab', 'lobster', 'clam', 'mussel', 'oyster', 'anchovy', 'fish'
]
},
{
category: 'Dairy & Eggs',
keywords: [
'milk', 'cream', 'heavy cream', 'half-and-half', 'half and half', 'butter', 'margarine', 'egg',
'cheese', 'cheddar', 'mozzarella', 'parmesan', 'pecorino', 'feta', 'ricotta', 'gouda', 'gruyere',
'swiss', 'provolone', 'brie', 'goat cheese', 'cream cheese', 'sour cream', 'yogurt', 'greek yogurt',
'buttermilk', 'whipped cream', 'ghee', 'mascarpone'
]
},
{
category: 'Canned & Jarred',
keywords: [
'canned', 'crushed tomato', 'diced tomato', 'tomato paste', 'tomato sauce', 'marinara', 'salsa',
'kidney bean', 'black bean', 'pinto bean', 'cannellini', 'chickpea', 'garbanzo', 'lentil',
'broth', 'stock', 'chicken broth', 'beef broth', 'vegetable broth', 'coconut milk', 'olives',
'capers', 'pickle', 'jam', 'jelly', 'peanut butter', 'almond butter', 'tahini', 'artichoke heart',
'roasted red pepper', 'chipotle in adobo', 'beans'
]
},
{
category: 'Bakery & Grains',
keywords: [
'bread', 'loaf', 'baguette', 'ciabatta', 'sourdough', 'roll', 'bun', 'tortilla', 'pita', 'naan',
'bagel', 'croissant', 'english muffin', 'pasta', 'spaghetti', 'penne', 'fettuccine', 'linguine',
'macaroni', 'lasagna', 'orzo', 'noodle', 'ramen', 'udon', 'rice', 'jasmine rice', 'basmati',
'arborio', 'brown rice', 'quinoa', 'couscous', 'barley', 'oats', 'oatmeal', 'breadcrumb', 'panko',
'cracker', 'taco shell', 'tortilla chip'
]
},
{
category: 'Pantry & Spices',
keywords: [
'olive oil', 'vegetable oil', 'canola oil', 'sesame oil', 'coconut oil', 'avocado oil', 'oil',
'vinegar', 'apple cider vinegar', 'balsamic', 'red wine vinegar', 'white wine vinegar', 'rice vinegar',
'soy sauce', 'tamari', 'fish sauce', 'worcestershire', 'hot sauce', 'sriracha', 'mustard', 'dijon',
'ketchup', 'mayonnaise', 'mayo', 'honey', 'maple syrup', 'molasses', 'agave', 'sugar', 'brown sugar',
'powdered sugar', 'confectioners', 'flour', 'all-purpose flour', 'cornstarch', 'baking powder',
'baking soda', 'yeast', 'vanilla', 'cocoa', 'chocolate', 'chip', 'nut', 'almond', 'walnut', 'pecan',
'cashew', 'peanut', 'pine nut', 'pistachio', 'seed', 'sesame', 'chia', 'flax', 'sunflower',
'salt', 'kosher salt', 'sea salt', 'black pepper', 'pepper', 'chili powder', 'cumin', 'paprika',
'smoked paprika', 'cayenne', 'oregano', 'garlic powder', 'onion powder', 'cinnamon', 'nutmeg',
'cloves', 'allspice', 'cardamom', 'turmeric', 'curry', 'coriander', 'bay leaf', 'bay leaves',
'red pepper flakes', 'italian seasoning', 'thyme', 'rosemary', 'spice', 'seasoning', 'bouillon'
]
},
{
category: 'Frozen',
keywords: [
'frozen', 'ice cream', 'puff pastry', 'pie crust', 'frozen pea', 'frozen corn', 'frozen berry',
'edamame', 'tater tot', 'fries'
]
}
];
function classifyGroceryCategory(itemName, rawText) {
const haystack = `${itemName} ${rawText}`.toLowerCase();
// Check Canned & Jarred first if "can" or "canned" or "jar" is explicitly present
if (/\b(can|cans|canned|jar|jars|jarred)\b/.test(haystack)) {
for (const kw of AISLE_KEYWORDS.find(a => a.category === 'Canned & Jarred').keywords) {
if (haystack.includes(kw)) return 'Canned & Jarred';
}
}
for (const group of AISLE_KEYWORDS) {
for (const kw of group.keywords) {
const regex = new RegExp(`\\b${kw.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}s?\\b`, 'i');
if (regex.test(haystack)) {
return group.category;
}
}
}
return 'Other';
}
function parseIngredientLine(rawInput) {
const rawText = (rawInput || '').trim().replace(/\s+/g, ' ');
if (!rawText) {
return { rawText: '', itemName: '', quantity: null, unit: '', category: 'Other' };
}
// Normalize unicode vulgar fractions for parsing
const unicodeMap = {
'½': ' 1/2', '⅓': ' 1/3', '⅔': ' 2/3', '¼': ' 1/4', '¾': ' 3/4',
'⅕': ' 1/5', '⅖': ' 2/5', '⅗': ' 3/5', '⅘': ' 4/5', '⅙': ' 1/6', '⅚': ' 5/6', '⅛': ' 1/8', '⅜': ' 3/8', '⅝': ' 5/8', '⅞': ' 7/8'
};
let working = rawText.replace(/[½⅓⅔¼¾⅕⅖⅗⅘⅙⅚⅛⅜⅝⅞]/g, m => unicodeMap[m] || m).trim().replace(/\s+/g, ' ');
// Remove parenthetical notes like "(about 1 1/2 cups)" or "(15-ounce)"
let withoutParens = working.replace(/\([^)]*\)/g, ' ').replace(/\s+/g, ' ').trim();
let quantity = null;
let rest = withoutParens;
// Match mixed fraction "1 1/2", simple fraction "3/4", range "1-2" or "1 to 2", or decimal/integer
const mixedMatch = rest.match(/^(\d+)\s+(\d+)\/(\d+)\b\s*(.*)/);
const fracMatch = !mixedMatch && rest.match(/^(\d+)\/(\d+)\b\s*(.*)/);
const rangeMatch = !mixedMatch && !fracMatch && rest.match(/^(\d+(?:\.\d+)?)\s*(?:-|to)\s*(\d+(?:\.\d+)?)\b\s*(.*)/i);
const numMatch = !mixedMatch && !fracMatch && !rangeMatch && rest.match(/^(\d+(?:\.\d+)?)\b\s*(.*)/);
if (mixedMatch) {
quantity = parseInt(mixedMatch[1], 10) + (parseInt(mixedMatch[2], 10) / parseInt(mixedMatch[3], 10));
rest = mixedMatch[4];
} else if (fracMatch) {
quantity = parseInt(fracMatch[1], 10) / parseInt(fracMatch[2], 10);
rest = fracMatch[3];
} else if (rangeMatch) {
quantity = parseFloat(rangeMatch[2]);
rest = rangeMatch[3];
} else if (numMatch) {
quantity = parseFloat(numMatch[1]);
rest = numMatch[2];
}
if (quantity !== null && (!isFinite(quantity) || isNaN(quantity))) {
quantity = null;
}
// Extract unit if first token matches UNIT_ALIASES
let unit = '';
const unitTokenMatch = rest.match(/^([a-zA-Z]+)\.?\b\s*(.*)/);
if (unitTokenMatch) {
const candidate = unitTokenMatch[1].toLowerCase();
if (UNIT_ALIASES[candidate]) {
unit = UNIT_ALIASES[candidate];
rest = unitTokenMatch[2].replace(/^of\b\s*/i, '');
}
}
// Clean itemName: strip trailing prep instructions after comma (e.g. ", diced", ", finely chopped")
let itemName = rest.split(',')[0].trim();
// Remove leading descriptors like "medium ", "large ", "small ", "fresh ", "boneless skinless "
itemName = itemName
.replace(/^(?:of\s+|medium\s+|large\s+|small\s+|extra-large\s+|fresh\s+|dried\s+|finely\s+|chopped\s+|diced\s+|minced\s+|sliced\s+|grated\s+|shredded\s+)+/i, '')
.trim()
.toLowerCase();
// Basic singularization for better consolidation matching
if (itemName.length > 4) {
if (itemName.endsWith('tomatoes')) itemName = itemName.slice(0, -2);
else if (itemName.endsWith('potatoes')) itemName = itemName.slice(0, -2);
else if (itemName.endsWith('berries')) itemName = itemName.slice(0, -3) + 'y';
else if (itemName.endsWith('leaves')) itemName = itemName.slice(0, -3) + 'f';
else if (itemName.endsWith('s') && !itemName.endsWith('ss') && !itemName.endsWith('us') && !itemName.endsWith('beans') && !itemName.endsWith('oats') && !itemName.endsWith('flakes') && !itemName.endsWith('noodles') && !itemName.endsWith('chips')) {
itemName = itemName.slice(0, -1);
}
}
if (!itemName) {
itemName = rawText.toLowerCase();
}
const category = classifyGroceryCategory(itemName, rawText);
return {
rawText,
itemName,
quantity: quantity !== null ? Math.round(quantity * 100) / 100 : null,
unit,
category
};
}
function formatShoppingList(listRow) {
if (!listRow) return null;
const items = db.prepare('SELECT * FROM shopping_list_items WHERE listId = ? ORDER BY createdAt ASC').all(listRow.id).map(item => ({
...item,
checked: item.checked === 1
}));
let visitorCount = 0;
if (listRow.shareToken) {
try {
const stats = db.prepare('SELECT COUNT(*) as count FROM shopping_list_shares_visits WHERE shareToken = ?').get(listRow.shareToken);
visitorCount = stats ? stats.count : 0;
} catch (e) {
console.error('Failed to query shopping list visitor count:', e);
}
}
return {
...listRow,
items,
visitorCount
};
}
// API: Get all shopping lists for current user (creates a default list if none exist)
app.get('/api/shopping-lists', requireAuth, (req, res) => {
try {
let rows = db.prepare('SELECT * FROM shopping_lists WHERE userId = ? ORDER BY createdAt ASC').all(req.user.id);
if (rows.length === 0) {
const defaultId = 'sl_' + Date.now().toString(36) + Math.random().toString(36).substr(2, 5);
const now = new Date().toISOString();
db.prepare(`
INSERT INTO shopping_lists (id, userId, createdBy, name, shareToken, createdAt, updatedAt)
VALUES (?, ?, ?, ?, NULL, ?, NULL)
`).run(defaultId, req.user.id, req.user.username, 'Weekly Groceries', now);
rows = db.prepare('SELECT * FROM shopping_lists WHERE userId = ? ORDER BY createdAt ASC').all(req.user.id);
}
res.json(rows.map(formatShoppingList));
} catch (error) {
console.error('Error fetching shopping lists:', error);
res.status(500).json({ error: 'Failed to retrieve shopping lists.' });
}
});
// API: Create a new shopping list
app.post('/api/shopping-lists', requireAuth, (req, res) => {
const name = (req.body.name || '').trim();
if (!name) {
return res.status(400).json({ error: 'Shopping list name is required.' });
}
try {
const id = 'sl_' + Date.now().toString(36) + Math.random().toString(36).substr(2, 5);
const now = new Date().toISOString();
db.prepare(`
INSERT INTO shopping_lists (id, userId, createdBy, name, shareToken, createdAt, updatedAt)
VALUES (?, ?, ?, ?, NULL, ?, NULL)
`).run(id, req.user.id, req.user.username, name, now);
const created = formatShoppingList(db.prepare('SELECT * FROM shopping_lists WHERE id = ?').get(id));
res.status(201).json(created);
} catch (error) {
console.error('Error creating shopping list:', error);
res.status(500).json({ error: 'Failed to create shopping list.' });
}
});
// API: Rename a shopping list
app.put('/api/shopping-lists/:id', requireAuth, (req, res) => {
const name = (req.body.name || '').trim();
if (!name) {
return res.status(400).json({ error: 'Shopping list name is required.' });
}
try {
const existing = db.prepare('SELECT * FROM shopping_lists WHERE id = ?').get(req.params.id);
if (!existing) return res.status(404).json({ error: 'Shopping list not found.' });
if (existing.userId !== req.user.id && !req.user.isAdmin) {
return res.status(403).json({ error: 'Permission denied.' });
}
db.prepare('UPDATE shopping_lists SET name = ?, updatedAt = ? WHERE id = ?').run(name, new Date().toISOString(), req.params.id);
const updated = formatShoppingList(db.prepare('SELECT * FROM shopping_lists WHERE id = ?').get(req.params.id));
res.json(updated);
} catch (error) {
console.error('Error renaming shopping list:', error);
res.status(500).json({ error: 'Failed to rename shopping list.' });
}
});
// API: Delete a shopping list
app.delete('/api/shopping-lists/:id', requireAuth, (req, res) => {
try {
const existing = db.prepare('SELECT * FROM shopping_lists WHERE id = ?').get(req.params.id);
if (!existing) return res.status(404).json({ error: 'Shopping list not found.' });
if (existing.userId !== req.user.id && !req.user.isAdmin) {
return res.status(403).json({ error: 'Permission denied.' });
}
const delTx = db.transaction(() => {
if (existing.shareToken) {
db.prepare('DELETE FROM shopping_list_shares_visits WHERE shareToken = ?').run(existing.shareToken);
}
db.prepare('DELETE FROM shopping_lists WHERE id = ?').run(req.params.id);
});
delTx();
res.json({ success: true });
} catch (error) {
console.error('Error deleting shopping list:', error);
res.status(500).json({ error: 'Failed to delete shopping list.' });
}
});
// API: Add one or more items to a shopping list
app.post('/api/shopping-lists/:id/items', requireAuth, (req, res) => {
try {
const existing = db.prepare('SELECT * FROM shopping_lists WHERE id = ?').get(req.params.id);
if (!existing) return res.status(404).json({ error: 'Shopping list not found.' });
if (existing.userId !== req.user.id && !req.user.isAdmin) {
return res.status(403).json({ error: 'Permission denied.' });
}
const rawItems = Array.isArray(req.body.items)
? req.body.items
: (req.body.text ? [req.body.text] : []);
const recipeId = req.body.recipeId || null;
const recipeTitle = req.body.recipeTitle || null;
if (rawItems.length === 0) {
return res.status(400).json({ error: 'No items provided.' });
}
const insertStmt = db.prepare(`
INSERT INTO shopping_list_items (
id, listId, rawText, itemName, quantity, unit, category, recipeId, recipeTitle, checked, createdAt
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?)
`);
const now = new Date().toISOString();
const insertMany = db.transaction((lines) => {
lines.forEach((line, idx) => {
if (!line || typeof line !== 'string' || !line.trim()) return;
const parsed = parseIngredientLine(line);
const itemId = 'sli_' + Date.now().toString(36) + '_' + idx + '_' + Math.random().toString(36).substr(2, 4);
insertStmt.run(
itemId,
existing.id,
parsed.rawText,
parsed.itemName,
parsed.quantity,
parsed.unit,
parsed.category,
recipeId,
recipeTitle,
now
);
});
db.prepare('UPDATE shopping_lists SET updatedAt = ? WHERE id = ?').run(now, existing.id);
});
insertMany(rawItems);
const updated = formatShoppingList(db.prepare('SELECT * FROM shopping_lists WHERE id = ?').get(existing.id));
res.status(201).json(updated);
} catch (error) {
console.error('Error adding shopping list items:', error);
res.status(500).json({ error: 'Failed to add items to shopping list.' });
}
});
// API: Toggle checked state for one or multiple shopping list items
app.put('/api/shopping-lists/:id/items/check', requireAuth, (req, res) => {
try {
const existing = db.prepare('SELECT * FROM shopping_lists WHERE id = ?').get(req.params.id);
if (!existing) return res.status(404).json({ error: 'Shopping list not found.' });
if (existing.userId !== req.user.id && !req.user.isAdmin) {
return res.status(403).json({ error: 'Permission denied.' });
}
const itemIds = Array.isArray(req.body.itemIds) ? req.body.itemIds : [];
const checked = req.body.checked ? 1 : 0;
if (itemIds.length === 0) {
return res.status(400).json({ error: 'itemIds array is required.' });
}
const updateStmt = db.prepare('UPDATE shopping_list_items SET checked = ? WHERE id = ? AND listId = ?');
const tx = db.transaction((ids) => {
for (const itemId of ids) {
updateStmt.run(checked, itemId, existing.id);
}
db.prepare('UPDATE shopping_lists SET updatedAt = ? WHERE id = ?').run(new Date().toISOString(), existing.id);
});
tx(itemIds);
const updated = formatShoppingList(db.prepare('SELECT * FROM shopping_lists WHERE id = ?').get(existing.id));
res.json(updated);
} catch (error) {
console.error('Error toggling shopping list items:', error);
res.status(500).json({ error: 'Failed to update item state.' });
}
});
// API: Delete one or multiple items from a shopping list
app.post('/api/shopping-lists/:id/items/delete', requireAuth, (req, res) => {
try {
const existing = db.prepare('SELECT * FROM shopping_lists WHERE id = ?').get(req.params.id);
if (!existing) return res.status(404).json({ error: 'Shopping list not found.' });
if (existing.userId !== req.user.id && !req.user.isAdmin) {
return res.status(403).json({ error: 'Permission denied.' });
}
const itemIds = Array.isArray(req.body.itemIds) ? req.body.itemIds : [];
const delStmt = db.prepare('DELETE FROM shopping_list_items WHERE id = ? AND listId = ?');
const tx = db.transaction((ids) => {
for (const itemId of ids) {
delStmt.run(itemId, existing.id);
}
db.prepare('UPDATE shopping_lists SET updatedAt = ? WHERE id = ?').run(new Date().toISOString(), existing.id);
});
tx(itemIds);
const updated = formatShoppingList(db.prepare('SELECT * FROM shopping_lists WHERE id = ?').get(existing.id));
res.json(updated);
} catch (error) {
console.error('Error deleting shopping list items:', error);
res.status(500).json({ error: 'Failed to delete items.' });
}
});
// API: Clear checked or all items from a shopping list
app.post('/api/shopping-lists/:id/clear', requireAuth, (req, res) => {
try {
const existing = db.prepare('SELECT * FROM shopping_lists WHERE id = ?').get(req.params.id);
if (!existing) return res.status(404).json({ error: 'Shopping list not found.' });
if (existing.userId !== req.user.id && !req.user.isAdmin) {
return res.status(403).json({ error: 'Permission denied.' });
}
const onlyChecked = req.body.onlyChecked !== false;
if (onlyChecked) {
db.prepare('DELETE FROM shopping_list_items WHERE listId = ? AND checked = 1').run(existing.id);
} else {
db.prepare('DELETE FROM shopping_list_items WHERE listId = ?').run(existing.id);
}
db.prepare('UPDATE shopping_lists SET updatedAt = ? WHERE id = ?').run(new Date().toISOString(), existing.id);
const updated = formatShoppingList(db.prepare('SELECT * FROM shopping_lists WHERE id = ?').get(existing.id));
res.json(updated);
} catch (error) {
console.error('Error clearing shopping list:', error);
res.status(500).json({ error: 'Failed to clear shopping list.' });
}
});
// API: Generate or retrieve public share link token for a shopping list
app.post('/api/shopping-lists/:id/share', requireAuth, (req, res) => {
try {
const existing = db.prepare('SELECT * FROM shopping_lists WHERE id = ?').get(req.params.id);
if (!existing) return res.status(404).json({ error: 'Shopping list not found.' });
if (existing.userId !== req.user.id && !req.user.isAdmin) {
return res.status(403).json({ error: 'Permission denied.' });
}
let shareToken = existing.shareToken;
if (!shareToken) {
shareToken = crypto.randomBytes(16).toString('hex');
db.prepare('UPDATE shopping_lists SET shareToken = ?, updatedAt = ? WHERE id = ?').run(shareToken, new Date().toISOString(), existing.id);
}
const updated = formatShoppingList(db.prepare('SELECT * FROM shopping_lists WHERE id = ?').get(existing.id));
res.json(updated);
} catch (error) {
console.error('Error sharing shopping list:', error);
res.status(500).json({ error: 'Failed to generate shopping list share link.' });
}
});
// API: Revoke public share link for a shopping list
app.post('/api/shopping-lists/:id/revoke-share', requireAuth, (req, res) => {
try {
const existing = db.prepare('SELECT * FROM shopping_lists WHERE id = ?').get(req.params.id);
if (!existing) return res.status(404).json({ error: 'Shopping list not found.' });
if (existing.userId !== req.user.id && !req.user.isAdmin) {
return res.status(403).json({ error: 'Permission denied.' });
}
const oldToken = existing.shareToken;
const revokeTx = db.transaction(() => {
db.prepare('UPDATE shopping_lists SET shareToken = NULL, updatedAt = ? WHERE id = ?').run(new Date().toISOString(), existing.id);
if (oldToken) {
db.prepare('DELETE FROM shopping_list_shares_visits WHERE shareToken = ?').run(oldToken);
}
});
revokeTx();
const updated = formatShoppingList(db.prepare('SELECT * FROM shopping_lists WHERE id = ?').get(existing.id));
res.json(updated);
} catch (error) {
console.error('Error revoking shopping list share link:', error);
res.status(500).json({ error: 'Failed to revoke shopping list share link.' });
}
});
// Public API: View shared shopping list by shareToken
app.get('/api/share/shopping-lists/:shareToken', publicShareLimiter, (req, res) => {
const shareToken = (req.params.shareToken || '').trim();
if (!shareToken) {
return res.status(400).json({ error: 'Share token is required.' });
}
try {
const listRow = db.prepare('SELECT * FROM shopping_lists WHERE shareToken = ?').get(shareToken);
if (!listRow) {
return res.status(404).json({ error: 'Shopping list not found or sharing has been disabled.' });
}
try {
const ip = req.headers['x-forwarded-for'] || req.socket.remoteAddress;
db.prepare(`
INSERT OR IGNORE INTO shopping_list_shares_visits (shareToken, visitorIp, visitedAt)
VALUES (?, ?, ?)
`).run(shareToken, ip, new Date().toISOString());
} catch (visitErr) {
console.error('Failed to log shared shopping list visitor IP:', visitErr);
}
const formatted = formatShoppingList(listRow);
res.json({
name: formatted.name,
createdBy: formatted.createdBy,
createdAt: formatted.createdAt,
updatedAt: formatted.updatedAt,
items: formatted.items
});
} catch (error) {
console.error('Error serving shared shopping list:', error);
res.status(500).json({ error: 'Internal server error while retrieving shared shopping list.' });
}
});
// Public API: Check/uncheck items on a shared shopping list
app.put('/api/share/shopping-lists/:shareToken/items/check', publicShareLimiter, (req, res) => {
const shareToken = (req.params.shareToken || '').trim();
if (!shareToken) {
return res.status(400).json({ error: 'Share token is required.' });
}
try {
const listRow = db.prepare('SELECT * FROM shopping_lists WHERE shareToken = ?').get(shareToken);
if (!listRow) {
return res.status(404).json({ error: 'Shopping list not found or sharing has been disabled.' });
}
const itemIds = Array.isArray(req.body.itemIds) ? req.body.itemIds : [];
const checked = req.body.checked ? 1 : 0;
if (itemIds.length === 0) {
return res.status(400).json({ error: 'itemIds array is required.' });
}
const updateStmt = db.prepare('UPDATE shopping_list_items SET checked = ? WHERE id = ? AND listId = ?');
const tx = db.transaction((ids) => {
for (const itemId of ids) {
updateStmt.run(checked, itemId, listRow.id);
}
db.prepare('UPDATE shopping_lists SET updatedAt = ? WHERE id = ?').run(new Date().toISOString(), listRow.id);
});
tx(itemIds);
const formatted = formatShoppingList(listRow);
res.json({
name: formatted.name,
createdBy: formatted.createdBy,
createdAt: formatted.createdAt,
updatedAt: formatted.updatedAt,
items: formatted.items
});
} catch (error) {
console.error('Error updating shared shopping list items:', error);
res.status(500).json({ error: 'Failed to update shared shopping list.' });
}
});
// API: Sanity-checked, unauthenticated public recipe sharing with rate limiting // API: Sanity-checked, unauthenticated public recipe sharing with rate limiting
app.get('/api/share/recipes/:shareToken', publicShareLimiter, (req, res) => { app.get('/api/share/recipes/:shareToken', publicShareLimiter, (req, res) => {
const shareToken = req.params.shareToken; const shareToken = req.params.shareToken;