mirror of
https://gitlab.com/Zer0interrupt/recipe-manager.git
synced 2026-11-01 23:24:11 -05:00
feat: add multiple shareable shopping lists with smart consolidation toggle and default user setting
This commit is contained in:
parent
72b965743e
commit
dc6f500045
6 changed files with 2517 additions and 14 deletions
851
public/app.js
851
public/app.js
|
|
@ -8,6 +8,10 @@ let stopwatchSeconds = 0;
|
|||
let cookingActiveStep = 0;
|
||||
let currentUser = null;
|
||||
let activeTab = 'my-cookbook';
|
||||
let shoppingLists = [];
|
||||
let activeShoppingListId = null;
|
||||
let consolidateItems = true;
|
||||
let shoppingGroupBy = 'aisle';
|
||||
|
||||
// Page elements caching
|
||||
const elements = {
|
||||
|
|
@ -131,6 +135,8 @@ const elements = {
|
|||
fabAddRecipe: document.getElementById('mobile-header-btn-add'),
|
||||
mobileTabCookbook: document.getElementById('mobile-header-tab-cookbook'),
|
||||
mobileTabCommunity: document.getElementById('mobile-header-tab-community'),
|
||||
mobileTabShopping: document.getElementById('mobile-header-tab-shopping'),
|
||||
mobileShoppingTabBadge: document.getElementById('mobile-shopping-tab-badge'),
|
||||
mobileTabProfile: document.getElementById('mobile-header-btn-profile'),
|
||||
mobileProfileModal: document.getElementById('mobile-profile-modal'),
|
||||
mobileProfileUsername: document.getElementById('mobile-profile-username'),
|
||||
|
|
@ -140,7 +146,31 @@ const elements = {
|
|||
mobileBtnAdmin: document.getElementById('mobile-btn-admin'),
|
||||
mobileBtnLogout: document.getElementById('mobile-btn-logout'),
|
||||
headerLogo: document.getElementById('header-logo'),
|
||||
libraryViewTitle: document.getElementById('library-view-title')
|
||||
libraryViewTitle: document.getElementById('library-view-title'),
|
||||
|
||||
// Shopping Lists Components
|
||||
tabShoppingLists: document.getElementById('tab-shopping-lists'),
|
||||
shoppingTabBadge: document.getElementById('shopping-tab-badge'),
|
||||
shoppingListsPanel: document.getElementById('shopping-lists-panel'),
|
||||
shoppingListSelect: document.getElementById('shopping-list-select'),
|
||||
btnShoppingNewList: document.getElementById('btn-shopping-new-list'),
|
||||
btnShoppingRenameList: document.getElementById('btn-shopping-rename-list'),
|
||||
btnShoppingDeleteList: document.getElementById('btn-shopping-delete-list'),
|
||||
shoppingShareStatsBadge: document.getElementById('shopping-share-stats-badge'),
|
||||
btnShoppingShare: document.getElementById('btn-shopping-share'),
|
||||
btnShoppingRevokeShare: document.getElementById('btn-shopping-revoke-share'),
|
||||
btnShoppingCopyText: document.getElementById('btn-shopping-copy-text'),
|
||||
shoppingAddItemForm: document.getElementById('shopping-add-item-form'),
|
||||
shoppingAddItemInput: document.getElementById('shopping-add-item-input'),
|
||||
toggleConsolidateItems: document.getElementById('toggle-consolidate-items'),
|
||||
shoppingGroupBySelect: document.getElementById('shopping-group-by'),
|
||||
btnShoppingClearChecked: document.getElementById('btn-shopping-clear-checked'),
|
||||
btnShoppingClearAll: document.getElementById('btn-shopping-clear-all'),
|
||||
shoppingListsContent: document.getElementById('shopping-lists-content'),
|
||||
shoppingEmptyState: document.getElementById('shopping-empty-state'),
|
||||
detailShoppingListSelect: document.getElementById('detail-shopping-list-select'),
|
||||
btnAddRecipeToList: document.getElementById('btn-add-recipe-to-list'),
|
||||
settingsDefaultConsolidate: document.getElementById('settings-default-consolidate')
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------
|
||||
|
|
@ -193,8 +223,35 @@ function registerEvents() {
|
|||
// Dashboard Tabs Switchers
|
||||
elements.tabMyCookbook.addEventListener('click', () => switchTab('my-cookbook'));
|
||||
elements.tabCommunityVault.addEventListener('click', () => switchTab('community-vault'));
|
||||
elements.tabShoppingLists.addEventListener('click', () => switchTab('shopping-lists'));
|
||||
elements.tabUserAdmin.addEventListener('click', () => switchTab('user-admin'));
|
||||
|
||||
// Shopping Lists Events
|
||||
elements.shoppingListSelect.addEventListener('change', (e) => {
|
||||
activeShoppingListId = e.target.value;
|
||||
renderShoppingListsWorkspace();
|
||||
});
|
||||
elements.btnShoppingNewList.addEventListener('click', handleCreateShoppingList);
|
||||
elements.btnShoppingRenameList.addEventListener('click', handleRenameShoppingList);
|
||||
elements.btnShoppingDeleteList.addEventListener('click', handleDeleteShoppingList);
|
||||
elements.btnShoppingShare.addEventListener('click', handleShareShoppingList);
|
||||
elements.btnShoppingRevokeShare.addEventListener('click', handleRevokeShoppingListShare);
|
||||
elements.btnShoppingCopyText.addEventListener('click', handleCopyShoppingListText);
|
||||
elements.shoppingAddItemForm.addEventListener('submit', handleAddManualShoppingItem);
|
||||
elements.toggleConsolidateItems.addEventListener('change', (e) => {
|
||||
consolidateItems = e.target.checked;
|
||||
renderShoppingListsWorkspace();
|
||||
});
|
||||
elements.shoppingGroupBySelect.addEventListener('change', (e) => {
|
||||
shoppingGroupBy = e.target.value;
|
||||
renderShoppingListsWorkspace();
|
||||
});
|
||||
elements.btnShoppingClearChecked.addEventListener('click', () => handleClearShoppingList(true));
|
||||
elements.btnShoppingClearAll.addEventListener('click', () => handleClearShoppingList(false));
|
||||
elements.detailShoppingListSelect.addEventListener('change', handleDetailListSelectChange);
|
||||
elements.btnAddRecipeToList.addEventListener('click', handleAddRecipeToShoppingList);
|
||||
elements.settingsDefaultConsolidate.addEventListener('change', handleDefaultConsolidatePreferenceChange);
|
||||
|
||||
// Admin Action Buttons & Forms
|
||||
elements.btnAdminAddUser.addEventListener('click', () => openModal('admin-create-user-modal'));
|
||||
elements.adminCreateUserForm.addEventListener('submit', handleAdminCreateUser);
|
||||
|
|
@ -244,6 +301,9 @@ function registerEvents() {
|
|||
// Mobile UI Events
|
||||
elements.mobileTabCookbook.addEventListener('click', () => switchTab('my-cookbook'));
|
||||
elements.mobileTabCommunity.addEventListener('click', () => switchTab('community-vault'));
|
||||
if (elements.mobileTabShopping) {
|
||||
elements.mobileTabShopping.addEventListener('click', () => switchTab('shopping-lists'));
|
||||
}
|
||||
elements.mobileTabProfile.addEventListener('click', openMobileProfile);
|
||||
elements.headerLogo.addEventListener('click', () => switchTab('my-cookbook'));
|
||||
elements.fabAddRecipe.addEventListener('click', handleFabClick);
|
||||
|
|
@ -1055,6 +1115,7 @@ function showRecipeDetails(recipe) {
|
|||
|
||||
// Populate Ingredients with scaler
|
||||
renderScaledIngredients();
|
||||
populateDetailShoppingListSelect();
|
||||
|
||||
// Populate Directions Steps
|
||||
elements.recipeDetailInstructions.innerHTML = '';
|
||||
|
|
@ -1220,6 +1281,7 @@ function initAuth() {
|
|||
|
||||
if (token && userJson) {
|
||||
currentUser = JSON.parse(userJson);
|
||||
applyUserDefaultConsolidationPreference(currentUser.defaultConsolidateLists);
|
||||
elements.authModal.classList.remove('active');
|
||||
elements.authModal.style.display = 'none';
|
||||
elements.userBadgeContainer.style.display = 'flex';
|
||||
|
|
@ -1234,6 +1296,10 @@ function initAuth() {
|
|||
elements.mobileBtnAdmin.style.display = 'none';
|
||||
}
|
||||
|
||||
// Preload shopping lists and user preferences in background
|
||||
fetchShoppingLists();
|
||||
syncUserPreferencesFromServer();
|
||||
|
||||
// Reset view to My Cookbook on init
|
||||
switchTab('my-cookbook');
|
||||
} else {
|
||||
|
|
@ -1267,6 +1333,7 @@ async function handleLogin(e) {
|
|||
localStorage.setItem('gourmet_token', data.token);
|
||||
localStorage.setItem('gourmet_user', JSON.stringify(data.user));
|
||||
currentUser = data.user;
|
||||
applyUserDefaultConsolidationPreference(currentUser.defaultConsolidateLists);
|
||||
|
||||
elements.authModal.classList.remove('active');
|
||||
elements.authModal.style.display = 'none';
|
||||
|
|
@ -1283,6 +1350,7 @@ async function handleLogin(e) {
|
|||
}
|
||||
|
||||
elements.loginForm.reset();
|
||||
fetchShoppingLists();
|
||||
|
||||
// Default back to My Cookbook on fresh login
|
||||
switchTab('my-cookbook');
|
||||
|
|
@ -1301,14 +1369,19 @@ function logout() {
|
|||
elements.userBadgeContainer.style.display = 'none';
|
||||
elements.mobileBtnAdmin.style.display = 'none';
|
||||
allRecipes = [];
|
||||
shoppingLists = [];
|
||||
activeShoppingListId = null;
|
||||
updateShoppingTabBadges();
|
||||
|
||||
// Reset tabs visual state
|
||||
elements.tabMyCookbook.classList.add('active');
|
||||
elements.tabCommunityVault.classList.remove('active');
|
||||
if (elements.tabShoppingLists) elements.tabShoppingLists.classList.remove('active');
|
||||
elements.tabUserAdmin.classList.remove('btn-primary');
|
||||
elements.tabUserAdmin.classList.add('btn-outline');
|
||||
elements.tabUserAdmin.classList.remove('active');
|
||||
elements.userAdminPanel.style.display = 'none';
|
||||
if (elements.shoppingListsPanel) elements.shoppingListsPanel.style.display = 'none';
|
||||
elements.recipesGrid.style.display = 'grid';
|
||||
|
||||
const filters = elements.librarySearch.closest('.filter-controls');
|
||||
|
|
@ -1320,6 +1393,7 @@ function logout() {
|
|||
function switchTab(tabName) {
|
||||
elements.tabMyCookbook.classList.remove('active');
|
||||
elements.tabCommunityVault.classList.remove('active');
|
||||
if (elements.tabShoppingLists) elements.tabShoppingLists.classList.remove('active');
|
||||
elements.tabUserAdmin.classList.remove('active');
|
||||
elements.tabUserAdmin.classList.remove('btn-primary');
|
||||
elements.tabUserAdmin.classList.add('btn-outline');
|
||||
|
|
@ -1327,11 +1401,13 @@ function switchTab(tabName) {
|
|||
// Mobile Tab Bar sync
|
||||
elements.mobileTabCookbook.classList.remove('active');
|
||||
elements.mobileTabCommunity.classList.remove('active');
|
||||
if (elements.mobileTabShopping) elements.mobileTabShopping.classList.remove('active');
|
||||
elements.mobileTabProfile.classList.remove('active');
|
||||
|
||||
elements.recipesGrid.style.display = 'none';
|
||||
elements.emptyState.style.display = 'none';
|
||||
elements.userAdminPanel.style.display = 'none';
|
||||
if (elements.shoppingListsPanel) elements.shoppingListsPanel.style.display = 'none';
|
||||
|
||||
const filters = elements.librarySearch.closest('.filter-controls');
|
||||
if (filters) filters.style.display = 'flex';
|
||||
|
|
@ -1342,6 +1418,8 @@ function switchTab(tabName) {
|
|||
elements.libraryViewTitle.textContent = 'My Cookbook';
|
||||
} else if (tabName === 'community-vault') {
|
||||
elements.libraryViewTitle.textContent = 'Shared Cookbook';
|
||||
} else if (tabName === 'shopping-lists') {
|
||||
elements.libraryViewTitle.textContent = 'Shopping Lists';
|
||||
} else if (tabName === 'user-admin') {
|
||||
elements.libraryViewTitle.textContent = 'User Administration';
|
||||
}
|
||||
|
|
@ -1359,6 +1437,13 @@ function switchTab(tabName) {
|
|||
elements.recipesGrid.style.display = 'grid';
|
||||
activeTab = 'community-vault';
|
||||
fetchRecipes();
|
||||
} else if (tabName === 'shopping-lists') {
|
||||
if (elements.tabShoppingLists) elements.tabShoppingLists.classList.add('active');
|
||||
if (elements.mobileTabShopping) elements.mobileTabShopping.classList.add('active');
|
||||
if (elements.shoppingListsPanel) elements.shoppingListsPanel.style.display = 'flex';
|
||||
if (filters) filters.style.display = 'none';
|
||||
activeTab = 'shopping-lists';
|
||||
fetchShoppingLists();
|
||||
} else if (tabName === 'user-admin') {
|
||||
elements.tabUserAdmin.classList.add('btn-primary');
|
||||
elements.tabUserAdmin.classList.remove('btn-outline');
|
||||
|
|
@ -1758,6 +1843,10 @@ async function openSettingsModal() {
|
|||
if (key && !isPlaceholder) {
|
||||
elements.settingsGeminiKeyInput.value = key;
|
||||
}
|
||||
if (elements.settingsDefaultConsolidate && typeof settings.defaultConsolidateLists === 'boolean') {
|
||||
elements.settingsDefaultConsolidate.checked = settings.defaultConsolidateLists;
|
||||
applyUserDefaultConsolidationPreference(settings.defaultConsolidateLists);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading settings:', err);
|
||||
elements.settingsGeminiKeyInput.placeholder = 'Failed to load settings';
|
||||
|
|
@ -1769,6 +1858,7 @@ async function handleSaveSettings(e) {
|
|||
elements.settingsStatusMsg.style.display = 'none';
|
||||
|
||||
const key = elements.settingsGeminiKeyInput.value.trim();
|
||||
const defaultConsolidate = elements.settingsDefaultConsolidate ? elements.settingsDefaultConsolidate.checked : true;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/settings', {
|
||||
|
|
@ -1777,7 +1867,10 @@ async function handleSaveSettings(e) {
|
|||
'Content-Type': 'application/json',
|
||||
...getAuthHeaders()
|
||||
},
|
||||
body: JSON.stringify({ geminiApiKey: key })
|
||||
body: JSON.stringify({
|
||||
geminiApiKey: key,
|
||||
defaultConsolidateLists: defaultConsolidate
|
||||
})
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
|
|
@ -1788,6 +1881,11 @@ async function handleSaveSettings(e) {
|
|||
|
||||
if (!response.ok) throw new Error('Failed to save settings file.');
|
||||
|
||||
applyUserDefaultConsolidationPreference(defaultConsolidate);
|
||||
if (elements.settingsStatusText) {
|
||||
elements.settingsStatusText.textContent = 'Settings saved successfully.';
|
||||
}
|
||||
|
||||
// Show success status banner
|
||||
elements.settingsStatusMsg.style.display = 'flex';
|
||||
setTimeout(() => {
|
||||
|
|
@ -2073,3 +2171,752 @@ function handleFabClick() {
|
|||
openManualAdd();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// SHOPPING LISTS MANAGEMENT & CONSOLIDATION ENGINE
|
||||
// -------------------------------------------------------------
|
||||
|
||||
const AISLE_ORDER = [
|
||||
'Produce',
|
||||
'Meat & Seafood',
|
||||
'Dairy & Eggs',
|
||||
'Canned & Jarred',
|
||||
'Bakery & Grains',
|
||||
'Pantry & Spices',
|
||||
'Frozen',
|
||||
'Other'
|
||||
];
|
||||
|
||||
const AISLE_ICONS = {
|
||||
'Produce': 'apple',
|
||||
'Meat & Seafood': 'beef',
|
||||
'Dairy & Eggs': 'milk',
|
||||
'Canned & Jarred': 'Archive',
|
||||
'Bakery & Grains': 'wheat',
|
||||
'Pantry & Spices': 'flame',
|
||||
'Frozen': 'snowflake',
|
||||
'Other': 'shopping-bag'
|
||||
};
|
||||
|
||||
function applyUserDefaultConsolidationPreference(prefValue) {
|
||||
const shouldConsolidate = prefValue !== false;
|
||||
consolidateItems = shouldConsolidate;
|
||||
if (elements.toggleConsolidateItems) {
|
||||
elements.toggleConsolidateItems.checked = shouldConsolidate;
|
||||
}
|
||||
if (elements.settingsDefaultConsolidate) {
|
||||
elements.settingsDefaultConsolidate.checked = shouldConsolidate;
|
||||
}
|
||||
if (currentUser) {
|
||||
currentUser.defaultConsolidateLists = shouldConsolidate;
|
||||
localStorage.setItem('gourmet_user', JSON.stringify(currentUser));
|
||||
}
|
||||
}
|
||||
|
||||
async function syncUserPreferencesFromServer() {
|
||||
try {
|
||||
const response = await fetch('/api/settings', { headers: getAuthHeaders() });
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (typeof data.defaultConsolidateLists === 'boolean') {
|
||||
applyUserDefaultConsolidationPreference(data.defaultConsolidateLists);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to sync user preferences:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDefaultConsolidatePreferenceChange(e) {
|
||||
const newPref = e.target.checked;
|
||||
try {
|
||||
const response = await fetch('/api/user/preferences', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...getAuthHeaders()
|
||||
},
|
||||
body: JSON.stringify({ defaultConsolidateLists: newPref })
|
||||
});
|
||||
if (response.status === 401) {
|
||||
logout();
|
||||
return;
|
||||
}
|
||||
if (!response.ok) throw new Error('Failed to save default consolidation preference.');
|
||||
applyUserDefaultConsolidationPreference(newPref);
|
||||
renderShoppingListsWorkspace();
|
||||
if (elements.settingsStatusText && elements.settingsStatusMsg) {
|
||||
elements.settingsStatusText.textContent = 'Default consolidation preference updated.';
|
||||
elements.settingsStatusMsg.style.display = 'flex';
|
||||
setTimeout(() => {
|
||||
elements.settingsStatusMsg.style.display = 'none';
|
||||
}, 3000);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error updating consolidation preference:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchShoppingLists() {
|
||||
if (!currentUser) return;
|
||||
try {
|
||||
const response = await fetch('/api/shopping-lists', {
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
if (response.status === 401) {
|
||||
logout();
|
||||
return;
|
||||
}
|
||||
if (!response.ok) throw new Error('Failed to load shopping lists.');
|
||||
shoppingLists = await response.json();
|
||||
|
||||
if (!activeShoppingListId || !shoppingLists.some(l => l.id === activeShoppingListId)) {
|
||||
activeShoppingListId = shoppingLists.length > 0 ? shoppingLists[0].id : null;
|
||||
}
|
||||
|
||||
renderShoppingListsWorkspace();
|
||||
populateDetailShoppingListSelect();
|
||||
} catch (error) {
|
||||
console.error('Error loading shopping lists:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function getActiveShoppingList() {
|
||||
return shoppingLists.find(l => l.id === activeShoppingListId) || shoppingLists[0] || null;
|
||||
}
|
||||
|
||||
function updateShoppingListInState(updatedList) {
|
||||
const idx = shoppingLists.findIndex(l => l.id === updatedList.id);
|
||||
if (idx !== -1) {
|
||||
shoppingLists[idx] = updatedList;
|
||||
} else {
|
||||
shoppingLists.push(updatedList);
|
||||
}
|
||||
activeShoppingListId = updatedList.id;
|
||||
renderShoppingListsWorkspace();
|
||||
populateDetailShoppingListSelect();
|
||||
}
|
||||
|
||||
function updateShoppingTabBadges() {
|
||||
const activeList = getActiveShoppingList();
|
||||
const uncheckedCount = activeList && Array.isArray(activeList.items)
|
||||
? activeList.items.filter(i => !i.checked).length
|
||||
: 0;
|
||||
|
||||
if (elements.shoppingTabBadge) {
|
||||
if (uncheckedCount > 0) {
|
||||
elements.shoppingTabBadge.textContent = uncheckedCount;
|
||||
elements.shoppingTabBadge.style.display = 'inline-block';
|
||||
} else {
|
||||
elements.shoppingTabBadge.style.display = 'none';
|
||||
}
|
||||
}
|
||||
if (elements.mobileShoppingTabBadge) {
|
||||
if (uncheckedCount > 0) {
|
||||
elements.mobileShoppingTabBadge.textContent = uncheckedCount;
|
||||
elements.mobileShoppingTabBadge.style.display = 'flex';
|
||||
} else {
|
||||
elements.mobileShoppingTabBadge.style.display = 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function populateDetailShoppingListSelect() {
|
||||
if (!elements.detailShoppingListSelect) return;
|
||||
elements.detailShoppingListSelect.innerHTML = '';
|
||||
|
||||
shoppingLists.forEach(list => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = list.id;
|
||||
opt.textContent = list.name;
|
||||
if (list.id === activeShoppingListId) opt.selected = true;
|
||||
elements.detailShoppingListSelect.appendChild(opt);
|
||||
});
|
||||
|
||||
const newOpt = document.createElement('option');
|
||||
newOpt.value = '__new__';
|
||||
newOpt.textContent = '+ Create New List...';
|
||||
elements.detailShoppingListSelect.appendChild(newOpt);
|
||||
}
|
||||
|
||||
async function handleDetailListSelectChange(e) {
|
||||
if (e.target.value === '__new__') {
|
||||
const name = prompt('Enter a name for the new shopping list:', 'Weekend Groceries');
|
||||
if (name && name.trim()) {
|
||||
try {
|
||||
const res = await fetch('/api/shopping-lists', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
|
||||
body: JSON.stringify({ name: name.trim() })
|
||||
});
|
||||
if (res.ok) {
|
||||
const created = await res.json();
|
||||
updateShoppingListInState(created);
|
||||
elements.detailShoppingListSelect.value = created.id;
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error creating list from detail modal:', err);
|
||||
}
|
||||
}
|
||||
populateDetailShoppingListSelect();
|
||||
} else {
|
||||
activeShoppingListId = e.target.value;
|
||||
renderShoppingListsWorkspace();
|
||||
}
|
||||
}
|
||||
|
||||
let addRecipeBtnTimeout = null;
|
||||
|
||||
async function handleAddRecipeToShoppingList() {
|
||||
if (!activeRecipe) return;
|
||||
let targetListId = elements.detailShoppingListSelect ? elements.detailShoppingListSelect.value : activeShoppingListId;
|
||||
if (!targetListId || targetListId === '__new__') {
|
||||
const activeList = getActiveShoppingList();
|
||||
if (!activeList) return;
|
||||
targetListId = activeList.id;
|
||||
}
|
||||
|
||||
// Gather unchecked ingredients from the Recipe Detail checklist (or all if all are checked)
|
||||
const listElements = Array.from(elements.recipeDetailIngredients.querySelectorAll('.ingredient-checkbox-item'));
|
||||
let selectedLines = listElements
|
||||
.filter(li => !li.classList.contains('checked'))
|
||||
.map(li => li.querySelector('.ingredient-text')?.textContent?.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (selectedLines.length === 0 && listElements.length > 0) {
|
||||
selectedLines = listElements
|
||||
.map(li => li.querySelector('.ingredient-text')?.textContent?.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
if (selectedLines.length === 0) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/shopping-lists/${targetListId}/items`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...getAuthHeaders()
|
||||
},
|
||||
body: JSON.stringify({
|
||||
items: selectedLines,
|
||||
recipeId: activeRecipe.id,
|
||||
recipeTitle: activeRecipe.title
|
||||
})
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
logout();
|
||||
return;
|
||||
}
|
||||
if (!response.ok) throw new Error('Failed to add ingredients to shopping list.');
|
||||
|
||||
const updatedList = await response.json();
|
||||
updateShoppingListInState(updatedList);
|
||||
|
||||
if (elements.btnAddRecipeToList) {
|
||||
if (addRecipeBtnTimeout) clearTimeout(addRecipeBtnTimeout);
|
||||
elements.btnAddRecipeToList.innerHTML = `<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();
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -47,6 +47,10 @@
|
|||
<button class="mobile-header-tab" id="mobile-header-tab-community" title="Shared Cookbook">
|
||||
<i data-lucide="globe"></i>
|
||||
</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">
|
||||
<i data-lucide="plus"></i>
|
||||
</button>
|
||||
|
|
@ -93,6 +97,10 @@
|
|||
<button class="dashboard-tab-btn" id="tab-community-vault">
|
||||
<i data-lucide="globe"></i> <span>Shared Cookbook</span>
|
||||
</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>
|
||||
|
||||
<!-- 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>
|
||||
</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 -->
|
||||
<div id="user-admin-panel" style="display: none;" class="admin-panel-container">
|
||||
<!-- Admin Console Sub-Tabs -->
|
||||
|
|
@ -331,8 +419,18 @@
|
|||
<div class="detail-split-layout">
|
||||
<!-- Left: Ingredients Checklist -->
|
||||
<div class="detail-ingredients-column">
|
||||
<div class="detail-ingredients-header">
|
||||
<div>
|
||||
<h3>Ingredients</h3>
|
||||
<p class="section-subtext">Check off items as you gather them</p>
|
||||
<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">
|
||||
<!-- Checkbox elements populated via JS -->
|
||||
</ul>
|
||||
|
|
@ -571,7 +669,24 @@
|
|||
</form>
|
||||
</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-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;">
|
||||
|
|
|
|||
140
public/share-list.html
Normal file
140
public/share-list.html
Normal 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
316
public/share-list.js
Normal 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);
|
||||
}
|
||||
}
|
||||
413
public/style.css
413
public/style.css
|
|
@ -2414,3 +2414,416 @@ h1, h2, h3, h4, h5, h6 {
|
|||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
688
server.js
688
server.js
|
|
@ -37,10 +37,21 @@ db.prepare(`
|
|||
passwordHash TEXT NOT NULL,
|
||||
isAdmin INTEGER NOT NULL DEFAULT 0,
|
||||
isRecipeManager INTEGER NOT NULL DEFAULT 0,
|
||||
defaultConsolidateLists INTEGER NOT NULL DEFAULT 1,
|
||||
createdAt TEXT NOT NULL
|
||||
)
|
||||
`).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(`
|
||||
CREATE TABLE IF NOT EXISTS recipes (
|
||||
id TEXT PRIMARY KEY,
|
||||
|
|
@ -80,6 +91,45 @@ db.prepare(`
|
|||
)
|
||||
`).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
|
||||
const settingsCount = db.prepare('SELECT COUNT(*) as count FROM settings').get().count;
|
||||
if (settingsCount === 0) {
|
||||
|
|
@ -398,7 +448,8 @@ app.post('/api/auth/register', requireAuth, (req, res) => {
|
|||
id,
|
||||
username: cleanUsername,
|
||||
isAdmin: false,
|
||||
isRecipeManager: false
|
||||
isRecipeManager: false,
|
||||
defaultConsolidateLists: true
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
|
|
@ -427,11 +478,13 @@ app.post('/api/auth/login', (req, res) => {
|
|||
}
|
||||
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
const defaultConsolidateLists = user.defaultConsolidateLists !== 0;
|
||||
activeSessions.set(token, {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
isAdmin: user.isAdmin === 1,
|
||||
isRecipeManager: user.isRecipeManager === 1
|
||||
isRecipeManager: user.isRecipeManager === 1,
|
||||
defaultConsolidateLists
|
||||
});
|
||||
|
||||
res.json({
|
||||
|
|
@ -440,7 +493,8 @@ app.post('/api/auth/login', (req, res) => {
|
|||
id: user.id,
|
||||
username: user.username,
|
||||
isAdmin: user.isAdmin === 1,
|
||||
isRecipeManager: user.isRecipeManager === 1
|
||||
isRecipeManager: user.isRecipeManager === 1,
|
||||
defaultConsolidateLists
|
||||
}
|
||||
});
|
||||
} 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) => {
|
||||
try {
|
||||
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) {
|
||||
console.error('Failed to load settings:', e);
|
||||
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) => {
|
||||
const { geminiApiKey } = req.body;
|
||||
const { geminiApiKey, defaultConsolidateLists } = req.body;
|
||||
try {
|
||||
if (geminiApiKey !== undefined) {
|
||||
db.prepare('UPDATE settings SET geminiApiKey = ?').run(geminiApiKey || '');
|
||||
res.json({ geminiApiKey: 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) {
|
||||
console.error('Failed to save settings:', e);
|
||||
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
|
||||
app.get('/api/share/recipes/:shareToken', publicShareLimiter, (req, res) => {
|
||||
const shareToken = req.params.shareToken;
|
||||
|
|
|
|||
Loading…
Reference in a new issue