recipe-manager/public/app.js

3452 lines
127 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ForkFolio client application state
let allRecipes = [];
let activeRecipe = null;
let currentMultiplier = 1;
let selectedCoverImage = '';
let stopwatchInterval = null;
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 = {
themeToggle: document.getElementById('theme-toggle'),
urlScrapeForm: document.getElementById('url-scrape-form'),
scrapeUrlInput: document.getElementById('scrape-url-input'),
btnScrapeSubmit: document.getElementById('btn-scrape-submit'),
btnManualAdd: document.getElementById('btn-manual-add'),
btnSettings: document.getElementById('btn-settings'),
settingsModal: document.getElementById('settings-modal'),
settingsApiForm: document.getElementById('settings-api-form'),
settingsGeminiKeyInput: document.getElementById('settings-gemini-key'),
btnToggleGeminiKey: document.getElementById('btn-toggle-gemini-key'),
settingsStatusMsg: document.getElementById('settings-status-msg'),
settingsStatusText: document.getElementById('settings-status-text'),
scanImageInput: document.getElementById('scan-image-input'),
btnScanImage: document.getElementById('btn-scan-image'),
recipesGrid: document.getElementById('recipes-grid'),
emptyState: document.getElementById('empty-state'),
librarySearch: document.getElementById('library-search'),
// Auth caching
authModal: document.getElementById('auth-modal'),
loginForm: document.getElementById('login-form'),
authErrorMsg: document.getElementById('auth-error-msg'),
authErrorText: document.getElementById('auth-error-text'),
loginUsernameInput: document.getElementById('login-username'),
loginPasswordInput: document.getElementById('login-password'),
userBadgeContainer: document.getElementById('user-badge-container'),
userDisplayName: document.getElementById('user-display-name'),
btnLogout: document.getElementById('btn-logout'),
tabMyCookbook: document.getElementById('tab-my-cookbook'),
tabCommunityVault: document.getElementById('tab-community-vault'),
tabUserAdmin: document.getElementById('tab-user-admin'),
userAdminPanel: document.getElementById('user-admin-panel'),
adminUsersList: document.getElementById('admin-users-list'),
btnAdminAddUser: document.getElementById('btn-admin-add-user'),
btnAdminTabUsers: document.getElementById('btn-admin-tab-users'),
btnAdminTabShares: document.getElementById('btn-admin-tab-shares'),
btnAdminTabBackups: document.getElementById('btn-admin-tab-backups'),
adminUsersSection: document.getElementById('admin-users-section'),
adminSharesSection: document.getElementById('admin-shares-section'),
adminBackupsSection: document.getElementById('admin-backups-section'),
adminSharesList: document.getElementById('admin-shares-list'),
// Admin Backups Console Elements
btnBackupRunCloud: document.getElementById('btn-backup-run-cloud'),
btnBackupDownloadDb: document.getElementById('btn-backup-download-db'),
btnBackupDownloadJson: document.getElementById('btn-backup-download-json'),
backupRestoreFileInput: document.getElementById('backup-restore-file-input'),
btnBackupRestoreLocal: document.getElementById('btn-backup-restore-local'),
backupStatusBanner: document.getElementById('backup-status-banner'),
adminBackupConfigForm: document.getElementById('admin-backup-config-form'),
backupLastRunBadge: document.getElementById('backup-last-run-badge'),
backupScheduleFrequency: document.getElementById('backup-schedule-frequency'),
backupScheduleTime: document.getElementById('backup-schedule-time'),
backupScheduleDay: document.getElementById('backup-schedule-day'),
backupRetentionCount: document.getElementById('backup-retention-count'),
backupFormat: document.getElementById('backup-format'),
backupEmbedImages: document.getElementById('backup-embed-images'),
backupS3Enabled: document.getElementById('backup-s3-enabled'),
backupS3Endpoint: document.getElementById('backup-s3-endpoint'),
backupS3Region: document.getElementById('backup-s3-region'),
backupS3Bucket: document.getElementById('backup-s3-bucket'),
backupS3Prefix: document.getElementById('backup-s3-prefix'),
backupS3AccessKey: document.getElementById('backup-s3-access-key'),
backupS3SecretKey: document.getElementById('backup-s3-secret-key'),
btnBackupTestS3: document.getElementById('btn-backup-test-s3'),
backupGdriveEnabled: document.getElementById('backup-gdrive-enabled'),
backupGdriveClientId: document.getElementById('backup-gdrive-client-id'),
backupGdriveClientSecret: document.getElementById('backup-gdrive-client-secret'),
backupGdriveRefreshToken: document.getElementById('backup-gdrive-refresh-token'),
backupGdriveFolderId: document.getElementById('backup-gdrive-folder-id'),
btnBackupAuthorizeGdrive: document.getElementById('btn-backup-authorize-gdrive'),
btnBackupTestGdrive: document.getElementById('btn-backup-test-gdrive'),
adminBackupsHistoryList: document.getElementById('admin-backups-history-list'),
// Admin Create User Modal
adminCreateUserModal: document.getElementById('admin-create-user-modal'),
adminCreateUserForm: document.getElementById('admin-create-user-form'),
adminNewUsername: document.getElementById('admin-new-username'),
adminNewPassword: document.getElementById('admin-new-password'),
adminCreateError: document.getElementById('admin-create-error'),
adminCreateErrorText: document.getElementById('admin-create-error-text'),
// Admin Delete User Modal
adminDeleteUserModal: document.getElementById('admin-delete-user-modal'),
deleteUserTargetId: document.getElementById('delete-user-target-id'),
deleteUserTargetName: document.getElementById('delete-user-target-name'),
deleteUserPreserveRecipes: document.getElementById('delete-user-preserve-recipes'),
btnAdminConfirmDelete: document.getElementById('btn-admin-confirm-delete'),
// Scrape / Edit Modal
saveModal: document.getElementById('save-modal'),
recipeEditForm: document.getElementById('recipe-edit-form'),
editRecipeId: document.getElementById('edit-recipe-id'),
editTitle: document.getElementById('edit-title'),
editSource: document.getElementById('edit-source'),
editDescription: document.getElementById('edit-description'),
editServings: document.getElementById('edit-servings'),
editPrepTime: document.getElementById('edit-prep-time'),
editCookTime: document.getElementById('edit-cook-time'),
imageSelectionGrid: document.getElementById('image-selection-grid'),
editImageUrl: document.getElementById('edit-image-url'),
editIngredients: document.getElementById('edit-ingredients'),
editInstructions: document.getElementById('edit-instructions'),
modalActionTitle: document.getElementById('modal-action-title'),
btnSaveConfirm: document.getElementById('btn-save-confirm'),
editIsPublic: document.getElementById('edit-is-public'),
// Detail Modal
detailModal: document.getElementById('detail-modal'),
recipeDetailBanner: document.getElementById('recipe-detail-banner'),
btnDetailEdit: document.getElementById('btn-detail-edit'),
btnDetailDelete: document.getElementById('btn-detail-delete'),
btnDetailClone: document.getElementById('btn-detail-clone'),
btnDetailShare: document.getElementById('btn-detail-share'),
btnDetailRevokeShare: document.getElementById('btn-detail-revoke-share'),
tagShareStats: document.getElementById('tag-share-stats'),
recipeDetailCreator: document.getElementById('recipe-detail-creator'),
recipeDetailCreatorName: document.getElementById('recipe-detail-creator-name'),
recipeDetailTitle: document.getElementById('recipe-detail-title'),
tagServings: document.getElementById('tag-servings'),
tagPrep: document.getElementById('tag-prep'),
tagCook: document.getElementById('tag-cook'),
tagSource: document.getElementById('tag-source'),
recipeDetailDescription: document.getElementById('recipe-detail-description'),
recipeDetailIngredients: document.getElementById('recipe-detail-ingredients'),
recipeDetailInstructions: document.getElementById('recipe-detail-instructions'),
scalerMinus: document.getElementById('scaler-minus'),
scalerPlus: document.getElementById('scaler-plus'),
currentServingsVal: document.getElementById('current-servings-val'),
btnTriggerCookmode: document.getElementById('btn-trigger-cookmode'),
// Cooking Mode Modal
cookingModeModal: document.getElementById('cooking-mode-modal'),
cookRecipeTitle: document.getElementById('cook-recipe-title'),
cookTimerVal: document.getElementById('cook-timer-val'),
cookTimerPlay: document.getElementById('cook-timer-play'),
cookTimerReset: document.getElementById('cook-timer-reset'),
cookProgressBar: document.getElementById('cook-progress-bar'),
cookStepIndex: document.getElementById('cook-step-index'),
cookStepInstruction: document.getElementById('cook-step-instruction'),
cookBtnPrev: document.getElementById('cook-btn-prev'),
cookBtnNext: document.getElementById('cook-btn-next'),
cookStepDots: document.getElementById('cook-step-dots'),
// Loader
loadingOverlay: document.getElementById('loading-overlay'),
loaderStepFetch: document.getElementById('loader-step-fetch'),
loaderStepParse: document.getElementById('loader-step-parse'),
loaderStepImages: document.getElementById('loader-step-images'),
// Mobile Components
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'),
mobileProfileRole: document.getElementById('mobile-profile-role'),
mobileBtnThemeToggle: document.getElementById('mobile-btn-theme-toggle'),
mobileBtnSettings: document.getElementById('mobile-btn-settings'),
mobileBtnAdmin: document.getElementById('mobile-btn-admin'),
mobileBtnLogout: document.getElementById('mobile-btn-logout'),
headerLogo: document.getElementById('header-logo'),
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')
};
// -------------------------------------------------------------
// LIFE CYCLE AND REGISTRATION
// -------------------------------------------------------------
window.addEventListener('DOMContentLoaded', () => {
initTheme();
registerEvents();
initAuth();
});
function registerEvents() {
// Theme Toggle
elements.themeToggle.addEventListener('click', toggleTheme);
// URL Form Submission
elements.urlScrapeForm.addEventListener('submit', handleScrapeSubmit);
// Manual Add Form Opening
elements.btnManualAdd.addEventListener('click', openManualAdd);
// Settings Modal Opening
elements.btnSettings.addEventListener('click', openSettingsModal);
elements.settingsApiForm.addEventListener('submit', handleSaveSettings);
elements.btnToggleGeminiKey.addEventListener('click', toggleSettingsKeyVisibility);
// Image Scanning Actions
elements.btnScanImage.addEventListener('click', () => elements.scanImageInput.click());
elements.scanImageInput.addEventListener('change', handleRecipeImageScan);
document.addEventListener('paste', handleClipboardPaste);
// Library Search
elements.librarySearch.addEventListener('input', filterRecipes);
// Recipe Form Save
elements.recipeEditForm.addEventListener('submit', handleRecipeSave);
// Action Buttons Detail Modal
elements.btnDetailEdit.addEventListener('click', editActiveRecipe);
elements.btnDetailDelete.addEventListener('click', deleteActiveRecipe);
elements.btnDetailClone.addEventListener('click', cloneActiveRecipe);
elements.btnDetailShare.addEventListener('click', shareActiveRecipe);
elements.btnDetailRevokeShare.addEventListener('click', revokeActiveRecipeShare);
// Auth Form Submissions
elements.loginForm.addEventListener('submit', handleLogin);
elements.btnLogout.addEventListener('click', logout);
// 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);
elements.btnAdminConfirmDelete.addEventListener('click', handleAdminDeleteUser);
// Admin Sub-Tab Switchers
elements.btnAdminTabUsers.addEventListener('click', () => switchAdminSubTab('users'));
elements.btnAdminTabShares.addEventListener('click', () => switchAdminSubTab('shares'));
if (elements.btnAdminTabBackups) {
elements.btnAdminTabBackups.addEventListener('click', () => switchAdminSubTab('backups'));
}
// Admin Backups Console Actions
if (elements.adminBackupConfigForm) {
elements.adminBackupConfigForm.addEventListener('submit', handleSaveBackupConfig);
elements.btnBackupRunCloud.addEventListener('click', handleRunCloudBackupNow);
elements.btnBackupDownloadDb.addEventListener('click', () => handleDownloadLocalBackup('db'));
elements.btnBackupDownloadJson.addEventListener('click', () => handleDownloadLocalBackup('json'));
elements.btnBackupRestoreLocal.addEventListener('click', () => elements.backupRestoreFileInput.click());
elements.backupRestoreFileInput.addEventListener('change', handleRestoreLocalFile);
elements.btnBackupTestS3.addEventListener('click', handleTestS3Connection);
elements.btnBackupTestGdrive.addEventListener('click', handleTestGdriveConnection);
elements.btnBackupAuthorizeGdrive.addEventListener('click', handleAuthorizeGoogleDrive);
}
window.addEventListener('message', (event) => {
if (event.data && event.data.type === 'GDRIVE_OAUTH_SUCCESS') {
showBackupStatus('Google Drive account authorized and Refresh Token saved!', 'success');
fetchAdminBackupConfig();
}
});
// Servings Scaler Buttons
elements.scalerMinus.addEventListener('click', () => adjustMultiplier(-0.5));
elements.scalerPlus.addEventListener('click', () => adjustMultiplier(0.5));
// Cooking Mode Trigger
elements.btnTriggerCookmode.addEventListener('click', enterCookingMode);
// Cooking Controls
elements.cookBtnPrev.addEventListener('click', prevCookingStep);
elements.cookBtnNext.addEventListener('click', nextCookingStep);
elements.cookTimerPlay.addEventListener('click', toggleTimer);
elements.cookTimerReset.addEventListener('click', resetTimer);
// Global Modals click to dismiss on overlay
document.querySelectorAll('.modal-overlay').forEach(overlay => {
overlay.addEventListener('click', (e) => {
if (e.target === overlay && !overlay.classList.contains('loading-overlay')) {
closeModal(overlay.id);
}
});
});
// Global key bindings for cooking mode
document.addEventListener('keydown', (e) => {
if (elements.cookingModeModal.classList.contains('active')) {
if (e.key === 'ArrowRight' || e.key === ' ') {
e.preventDefault();
nextCookingStep();
} else if (e.key === 'ArrowLeft') {
e.preventDefault();
prevCookingStep();
} else if (e.key === 'Escape') {
exitCookingMode();
}
}
});
// 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);
elements.mobileBtnThemeToggle.addEventListener('click', toggleTheme);
elements.mobileBtnSettings.addEventListener('click', () => {
closeModal('mobile-profile-modal');
openSettingsModal();
});
elements.mobileBtnAdmin.addEventListener('click', () => {
closeModal('mobile-profile-modal');
switchTab('user-admin');
});
elements.mobileBtnLogout.addEventListener('click', () => {
closeModal('mobile-profile-modal');
logout();
});
}
// -------------------------------------------------------------
// UTILITIES: SYSTEM THEMES
// -------------------------------------------------------------
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');
}
// -------------------------------------------------------------
// LOADING STATUS MACHINE
// -------------------------------------------------------------
function setLoaderStepIcon(stepElement, iconName, spin = false) {
const wrapper = stepElement.querySelector('.step-icon-wrapper');
if (wrapper) {
wrapper.innerHTML = `<i data-lucide="${iconName}" class="${spin ? 'spinning-icon' : ''}"></i>`;
}
}
function showScrapeLoading() {
elements.loadingOverlay.classList.add('active');
// Reset Steps to default
elements.loaderStepFetch.className = 'loader-step active';
elements.loaderStepParse.className = 'loader-step';
elements.loaderStepImages.className = 'loader-step';
setLoaderStepIcon(elements.loaderStepFetch, 'circle-dashed', true);
setLoaderStepIcon(elements.loaderStepParse, 'circle', false);
setLoaderStepIcon(elements.loaderStepImages, 'circle', false);
lucide.createIcons();
// Simulate process transitions
setTimeout(() => {
if (!elements.loadingOverlay.classList.contains('active')) return;
elements.loaderStepFetch.className = 'loader-step completed';
setLoaderStepIcon(elements.loaderStepFetch, 'check-circle-2', false);
elements.loaderStepParse.className = 'loader-step active';
setLoaderStepIcon(elements.loaderStepParse, 'circle-dashed', true);
lucide.createIcons();
}, 1800);
setTimeout(() => {
if (!elements.loadingOverlay.classList.contains('active')) return;
elements.loaderStepParse.className = 'loader-step completed';
setLoaderStepIcon(elements.loaderStepParse, 'check-circle-2', false);
elements.loaderStepImages.className = 'loader-step active';
setLoaderStepIcon(elements.loaderStepImages, 'circle-dashed', true);
lucide.createIcons();
}, 3500);
}
function hideScrapeLoading() {
elements.loadingOverlay.classList.remove('active');
}
// -------------------------------------------------------------
// CORE MODAL ROUTER
// -------------------------------------------------------------
function openModal(id) {
document.getElementById(id).classList.add('active');
}
function closeModal(id) {
document.getElementById(id).classList.remove('active');
if (id === 'cooking-mode-modal') {
exitCookingMode();
}
}
// Helper: get authentication headers for fetches
function getAuthHeaders() {
const token = localStorage.getItem('gourmet_token');
return token ? { 'Authorization': `Bearer ${token}` } : {};
}
// -------------------------------------------------------------
// CRUD & API HANDLERS
// -------------------------------------------------------------
async function fetchRecipes() {
try {
let url = '/api/recipes';
if (activeTab === 'community-vault') {
url = '/api/recipes/public';
}
const response = await fetch(url, {
headers: getAuthHeaders()
});
if (response.status === 401) {
logout();
return;
}
if (!response.ok) {
throw new Error('Failed to load recipes');
}
allRecipes = await response.json();
renderLibrary();
} catch (error) {
console.error('Error fetching recipes library:', error);
}
}
// Scrape link form submission
async function handleScrapeSubmit(e) {
e.preventDefault();
const url = elements.scrapeUrlInput.value.trim();
if (!url) return;
showScrapeLoading();
try {
const response = await fetch('/api/scrape', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...getAuthHeaders()
},
body: JSON.stringify({ url })
});
if (response.status === 401) {
logout();
hideScrapeLoading();
return;
}
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
if (response.status === 403 || errorData.isBlocked) {
throw new Error('BLOCKED');
}
throw new Error(errorData.error || 'Extraction error.');
}
const data = await response.json();
hideScrapeLoading();
openDraftPreview(data);
elements.scrapeUrlInput.value = '';
} catch (error) {
hideScrapeLoading();
if (error.message === 'BLOCKED') {
alert(`Access Blocked by Site Protection\n\nThis website uses Cloudflare or bot-defense screens to prevent automated scraping.\n\nDon't worry! You can still add this recipe by clicking the "Manual Recipe" button at the top to quickly paste your ingredients and directions.`);
} else {
alert(`Scraping failed: ${error.message}`);
}
}
}
// Save or Edit submit
async function handleRecipeSave(e) {
e.preventDefault();
const id = elements.editRecipeId.value;
const recipePayload = {
title: elements.editTitle.value.trim(),
sourceUrl: elements.editSource.value.trim(),
description: elements.editDescription.value.trim(),
servings: elements.editServings.value.trim(),
prepTime: elements.editPrepTime.value.trim(),
cookTime: elements.editCookTime.value.trim(),
isPublic: elements.editIsPublic.checked,
image: elements.editImageUrl.value.trim(),
images: Array.from(elements.imageSelectionGrid.querySelectorAll('.image-option img')).map(img => img.src),
ingredients: elements.editIngredients.value.trim().split(/\r?\n/).map(line => line.trim()).filter(Boolean),
instructions: elements.editInstructions.value.trim().split(/\r?\n/).map(line => line.trim()).filter(Boolean)
};
try {
let response;
if (id) {
// Edit
response = await fetch(`/api/recipes/${id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
...getAuthHeaders()
},
body: JSON.stringify(recipePayload)
});
} else {
// Create
response = await fetch('/api/recipes', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...getAuthHeaders()
},
body: JSON.stringify(recipePayload)
});
}
if (response.status === 401) {
logout();
return;
}
if (!response.ok) throw new Error('Saving failed.');
const saved = await response.json();
closeModal('save-modal');
fetchRecipes();
// If active view was open, update it
if (activeRecipe && activeRecipe.id === saved.id) {
showRecipeDetails(saved);
}
} catch (error) {
console.error('Error saving recipe:', error);
alert('Error saving recipe.');
}
}
// Delete Active Recipe
async function deleteActiveRecipe() {
if (!activeRecipe) return;
if (!confirm(`Are you sure you want to delete "${activeRecipe.title}"?`)) return;
try {
const response = await fetch(`/api/recipes/${activeRecipe.id}`, {
method: 'DELETE',
headers: getAuthHeaders()
});
if (response.status === 401) {
logout();
return;
}
if (!response.ok) throw new Error('Deletion failed.');
closeModal('detail-modal');
activeRecipe = null;
fetchRecipes();
} catch (error) {
console.error('Error deleting recipe:', error);
alert('Deletion failed.');
}
}
// Clone a Shared Cookbook recipe to current user's collection
async function cloneActiveRecipe() {
if (!activeRecipe) return;
try {
const response = await fetch(`/api/recipes/clone/${activeRecipe.id}`, {
method: 'POST',
headers: getAuthHeaders()
});
if (response.status === 401) {
logout();
return;
}
if (!response.ok) {
const err = await response.json().catch(() => ({}));
throw new Error(err.error || 'Cloning failed.');
}
const cloned = await response.json();
alert(`Successfully cloned "${activeRecipe.title}" into your cookbook!`);
closeModal('detail-modal');
// Switch back to "My Cookbook" tab to let them see it immediately
switchTab('my-cookbook');
} catch (error) {
console.error('Error cloning recipe:', error);
alert(`Error cloning recipe: ${error.message}`);
}
}
async function copyTextToClipboard(text) {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(text);
return;
}
const textArea = document.createElement('textarea');
textArea.value = text;
textArea.style.position = 'fixed';
textArea.style.opacity = '0';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
document.execCommand('copy');
document.body.removeChild(textArea);
}
let shareBtnResetTimeout = null;
async function shareActiveRecipe() {
if (!activeRecipe) return;
if (activeRecipe.isPublic === false) {
if (confirm(`"${activeRecipe.title}" is currently private.\n\nTo share it with people outside ForkFolio, you must make it public first. Would you like to make this recipe public now?`)) {
try {
const response = await fetch(`/api/recipes/${activeRecipe.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
...getAuthHeaders()
},
body: JSON.stringify({ isPublic: true })
});
if (response.status === 401) {
logout();
return;
}
if (!response.ok) throw new Error('Failed to update privacy.');
const updated = await response.json();
activeRecipe = updated;
fetchRecipes();
// Re-render detail badge visual state
showRecipeDetails(updated);
} catch (err) {
console.error('Error toggling privacy for share link:', err);
alert('Failed to make the recipe public.');
return;
}
} else {
return;
}
}
// If public and has a share token
if (activeRecipe.shareToken) {
const shareLink = `${window.location.origin}/share.html?token=${activeRecipe.shareToken}`;
try {
await copyTextToClipboard(shareLink);
if (elements.btnDetailShare) {
if (shareBtnResetTimeout) clearTimeout(shareBtnResetTimeout);
elements.btnDetailShare.classList.add('copied-feedback');
elements.btnDetailShare.innerHTML = `<i data-lucide="check"></i> <span>Copied!</span>`;
lucide.createIcons();
shareBtnResetTimeout = setTimeout(() => {
elements.btnDetailShare.classList.remove('copied-feedback');
elements.btnDetailShare.innerHTML = `<i data-lucide="share-2"></i>`;
lucide.createIcons();
}, 2000);
}
} catch (clipErr) {
console.warn('Clipboard write failed. Displaying fallback prompt:', clipErr);
prompt('Public Share Link:', shareLink);
}
} else {
alert('Sharing token was not generated. Please try saving this recipe as public again.');
}
}
async function revokeActiveRecipeShare() {
if (!activeRecipe) return;
if (!confirm(`Are you sure you want to revoke the shared link for "${activeRecipe.title}"?\n\nThis will make the recipe private, disable the public link immediately, and clear all visitor history.`)) return;
try {
const response = await fetch(`/api/recipes/${activeRecipe.id}/revoke-share`, {
method: 'POST',
headers: getAuthHeaders()
});
if (response.status === 401) {
logout();
return;
}
if (!response.ok) {
const err = await response.json().catch(() => ({}));
throw new Error(err.error || 'Failed to revoke shared link.');
}
alert(`Successfully revoked the public share link for "${activeRecipe.title}".`);
// Refresh list and details view
const listUrl = activeTab === 'community-vault' ? '/api/recipes/public' : '/api/recipes';
const listRes = await fetch(listUrl, { headers: getAuthHeaders() });
if (listRes.ok) {
allRecipes = await listRes.json();
renderLibrary();
const updated = allRecipes.find(r => r.id === activeRecipe.id);
if (updated) {
showRecipeDetails(updated);
} else {
closeModal('detail-modal');
}
}
} catch (error) {
console.error('Error revoking recipe share:', error);
alert(`Error: ${error.message}`);
}
}
// -------------------------------------------------------------
// DRAFT / MANUAL CREATOR MODAL POPULATOR
// -------------------------------------------------------------
function openManualAdd() {
elements.modalActionTitle.textContent = 'Create New Recipe';
elements.editRecipeId.value = '';
elements.recipeEditForm.reset();
elements.imageSelectionGrid.innerHTML = '';
selectedCoverImage = '';
elements.editIsPublic.checked = true;
openModal('save-modal');
}
function openDraftPreview(data) {
elements.modalActionTitle.textContent = 'Save Extracted Recipe';
elements.editRecipeId.value = '';
elements.editTitle.value = data.title || '';
elements.editSource.value = data.sourceUrl || '';
elements.editDescription.value = data.description || '';
elements.editServings.value = data.servings || '';
elements.editPrepTime.value = data.prepTime || '';
elements.editCookTime.value = data.cookTime || '';
elements.editIsPublic.checked = true;
// Ingredients text area
elements.editIngredients.value = data.ingredients ? data.ingredients.join('\n') : '';
// Instructions text area
elements.editInstructions.value = data.instructions ? data.instructions.join('\n') : '';
// Render Image Selector
renderImageSelector(data.images || []);
openModal('save-modal');
}
function editActiveRecipe() {
if (!activeRecipe) return;
elements.modalActionTitle.textContent = 'Edit Recipe Details';
elements.editRecipeId.value = activeRecipe.id;
elements.editTitle.value = activeRecipe.title || '';
elements.editSource.value = activeRecipe.sourceUrl || '';
elements.editDescription.value = activeRecipe.description || '';
elements.editServings.value = activeRecipe.servings || '';
elements.editPrepTime.value = activeRecipe.prepTime || '';
elements.editCookTime.value = activeRecipe.cookTime || '';
elements.editIsPublic.checked = activeRecipe.isPublic !== false;
elements.editIngredients.value = activeRecipe.ingredients ? activeRecipe.ingredients.join('\n') : '';
elements.editInstructions.value = activeRecipe.instructions ? activeRecipe.instructions.join('\n') : '';
renderImageSelector(activeRecipe.images || []);
// Set active selection
if (activeRecipe.image) {
elements.editImageUrl.value = activeRecipe.image;
selectedCoverImage = activeRecipe.image;
selectImageThumbnail(activeRecipe.image);
}
openModal('save-modal');
}
// -------------------------------------------------------------
// IMAGE EXTRACTION CAROUSEL & SELECTOR
// -------------------------------------------------------------
function renderImageSelector(images) {
elements.imageSelectionGrid.innerHTML = '';
selectedCoverImage = '';
if (!images || images.length === 0) {
elements.editImageUrl.value = '';
return;
}
// Pre-select first image
selectedCoverImage = images[0];
elements.editImageUrl.value = images[0];
images.forEach((imgSrc, index) => {
const option = document.createElement('div');
option.className = `image-option ${index === 0 ? 'selected' : ''}`;
option.innerHTML = `<img src="${imgSrc}" alt="extracted thumbnail">`;
option.addEventListener('click', () => {
elements.imageSelectionGrid.querySelectorAll('.image-option').forEach(el => el.classList.remove('selected'));
option.classList.add('selected');
selectedCoverImage = imgSrc;
elements.editImageUrl.value = imgSrc;
});
elements.imageSelectionGrid.appendChild(option);
});
}
function selectImageThumbnail(url) {
elements.imageSelectionGrid.querySelectorAll('.image-option').forEach(el => {
const img = el.querySelector('img');
if (img && img.src === url) {
el.classList.add('selected');
} else {
el.classList.remove('selected');
}
});
}
// -------------------------------------------------------------
// DASHBOARD RENDERING
// -------------------------------------------------------------
function renderLibrary() {
elements.recipesGrid.innerHTML = '';
const searchVal = elements.librarySearch.value.toLowerCase().trim();
const filtered = allRecipes.filter(recipe => {
return recipe.title.toLowerCase().includes(searchVal) ||
(recipe.description && recipe.description.toLowerCase().includes(searchVal)) ||
(recipe.ingredients && recipe.ingredients.some(i => i.toLowerCase().includes(searchVal)));
});
if (filtered.length === 0) {
elements.emptyState.style.display = 'flex';
return;
}
elements.emptyState.style.display = 'none';
filtered.forEach(recipe => {
const domain = recipe.sourceUrl ? new URL(recipe.sourceUrl).hostname.replace('www.', '') : 'Manual Recipe';
const card = document.createElement('div');
card.className = 'recipe-card';
card.addEventListener('click', () => showRecipeDetails(recipe));
let imgBlock = '';
if (recipe.image) {
imgBlock = `
<img src="${recipe.image}" class="recipe-card-img" alt="${recipe.title}" loading="lazy" onerror="this.style.display='none'; this.nextElementSibling.style.display='flex';">
<div class="recipe-card-img-placeholder" style="display: none;">
<i data-lucide="cooking-pot"></i>
<span>No dish photo</span>
</div>
`;
} else {
imgBlock = `
<div class="recipe-card-img-placeholder">
<i data-lucide="cooking-pot"></i>
<span>No dish photo</span>
</div>
`;
}
let creatorBadge = '';
if (activeTab === 'community-vault') {
const creator = recipe.createdBy || 'gourmet_system';
creatorBadge = `
<div class="recipe-card-creator" style="margin-top: 4px; margin-bottom: 8px;">
<i data-lucide="user"></i>
<span>@${creator}</span>
</div>
`;
}
card.innerHTML = `
<div class="recipe-card-img-wrapper">
${imgBlock}
</div>
<div class="recipe-card-content">
<span class="recipe-card-origin">${domain}</span>
<h3 class="recipe-card-title">${recipe.title}</h3>
${creatorBadge}
<p class="recipe-card-desc">${recipe.description || 'No description available for this recipe.'}</p>
<div class="recipe-card-meta">
${recipe.servings ? `<span><i data-lucide="users"></i> ${recipe.servings} Servings</span>` : ''}
${recipe.totalTime ? `<span><i data-lucide="clock"></i> ${recipe.totalTime}</span>` : ''}
</div>
</div>
`;
elements.recipesGrid.appendChild(card);
});
lucide.createIcons();
}
function filterRecipes() {
renderLibrary();
}
// -------------------------------------------------------------
// DYNAMIC SERVINGS QUANTITY SCALER
// -------------------------------------------------------------
function scaleIngredientText(text, multiplier) {
if (multiplier === 1) return text;
// Mixed number regex: e.g. "1 1/2" or "2 1/4"
const mixedRegex = /^(\d+)\s+(\d+\/\d+)\s*(.*)/;
const mixedMatch = text.match(mixedRegex);
if (mixedMatch) {
const whole = parseInt(mixedMatch[1], 10);
const fracParts = mixedMatch[2].split('/');
const fracVal = parseInt(fracParts[0], 10) / parseInt(fracParts[1], 10);
const total = (whole + fracVal) * multiplier;
return `${formatScaledNumber(total)} ${mixedMatch[3]}`;
}
// Pure Fraction regex: e.g. "1/2" or "3/4"
const fracRegex = /^(\d+\/\d+)\s*(.*)/;
const fracMatch = text.match(fracRegex);
if (fracMatch) {
const parts = fracMatch[1].split('/');
const val = (parseInt(parts[0], 10) / parseInt(parts[1], 10)) * multiplier;
return `${formatScaledNumber(val)} ${fracMatch[2]}`;
}
// Decimals & Integers regex: e.g. "1.5" or "4"
const numRegex = /^(\d+(?:\.\d+)?)\s*(.*)/;
const numMatch = text.match(numRegex);
if (numMatch) {
const val = parseFloat(numMatch[1]) * multiplier;
return `${formatScaledNumber(val)} ${numMatch[2]}`;
}
return text;
}
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);
// Common fraction mappings for nice reading
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 adjustMultiplier(change) {
const newMulti = currentMultiplier + change;
if (newMulti < 0.5 || newMulti > 10) return;
currentMultiplier = newMulti;
elements.currentServingsVal.textContent = `${currentMultiplier}x`;
renderScaledIngredients();
}
function renderScaledIngredients() {
elements.recipeDetailIngredients.innerHTML = '';
if (!activeRecipe || !activeRecipe.ingredients) return;
activeRecipe.ingredients.forEach((ing, index) => {
const scaledText = scaleIngredientText(ing, currentMultiplier);
const li = document.createElement('li');
li.className = 'ingredient-checkbox-item';
li.innerHTML = `
<div class="checkbox-custom"><i data-lucide="check" style="width:12px;height:12px;"></i></div>
<span class="ingredient-text">${scaledText}</span>
`;
li.addEventListener('click', () => {
li.classList.toggle('checked');
});
elements.recipeDetailIngredients.appendChild(li);
});
lucide.createIcons();
}
// -------------------------------------------------------------
// RECIPE DETAIL VIEWER COMPONENT
// -------------------------------------------------------------
function showRecipeDetails(recipe) {
activeRecipe = recipe;
currentMultiplier = 1;
elements.currentServingsVal.textContent = '1x';
if (shareBtnResetTimeout) {
clearTimeout(shareBtnResetTimeout);
shareBtnResetTimeout = null;
}
if (elements.btnDetailShare) {
elements.btnDetailShare.classList.remove('copied-feedback');
elements.btnDetailShare.innerHTML = `<i data-lucide="share-2"></i>`;
}
// Set hero cover banner image
if (recipe.image) {
elements.recipeDetailBanner.style.backgroundImage = `url('${recipe.image}')`;
} else {
elements.recipeDetailBanner.style.backgroundImage = 'none';
elements.recipeDetailBanner.style.backgroundColor = 'var(--bg-app)';
}
elements.recipeDetailTitle.textContent = recipe.title;
elements.recipeDetailDescription.textContent = recipe.description || 'No description available for this recipe.';
// Ownership and Permission controls
const isOwner = recipe.userId === currentUser.id;
const canEdit = isOwner || currentUser.isRecipeManager || currentUser.isAdmin;
if (canEdit) {
elements.btnDetailEdit.style.display = 'flex';
elements.btnDetailDelete.style.display = 'flex';
// Revoke share button toggle
if (recipe.isPublic && recipe.shareToken) {
elements.btnDetailRevokeShare.style.display = 'flex';
} else {
elements.btnDetailRevokeShare.style.display = 'none';
}
} else {
elements.btnDetailEdit.style.display = 'none';
elements.btnDetailDelete.style.display = 'none';
elements.btnDetailRevokeShare.style.display = 'none';
}
if (isOwner) {
elements.btnDetailClone.style.display = 'none';
elements.recipeDetailCreator.style.display = 'none';
} else {
elements.btnDetailClone.style.display = 'flex';
elements.recipeDetailCreator.style.display = 'inline-flex';
elements.recipeDetailCreatorName.textContent = `@${recipe.createdBy || 'gourmet_system'}`;
}
// Setup meta tags
if (recipe.servings) {
elements.tagServings.style.display = 'flex';
elements.tagServings.querySelector('.val').textContent = `${recipe.servings} Servings`;
} else {
elements.tagServings.style.display = 'none';
}
if (recipe.prepTime) {
elements.tagPrep.style.display = 'flex';
elements.tagPrep.querySelector('.val').textContent = recipe.prepTime;
} else {
elements.tagPrep.style.display = 'none';
}
if (recipe.cookTime) {
elements.tagCook.style.display = 'flex';
elements.tagCook.querySelector('.val').textContent = recipe.cookTime;
} else {
elements.tagCook.style.display = 'none';
}
if (recipe.sourceUrl) {
elements.tagSource.style.display = 'flex';
elements.tagSource.href = recipe.sourceUrl;
} else {
elements.tagSource.style.display = 'none';
}
// Public unique visitors count stats badge
if (recipe.isPublic && recipe.shareToken && recipe.visitorCount !== undefined) {
elements.tagShareStats.style.display = 'flex';
elements.tagShareStats.querySelector('.val').textContent = `${recipe.visitorCount} Unique ${recipe.visitorCount === 1 ? 'Visitor' : 'Visitors'}`;
} else {
elements.tagShareStats.style.display = 'none';
}
// Populate Ingredients with scaler
renderScaledIngredients();
populateDetailShoppingListSelect();
// Populate Directions Steps
elements.recipeDetailInstructions.innerHTML = '';
if (recipe.instructions) {
recipe.instructions.forEach((step, index) => {
const li = document.createElement('li');
// Look for section headers (e.g. ### Sauce)
if (step.startsWith('###')) {
li.className = 'instruction-step-item section-header-step';
li.innerHTML = `
<div class="step-text-block">${step.replace('###', '').trim()}</div>
`;
} else {
li.className = 'instruction-step-item';
li.innerHTML = `
<div class="step-number-bullet">${index + 1}</div>
<div class="step-text-block">${step}</div>
`;
li.addEventListener('click', () => {
li.classList.toggle('checked');
});
}
elements.recipeDetailInstructions.appendChild(li);
});
}
lucide.createIcons();
openModal('detail-modal');
}
// -------------------------------------------------------------
// INTERACTIVE FULLSCREEN COOKING ASSISTANT
// -------------------------------------------------------------
function enterCookingMode() {
if (!activeRecipe || !activeRecipe.instructions || activeRecipe.instructions.length === 0) return;
elements.cookRecipeTitle.textContent = activeRecipe.title;
cookingActiveStep = 0;
// Filter out header steps from the cooking carousel to keep steps clean
const cleanInstructions = activeRecipe.instructions.filter(step => !step.startsWith('###'));
if (cleanInstructions.length === 0) return;
elements.cookingModeModal.classList.add('active');
document.body.style.overflow = 'hidden'; // Lock background scrolling
renderCookingStep(cleanInstructions);
// Reset Timer
resetTimer();
startTimer();
}
function exitCookingMode() {
elements.cookingModeModal.classList.remove('active');
document.body.style.overflow = 'auto'; // Unlock background scrolling
stopTimer();
}
function renderCookingStep(instructions) {
const stepText = instructions[cookingActiveStep];
elements.cookStepIndex.textContent = `Step ${cookingActiveStep + 1} of ${instructions.length}`;
elements.cookStepInstruction.textContent = stepText;
// Update progress bar
const progressPercent = ((cookingActiveStep + 1) / instructions.length) * 100;
elements.cookProgressBar.style.width = `${progressPercent}%`;
// Render step navigation dot indicators
elements.cookStepDots.innerHTML = '';
instructions.forEach((_, idx) => {
const dot = document.createElement('div');
dot.className = `dot ${idx === cookingActiveStep ? 'active' : ''}`;
dot.addEventListener('click', () => {
cookingActiveStep = idx;
renderCookingStep(instructions);
});
elements.cookStepDots.appendChild(dot);
});
// Enable/disable buttons
elements.cookBtnPrev.disabled = cookingActiveStep === 0;
const nextBtnIconWrapper = elements.cookBtnNext.querySelector('.btn-icon-wrapper');
if (cookingActiveStep === instructions.length - 1) {
elements.cookBtnNext.querySelector('span').textContent = 'Finish Cooking';
if (nextBtnIconWrapper) nextBtnIconWrapper.innerHTML = '<i data-lucide="check"></i>';
} else {
elements.cookBtnNext.querySelector('span').textContent = 'Next Step';
if (nextBtnIconWrapper) nextBtnIconWrapper.innerHTML = '<i data-lucide="chevron-right"></i>';
}
lucide.createIcons();
}
function prevCookingStep() {
const cleanInstructions = activeRecipe.instructions.filter(step => !step.startsWith('###'));
if (cookingActiveStep > 0) {
cookingActiveStep--;
renderCookingStep(cleanInstructions);
}
}
function nextCookingStep() {
const cleanInstructions = activeRecipe.instructions.filter(step => !step.startsWith('###'));
if (cookingActiveStep < cleanInstructions.length - 1) {
cookingActiveStep++;
renderCookingStep(cleanInstructions);
} else {
// Finished cooking
exitCookingMode();
}
}
// Timer/Stopwatch Functions
function startTimer() {
if (stopwatchInterval) return;
elements.cookTimerPlay.innerHTML = '<i data-lucide="pause"></i>';
lucide.createIcons();
stopwatchInterval = setInterval(() => {
stopwatchSeconds++;
const mins = Math.floor(stopwatchSeconds / 60).toString().padStart(2, '0');
const secs = (stopwatchSeconds % 60).toString().padStart(2, '0');
elements.cookTimerVal.textContent = `${mins}:${secs}`;
}, 1000);
}
function stopTimer() {
if (stopwatchInterval) {
clearInterval(stopwatchInterval);
stopwatchInterval = null;
}
elements.cookTimerPlay.innerHTML = '<i data-lucide="play"></i>';
lucide.createIcons();
}
function toggleTimer() {
if (stopwatchInterval) {
stopTimer();
} else {
startTimer();
}
}
function resetTimer() {
stopTimer();
stopwatchSeconds = 0;
elements.cookTimerVal.textContent = '00:00';
}
// -------------------------------------------------------------
// USER AUTHENTICATION & SESSION MANAGEMENT
// -------------------------------------------------------------
function initAuth() {
const token = localStorage.getItem('gourmet_token');
const userJson = localStorage.getItem('gourmet_user');
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';
elements.userDisplayName.textContent = `@${currentUser.username}`;
// Toggle Admin visibility
if (currentUser.isAdmin) {
elements.tabUserAdmin.style.display = 'flex';
elements.mobileBtnAdmin.style.display = 'block';
} else {
elements.tabUserAdmin.style.display = 'none';
elements.mobileBtnAdmin.style.display = 'none';
}
// Preload shopping lists and user preferences in background
fetchShoppingLists();
syncUserPreferencesFromServer();
// Reset view to My Cookbook on init
switchTab('my-cookbook');
} else {
elements.authModal.classList.add('active');
elements.authModal.style.display = 'flex';
elements.userBadgeContainer.style.display = 'none';
elements.tabUserAdmin.style.display = 'none';
}
}
async function handleLogin(e) {
e.preventDefault();
elements.authErrorMsg.style.display = 'none';
const username = elements.loginUsernameInput.value.trim();
const password = elements.loginPasswordInput.value;
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
if (!response.ok) {
const err = await response.json().catch(() => ({}));
throw new Error(err.error || 'Login failed.');
}
const data = await response.json();
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';
elements.userBadgeContainer.style.display = 'flex';
elements.userDisplayName.textContent = `@${currentUser.username}`;
// Toggle Admin visibility
if (currentUser.isAdmin) {
elements.tabUserAdmin.style.display = 'flex';
elements.mobileBtnAdmin.style.display = 'block';
} else {
elements.tabUserAdmin.style.display = 'none';
elements.mobileBtnAdmin.style.display = 'none';
}
elements.loginForm.reset();
fetchShoppingLists();
// Default back to My Cookbook on fresh login
switchTab('my-cookbook');
} catch (error) {
elements.authErrorText.textContent = error.message;
elements.authErrorMsg.style.display = 'flex';
}
}
function logout() {
localStorage.removeItem('gourmet_token');
localStorage.removeItem('gourmet_user');
currentUser = null;
elements.authModal.classList.add('active');
elements.authModal.style.display = 'flex';
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');
if (filters) filters.style.display = 'flex';
renderLibrary();
}
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');
// 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';
// Update Dynamic Mobile Header Title
if (elements.libraryViewTitle) {
if (tabName === 'my-cookbook') {
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';
}
}
if (tabName === 'my-cookbook') {
elements.tabMyCookbook.classList.add('active');
elements.mobileTabCookbook.classList.add('active');
elements.recipesGrid.style.display = 'grid';
activeTab = 'my-cookbook';
fetchRecipes();
} else if (tabName === 'community-vault') {
elements.tabCommunityVault.classList.add('active');
elements.mobileTabCommunity.classList.add('active');
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');
elements.mobileTabProfile.classList.add('active');
elements.userAdminPanel.style.display = 'block';
if (filters) filters.style.display = 'none';
activeTab = 'user-admin';
switchAdminSubTab('users');
}
lucide.createIcons();
}
// -------------------------------------------------------------
// USER ADMINISTRATION MANAGEMENT PANEL (ADMIN ONLY)
// -------------------------------------------------------------
async function fetchAdminUsers() {
try {
const response = await fetch('/api/admin/users', {
headers: getAuthHeaders()
});
if (response.status === 401) {
logout();
return;
}
if (!response.ok) throw new Error('Failed to load registered chef accounts.');
const users = await response.json();
renderAdminUsers(users);
} catch (error) {
console.error('Error fetching admin users:', error);
}
}
function renderAdminUsers(users) {
elements.adminUsersList.innerHTML = '';
users.forEach(user => {
const tr = document.createElement('tr');
let roleBadge = user.isAdmin
? '<span class="admin-role-badge admin"><i data-lucide="shield"></i> Admin</span>'
: '<span class="admin-role-badge chef"><i data-lucide="chef-hat"></i> Chef</span>';
if (user.isRecipeManager) {
roleBadge += ' <span class="admin-role-badge manager" style="margin-left: 6px;"><i data-lucide="shield-check"></i> Manager</span>';
}
const dateStr = new Date(user.createdAt).toLocaleDateString(undefined, {
year: 'numeric',
month: 'long',
day: 'numeric'
});
const isSelf = user.id === currentUser.id;
const actionCell = isSelf
? `<span style="font-size: 0.88rem; color: var(--text-muted); font-style: italic;">Active Session</span>`
: `
${user.isAdmin
? `<button class="btn btn-outline" style="padding: 6px 12px; font-size: 0.8rem; border-radius: 6px; margin-right: 8px;" onclick="toggleUserRole('${user.id}', false)"><i data-lucide="user-check" style="width: 12px; height: 12px;"></i> Demote to Chef</button>`
: `<button class="btn btn-accent" style="padding: 6px 12px; font-size: 0.8rem; border-radius: 6px; margin-right: 8px;" onclick="toggleUserRole('${user.id}', true)"><i data-lucide="shield" style="width: 12px; height: 12px;"></i> Promote to Admin</button>`
}
${user.isRecipeManager
? `<button class="btn btn-outline" style="padding: 6px 12px; font-size: 0.8rem; border-radius: 6px; margin-right: 8px; background-color: rgba(99, 102, 241, 0.1); border-color: #6366f1; color: #6366f1;" onclick="toggleRecipeManagerRole('${user.id}', false)"><i data-lucide="user-x" style="width: 12px; height: 12px;"></i> Revoke Manager</button>`
: `<button class="btn btn-outline" style="padding: 6px 12px; font-size: 0.8rem; border-radius: 6px; margin-right: 8px; border-color: rgba(99, 102, 241, 0.5); color: #6366f1;" onclick="toggleRecipeManagerRole('${user.id}', true)"><i data-lucide="user-cog" style="width: 12px; height: 12px;"></i> Grant Manager</button>`
}
<button class="btn btn-danger" style="padding: 6px 12px; font-size: 0.8rem; border-radius: 6px;" onclick="confirmDeleteUser('${user.id}', '${user.username}')"><i data-lucide="trash-2" style="width: 12px; height: 12px;"></i> Delete</button>
`;
tr.innerHTML = `
<td style="font-weight: 600; color: var(--text-main);">@${user.username}</td>
<td>${roleBadge}</td>
<td style="color: var(--text-muted); font-size: 0.9rem;">${dateStr}</td>
<td style="text-align: right;">${actionCell}</td>
`;
elements.adminUsersList.appendChild(tr);
});
lucide.createIcons();
}
function switchAdminSubTab(subTabName) {
const tabs = [
{ name: 'users', btn: elements.btnAdminTabUsers, section: elements.adminUsersSection },
{ name: 'shares', btn: elements.btnAdminTabShares, section: elements.adminSharesSection },
{ name: 'backups', btn: elements.btnAdminTabBackups, section: elements.adminBackupsSection }
];
tabs.forEach(t => {
if (!t.btn || !t.section) return;
if (t.name === subTabName) {
t.btn.classList.add('btn-primary');
t.btn.classList.remove('btn-outline');
t.section.style.display = 'block';
} else {
t.btn.classList.add('btn-outline');
t.btn.classList.remove('btn-primary');
t.section.style.display = 'none';
}
});
if (subTabName === 'users') {
fetchAdminUsers();
} else if (subTabName === 'shares') {
fetchAdminSharedRecipes();
} else if (subTabName === 'backups') {
fetchAdminBackupConfig();
}
lucide.createIcons();
}
async function fetchAdminSharedRecipes() {
try {
const response = await fetch('/api/admin/shared-recipes', {
headers: getAuthHeaders()
});
if (response.status === 401) {
logout();
return;
}
if (!response.ok) throw new Error('Failed to load shared recipe records.');
const recipes = await response.json();
renderAdminSharedRecipes(recipes);
} catch (error) {
console.error('Error fetching admin shared recipes:', error);
}
}
function renderAdminSharedRecipes(recipes) {
elements.adminSharesList.innerHTML = '';
if (recipes.length === 0) {
const tr = document.createElement('tr');
tr.innerHTML = `
<td colspan="5" style="text-align: center; color: var(--text-muted); padding: 24px; font-style: italic;">
No recipes are currently shared publicly.
</td>
`;
elements.adminSharesList.appendChild(tr);
return;
}
recipes.forEach(recipe => {
const tr = document.createElement('tr');
const dateStr = new Date(recipe.createdAt).toLocaleDateString(undefined, {
year: 'numeric',
month: 'long',
day: 'numeric'
});
const shareLink = `${window.location.origin}/share.html?token=${recipe.shareToken}`;
const copyActionId = `btn-copy-share-${recipe.id}`;
const revokeActionId = `btn-revoke-share-${recipe.id}`;
tr.innerHTML = `
<td style="font-weight: 600; color: var(--text-main);">${recipe.title}</td>
<td style="font-weight: 500; color: var(--text-main);">@${recipe.createdBy}</td>
<td style="color: var(--text-muted); font-size: 0.9rem;">${dateStr}</td>
<td>
<span class="admin-role-badge" style="background: rgba(99, 102, 241, 0.12); border: 1px dashed rgba(99, 102, 241, 0.3); color: #6366f1; padding: 4px 8px; border-radius: 6px; display: inline-flex; align-items: center; gap: 4px;">
<i data-lucide="eye" style="width: 12px; height: 12px;"></i>
<span>${recipe.visitorCount} Unique ${recipe.visitorCount === 1 ? 'Visitor' : 'Visitors'}</span>
</span>
</td>
<td style="text-align: right;">
<button class="btn btn-outline" id="${copyActionId}" style="padding: 6px 12px; font-size: 0.8rem; border-radius: 6px; margin-right: 8px;">
<i data-lucide="copy" style="width: 12px; height: 12px;"></i> Copy Link
</button>
<button class="btn btn-danger" id="${revokeActionId}" style="padding: 6px 12px; font-size: 0.8rem; border-radius: 6px;">
<i data-lucide="link-2-off" style="width: 12px; height: 12px;"></i> Revoke
</button>
</td>
`;
elements.adminSharesList.appendChild(tr);
// Bind click events inside the row
const copyBtn = document.getElementById(copyActionId);
let rowCopyTimeout = null;
copyBtn.addEventListener('click', async () => {
try {
await copyTextToClipboard(shareLink);
if (rowCopyTimeout) clearTimeout(rowCopyTimeout);
copyBtn.innerHTML = `<i data-lucide="check" style="width: 12px; height: 12px;"></i> Copied!`;
copyBtn.style.borderColor = 'var(--accent)';
copyBtn.style.color = 'var(--accent)';
lucide.createIcons();
rowCopyTimeout = setTimeout(() => {
copyBtn.innerHTML = `<i data-lucide="copy" style="width: 12px; height: 12px;"></i> Copy Link`;
copyBtn.style.borderColor = '';
copyBtn.style.color = '';
lucide.createIcons();
}, 2000);
} catch (clipErr) {
prompt('Public Share Link:', shareLink);
}
});
document.getElementById(revokeActionId).addEventListener('click', () => {
revokeSharedRecipeFromList(recipe.id, recipe.title);
});
});
lucide.createIcons();
}
async function revokeSharedRecipeFromList(recipeId, recipeTitle) {
if (!confirm(`Are you sure you want to revoke the public share link for "${recipeTitle}" as an Administrator?\n\nThis will make the recipe private, disable the public link immediately, and clear all visitor history.`)) return;
try {
const response = await fetch(`/api/recipes/${recipeId}/revoke-share`, {
method: 'POST',
headers: getAuthHeaders()
});
if (response.status === 401) {
logout();
return;
}
if (!response.ok) {
const err = await response.json().catch(() => ({}));
throw new Error(err.error || 'Failed to revoke shared link.');
}
alert(`Successfully revoked the public share link for "${recipeTitle}".`);
fetchAdminSharedRecipes();
} catch (error) {
console.error('Error revoking recipe share from admin console:', error);
alert(`Error: ${error.message}`);
}
}
async function handleAdminCreateUser(e) {
e.preventDefault();
elements.adminCreateError.style.display = 'none';
const username = elements.adminNewUsername.value.trim();
const password = elements.adminNewPassword.value;
try {
const response = await fetch('/api/auth/register', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...getAuthHeaders()
},
body: JSON.stringify({ username, password })
});
if (response.status === 401) {
logout();
return;
}
if (!response.ok) {
const err = await response.json().catch(() => ({}));
throw new Error(err.error || 'Failed to create chef account.');
}
closeModal('admin-create-user-modal');
elements.adminCreateUserForm.reset();
fetchAdminUsers();
} catch (error) {
elements.adminCreateErrorText.textContent = error.message;
elements.adminCreateError.style.display = 'flex';
}
}
window.confirmDeleteUser = function(id, username) {
elements.deleteUserTargetId.value = id;
elements.deleteUserTargetName.textContent = `@${username}`;
elements.deleteUserPreserveRecipes.checked = true;
openModal('admin-delete-user-modal');
};
async function handleAdminDeleteUser() {
const id = elements.deleteUserTargetId.value;
const preserveRecipes = elements.deleteUserPreserveRecipes.checked;
try {
const response = await fetch(`/api/admin/users/${id}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
...getAuthHeaders()
},
body: JSON.stringify({ preserveRecipes })
});
if (response.status === 401) {
logout();
return;
}
if (!response.ok) {
const err = await response.json().catch(() => ({}));
throw new Error(err.error || 'Failed to delete user.');
}
closeModal('admin-delete-user-modal');
fetchAdminUsers();
} catch (error) {
alert(`Error deleting user: ${error.message}`);
}
}
window.toggleUserRole = async function(id, makeAdmin) {
try {
const response = await fetch(`/api/admin/users/${id}/role`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
...getAuthHeaders()
},
body: JSON.stringify({ isAdmin: makeAdmin })
});
if (response.status === 401) {
logout();
return;
}
if (!response.ok) {
const err = await response.json().catch(() => ({}));
throw new Error(err.error || 'Failed to update user role.');
}
fetchAdminUsers();
} catch (error) {
alert(`Error updating role: ${error.message}`);
}
};
window.toggleRecipeManagerRole = async function(id, makeManager) {
try {
const response = await fetch(`/api/admin/users/${id}/recipe-manager`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
...getAuthHeaders()
},
body: JSON.stringify({ isRecipeManager: makeManager })
});
if (response.status === 401) {
logout();
return;
}
if (!response.ok) {
const err = await response.json().catch(() => ({}));
throw new Error(err.error || 'Failed to update recipe manager permission.');
}
fetchAdminUsers();
} catch (error) {
alert(`Error updating permission: ${error.message}`);
}
};
// -------------------------------------------------------------
// SYSTEM SETTINGS & GEMINI AI PHOTO SCANNER
// -------------------------------------------------------------
async function openSettingsModal() {
// Reset visibility states and statuses
elements.settingsStatusMsg.style.display = 'none';
elements.settingsGeminiKeyInput.type = 'password';
const toggleIcon = elements.btnToggleGeminiKey.querySelector('i, svg');
if (toggleIcon) toggleIcon.setAttribute('data-lucide', 'eye');
lucide.createIcons();
elements.settingsGeminiKeyInput.value = '';
elements.settingsGeminiKeyInput.placeholder = 'Loading credentials...';
openModal('settings-modal');
try {
const response = await fetch('/api/settings', {
headers: getAuthHeaders()
});
if (response.status === 401) {
logout();
closeModal('settings-modal');
return;
}
if (!response.ok) throw new Error('Settings could not be loaded.');
const settings = await response.json();
const key = settings.geminiApiKey ? settings.geminiApiKey.trim() : '';
const isPlaceholder = key === 'PASTE_YOUR_GEMINI_API_KEY_HERE';
elements.settingsGeminiKeyInput.placeholder = 'Enter your Gemini API key...';
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';
}
}
async function handleSaveSettings(e) {
e.preventDefault();
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', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...getAuthHeaders()
},
body: JSON.stringify({
geminiApiKey: key,
defaultConsolidateLists: defaultConsolidate
})
});
if (response.status === 401) {
logout();
closeModal('settings-modal');
return;
}
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(() => {
elements.settingsStatusMsg.style.display = 'none';
}, 4000);
} catch (err) {
console.error('Error saving settings:', err);
alert(`Failed to save settings: ${err.message}`);
}
}
function toggleSettingsKeyVisibility() {
const input = elements.settingsGeminiKeyInput;
const toggleIcon = elements.btnToggleGeminiKey.querySelector('i, svg');
if (input.type === 'password') {
input.type = 'text';
if (toggleIcon) toggleIcon.setAttribute('data-lucide', 'eye-off');
} else {
input.type = 'password';
if (toggleIcon) toggleIcon.setAttribute('data-lucide', 'eye');
}
lucide.createIcons();
}
function showScanLoading() {
elements.loadingOverlay.classList.add('active');
const title = elements.loadingOverlay.querySelector('h3');
const subtitle = elements.loadingOverlay.querySelector('.loader-subtitle');
if (title) title.textContent = 'Scanning Recipe Photo';
if (subtitle) subtitle.textContent = 'Loading parameters and initializing scanner...';
elements.loaderStepFetch.className = 'loader-step active';
elements.loaderStepParse.className = 'loader-step';
elements.loaderStepImages.className = 'loader-step';
elements.loaderStepFetch.querySelector('span:last-child').textContent = 'Loading system configuration...';
elements.loaderStepParse.querySelector('span:last-child').textContent = 'Compressing and optimizing image...';
elements.loaderStepImages.querySelector('span:last-child').textContent = 'Digitizing with Gemini Vision AI...';
setLoaderStepIcon(elements.loaderStepFetch, 'circle-dashed', true);
setLoaderStepIcon(elements.loaderStepParse, 'circle', false);
setLoaderStepIcon(elements.loaderStepImages, 'circle', false);
lucide.createIcons();
}
function hideScanLoading() {
elements.loadingOverlay.classList.remove('active');
// Restore original loading screen texts
const title = elements.loadingOverlay.querySelector('h3');
const subtitle = elements.loadingOverlay.querySelector('.loader-subtitle');
if (title) title.textContent = 'Extracting Recipe Data';
if (subtitle) subtitle.textContent = 'Retrieving site contents and parsing culinary structures...';
elements.loaderStepFetch.querySelector('span:last-child').textContent = 'Connecting to source URL...';
elements.loaderStepParse.querySelector('span:last-child').textContent = 'Parsing schema structures...';
elements.loaderStepImages.querySelector('span:last-child').textContent = 'Extracting high-res dish photos...';
}
function compressImage(file, maxDimension = 1600) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = (event) => {
const img = new Image();
img.src = event.target.result;
img.onload = () => {
let width = img.width;
let height = img.height;
// Downscale maintaining aspect ratio
if (width > maxDimension || height > maxDimension) {
if (width > height) {
height = Math.round((height * maxDimension) / width);
width = maxDimension;
} else {
width = Math.round((width * maxDimension) / height);
height = maxDimension;
}
}
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0, width, height);
const dataUrl = canvas.toDataURL('image/jpeg', 0.85);
const base64 = dataUrl.split(',')[1];
resolve({
dataUrl,
base64,
mimeType: 'image/jpeg'
});
};
img.onerror = (err) => reject(err);
};
reader.onerror = (err) => reject(err);
});
}
async function handleRecipeImageScan(e) {
const file = e.target.files[0];
if (!file) return;
scanRecipeFile(file);
}
async function handleClipboardPaste(e) {
// Only trigger scan if logged in to prevent unauthenticated access attempts
if (!currentUser) return;
const items = (e.clipboardData || e.originalEvent?.clipboardData)?.items;
if (!items) return;
for (const item of items) {
if (item.kind === 'file' && item.type.startsWith('image/')) {
const file = item.getAsFile();
e.preventDefault(); // Intercept paste action
scanRecipeFile(file);
break;
}
}
}
async function scanRecipeFile(file) {
showScanLoading();
try {
// 1. Fetch system-wide configuration
const settingsResponse = await fetch('/api/settings', {
headers: getAuthHeaders()
});
if (settingsResponse.status === 401) {
logout();
hideScanLoading();
return;
}
if (!settingsResponse.ok) {
throw new Error('Could not fetch settings. Please ensure system settings are configured.');
}
const settings = await settingsResponse.json();
const apiKey = settings.geminiApiKey ? settings.geminiApiKey.trim() : '';
const isPlaceholder = apiKey === 'PASTE_YOUR_GEMINI_API_KEY_HERE' || apiKey === '';
if (!apiKey || isPlaceholder) {
hideScanLoading();
elements.scanImageInput.value = '';
alert('Google Gemini API Key is not configured.\n\nPlease click the Settings button at the top right, configure your Google Gemini API Key, and try again.');
return;
}
// Step 1 Completed -> Activate Step 2 (Compression)
elements.loaderStepFetch.className = 'loader-step completed';
setLoaderStepIcon(elements.loaderStepFetch, 'check-circle-2', false);
elements.loaderStepParse.className = 'loader-step active';
setLoaderStepIcon(elements.loaderStepParse, 'circle-dashed', true);
lucide.createIcons();
// 2. Compress image using Canvas
const compressed = await compressImage(file, 1600);
// Step 2 Completed -> Activate Step 3 (Gemini transcription)
elements.loaderStepParse.className = 'loader-step completed';
setLoaderStepIcon(elements.loaderStepParse, 'check-circle-2', false);
elements.loaderStepImages.className = 'loader-step active';
setLoaderStepIcon(elements.loaderStepImages, 'circle-dashed', true);
lucide.createIcons();
// 3. Make direct call to Google Gemini Vision API
const promptText = "You are a professional culinary transcriber. Analyze the image of a recipe (which could be a cookbook page, handwritten note, screenshot, or packaging) and extract it accurately. Fill in all fields as completely as possible. The response must be a JSON object matching this schema:\n{\n \"title\": \"Recipe Title\",\n \"description\": \"Brief description or review\",\n \"servings\": \"e.g., 4\",\n \"prepTime\": \"e.g., 15 mins\",\n \"cookTime\": \"e.g., 30 mins\",\n \"totalTime\": \"e.g., 45 mins\",\n \"ingredients\": [\"ingredient 1\", \"ingredient 2\"],\n \"instructions\": [\"step 1\", \"step 2\"]\n}\nDo not include any markdown styling (like ```json) or explanation. Return only the raw JSON string.";
const geminiUrl = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${apiKey}`;
const geminiResponse = await fetch(geminiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
contents: [
{
parts: [
{ text: promptText },
{
inlineData: {
mimeType: compressed.mimeType,
data: compressed.base64
}
}
]
}
],
generationConfig: {
responseMimeType: "application/json"
}
})
});
if (!geminiResponse.ok) {
const errorObj = await geminiResponse.json().catch(() => ({}));
const status = geminiResponse.status;
let errMsg = 'Google Gemini API call failed.';
if (status === 400) {
errMsg = 'Invalid parameters or model version. Please verify your settings.';
} else if (status === 403) {
errMsg = 'Access denied. The Google Gemini API key is invalid or inactive.';
} else if (status === 429) {
errMsg = 'Rate limit exceeded. You have made too many requests or hit your Gemini quota.';
} else if (errorObj.error && errorObj.error.message) {
errMsg = `Gemini API Error: ${errorObj.error.message}`;
}
throw new Error(errMsg);
}
const result = await geminiResponse.json();
if (!result.candidates || result.candidates.length === 0 || !result.candidates[0].content) {
throw new Error('Gemini API returned an empty response. The text in the image may be unreadable.');
}
const rawText = result.candidates[0].content.parts[0].text;
const recipeData = JSON.parse(rawText);
// Auto-inject the scanned local picture as a custom visual preview option
recipeData.images = [compressed.dataUrl];
recipeData.image = compressed.dataUrl;
// 4. Finalize and trigger draft preview
hideScanLoading();
elements.scanImageInput.value = '';
openDraftPreview(recipeData);
} catch (error) {
console.error('Photo scanning error occurred:', error);
hideScanLoading();
elements.scanImageInput.value = '';
alert(`Scanning Failed: ${error.message}\n\nPlease verify your internet connection and check that your Gemini API Key is correct.`);
}
}
// -------------------------------------------------------------
// MOBILE PORTAL CONTROLS & GESTURES
// -------------------------------------------------------------
function openMobileProfile() {
if (!currentUser) return;
elements.mobileProfileUsername.textContent = `@${currentUser.username}`;
// Set role text
let roleText = 'Chef Account';
if (currentUser.isAdmin) {
roleText = 'Administrator';
} else if (currentUser.isRecipeManager) {
roleText = 'Recipe Manager';
}
elements.mobileProfileRole.textContent = roleText;
// Handle theme icon inside profile drawer
const isDark = document.body.classList.contains('dark-theme');
const sunIcon = elements.mobileBtnThemeToggle.querySelector('.sun-icon');
const moonIcon = elements.mobileBtnThemeToggle.querySelector('.moon-icon');
if (sunIcon && moonIcon) {
if (isDark) {
sunIcon.style.display = 'block';
moonIcon.style.display = 'none';
} else {
sunIcon.style.display = 'none';
moonIcon.style.display = 'block';
}
}
openModal('mobile-profile-modal');
}
function handleFabClick() {
// If not on cookbook tab, switch back to list first
if (activeTab !== 'my-cookbook') {
switchTab('my-cookbook');
}
// Open the manual recipe creator
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();
}
// ==========================================
// ADMIN DATABASE BACKUPS & RECOVERY CONSOLE
// ==========================================
let backupStatusTimeout = null;
function showBackupStatus(message, type = 'info') {
if (!elements.backupStatusAlert) return;
if (backupStatusTimeout) clearTimeout(backupStatusTimeout);
elements.backupStatusAlert.className = `backup-status-alert status-${type}`;
const icon = type === 'success' ? 'check-circle-2' : type === 'error' ? 'alert-triangle' : 'info';
elements.backupStatusAlert.innerHTML = `<i data-lucide="${icon}" style="width:18px;height:18px;flex-shrink:0;"></i><span>${message}</span>`;
elements.backupStatusAlert.classList.remove('hidden');
lucide.createIcons();
if (type !== 'error') {
backupStatusTimeout = setTimeout(() => {
elements.backupStatusAlert.classList.add('hidden');
}, 8000);
}
}
function getBackupFormPayload() {
return {
schedule: elements.backupSchedule ? elements.backupSchedule.value : 'off',
scheduleTime: elements.backupScheduleTime ? elements.backupScheduleTime.value : '02:00',
retentionCount: elements.backupRetentionCount ? Number(elements.backupRetentionCount.value) || 14 : 14,
includeRemoteImages: elements.backupIncludeRemoteImages ? elements.backupIncludeRemoteImages.checked : true,
s3Enabled: elements.backupS3Enabled ? elements.backupS3Enabled.checked : false,
s3Endpoint: elements.backupS3Endpoint ? elements.backupS3Endpoint.value.trim() : 'https://s3.wasabisys.com',
s3Region: elements.backupS3Region ? elements.backupS3Region.value.trim() : 'us-east-1',
s3Bucket: elements.backupS3Bucket ? elements.backupS3Bucket.value.trim() : '',
s3Prefix: elements.backupS3Prefix ? elements.backupS3Prefix.value.trim() : 'forkfolio-backups/',
s3AccessKeyId: elements.backupS3AccessKey ? elements.backupS3AccessKey.value.trim() : '',
s3SecretAccessKey: elements.backupS3SecretKey ? elements.backupS3SecretKey.value.trim() : '',
gdriveEnabled: elements.backupGdriveEnabled ? elements.backupGdriveEnabled.checked : false,
gdriveClientId: elements.backupGdriveClientId ? elements.backupGdriveClientId.value.trim() : '',
gdriveClientSecret: elements.backupGdriveClientSecret ? elements.backupGdriveClientSecret.value.trim() : '',
gdriveRefreshToken: elements.backupGdriveRefreshToken ? elements.backupGdriveRefreshToken.value.trim() : '',
gdriveFolderId: elements.backupGdriveFolderId ? elements.backupGdriveFolderId.value.trim() : ''
};
}
async function fetchAdminBackupConfig() {
try {
const res = await fetch('/api/admin/backups/config', {
headers: getAuthHeaders()
});
if (res.status === 401) {
logout();
return;
}
if (!res.ok) throw new Error('Failed to load backup configuration.');
const data = await res.json();
const cfg = data.config || {};
if (elements.backupSchedule) elements.backupSchedule.value = cfg.schedule || 'off';
if (elements.backupScheduleTime) elements.backupScheduleTime.value = cfg.scheduleTime || '02:00';
if (elements.backupRetentionCount) elements.backupRetentionCount.value = cfg.retentionCount || 14;
if (elements.backupIncludeRemoteImages) elements.backupIncludeRemoteImages.checked = Boolean(cfg.includeRemoteImages);
if (elements.backupS3Enabled) elements.backupS3Enabled.checked = Boolean(cfg.s3Enabled);
if (elements.backupS3Endpoint) elements.backupS3Endpoint.value = cfg.s3Endpoint || 'https://s3.wasabisys.com';
if (elements.backupS3Region) elements.backupS3Region.value = cfg.s3Region || 'us-east-1';
if (elements.backupS3Bucket) elements.backupS3Bucket.value = cfg.s3Bucket || '';
if (elements.backupS3Prefix) elements.backupS3Prefix.value = cfg.s3Prefix || 'forkfolio-backups/';
if (elements.backupS3AccessKey) elements.backupS3AccessKey.value = cfg.s3AccessKeyId || '';
if (elements.backupS3SecretKey) elements.backupS3SecretKey.value = cfg.s3SecretAccessKey || '';
if (elements.backupGdriveEnabled) elements.backupGdriveEnabled.checked = Boolean(cfg.gdriveEnabled);
if (elements.backupGdriveClientId) elements.backupGdriveClientId.value = cfg.gdriveClientId || '';
if (elements.backupGdriveClientSecret) elements.backupGdriveClientSecret.value = cfg.gdriveClientSecret || '';
if (elements.backupGdriveRefreshToken) elements.backupGdriveRefreshToken.value = cfg.gdriveRefreshToken || '';
if (elements.backupGdriveFolderId) elements.backupGdriveFolderId.value = cfg.gdriveFolderId || '';
if (elements.backupLastRunBadge) {
if (cfg.lastRunAt) {
const dt = new Date(cfg.lastRunAt).toLocaleString();
const st = cfg.lastRunStatus === 'success' ? '✓ Success' : cfg.lastRunStatus === 'error' ? '⚠ Failed' : cfg.lastRunStatus || '';
elements.backupLastRunBadge.textContent = `Last Cloud Run: ${dt} (${st})`;
} else {
elements.backupLastRunBadge.textContent = 'Last Cloud Run: Never';
}
}
renderAdminBackupHistory(data.history || []);
} catch (err) {
console.error('Error fetching backup config:', err);
showBackupStatus(err.message, 'error');
}
}
function formatBytesHuman(bytes) {
const n = Number(bytes) || 0;
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / (1024 * 1024)).toFixed(2)} MB`;
}
function renderAdminBackupHistory(history) {
if (!elements.adminBackupsHistoryList) return;
elements.adminBackupsHistoryList.innerHTML = '';
if (!history || history.length === 0) {
elements.adminBackupsHistoryList.innerHTML = `
<tr>
<td colspan="7" style="text-align: center; color: var(--text-muted); padding: 24px; font-style: italic;">
No cloud backups have been recorded yet. Configure Wasabi S3 or Google Drive above and click "Backup to Cloud Now", or use "Download .DB" / "Download .JSON" for immediate local computer downloads.
</td>
</tr>
`;
return;
}
history.forEach(item => {
const tr = document.createElement('tr');
const dateStr = new Date(item.createdAt).toLocaleString();
const triggerBadge = item.triggerType === 'scheduled'
? `<span class="admin-role-badge" style="background:rgba(99,102,241,0.12);color:#6366f1;padding:3px 8px;border-radius:6px;font-size:0.78rem;">Scheduled</span>`
: `<span class="admin-role-badge" style="background:rgba(249,115,22,0.12);color:var(--accent);padding:3px 8px;border-radius:6px;font-size:0.78rem;">Manual</span>`;
const destBadges = [];
if (item.s3KeyDb) {
destBadges.push(`<span class="admin-role-badge" style="background:rgba(16,185,129,0.12);color:#10b981;padding:3px 8px;border-radius:6px;font-size:0.76rem;margin-right:4px;">Wasabi S3</span>`);
}
if (item.gdriveFileIdDb) {
destBadges.push(`<span class="admin-role-badge" style="background:rgba(59,130,246,0.12);color:#3b82f6;padding:3px 8px;border-radius:6px;font-size:0.76rem;">Google Drive</span>`);
}
const statusBadge = item.status === 'success'
? `<span style="color:#10b981;font-weight:600;font-size:0.84rem;">✓ Success</span>`
: `<span style="color:#ef4444;font-weight:600;font-size:0.84rem;" title="${(item.errorMessage || '').replace(/"/g, '&quot;')}">⚠ Error</span>`;
const hasCloudFiles = Boolean(item.s3KeyDb || item.gdriveFileIdDb);
tr.innerHTML = `
<td style="font-size:0.88rem;color:var(--text-main);white-space:nowrap;">${dateStr}</td>
<td>${triggerBadge}</td>
<td style="font-size:0.85rem;color:var(--text-muted);">${item.userCount} users · ${item.recipeCount} recipes · ${item.shoppingListCount} lists</td>
<td style="font-size:0.85rem;color:var(--text-muted);white-space:nowrap;">DB: ${formatBytesHuman(item.dbSizeBytes)}<br>JSON: ${formatBytesHuman(item.jsonSizeBytes)}</td>
<td>${destBadges.join('') || '<span style="color:var(--text-muted);font-size:0.8rem;">None</span>'}</td>
<td>${statusBadge}${item.errorMessage ? `<div style="font-size:0.75rem;color:#ef4444;max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${item.errorMessage.replace(/"/g, '&quot;')}">${item.errorMessage}</div>` : ''}</td>
<td style="text-align:right;white-space:nowrap;">
${hasCloudFiles ? `
<button type="button" class="btn btn-outline btn-dl-db" style="padding:5px 9px;font-size:0.76rem;border-radius:6px;margin-right:4px;" title="Stream .db from Cloud to Computer">
<i data-lucide="download" style="width:12px;height:12px;"></i> .DB
</button>
<button type="button" class="btn btn-outline btn-dl-json" style="padding:5px 9px;font-size:0.76rem;border-radius:6px;margin-right:4px;" title="Stream .json from Cloud to Computer">
<i data-lucide="file-json" style="width:12px;height:12px;"></i> .JSON
</button>
<button type="button" class="btn btn-outline btn-restore-cloud" style="padding:5px 9px;font-size:0.76rem;border-radius:6px;margin-right:4px;border-color:rgba(249,115,22,0.4);color:var(--accent);" title="Restore Database from this Cloud Snapshot">
<i data-lucide="rotate-ccw" style="width:12px;height:12px;"></i> Restore
</button>
` : ''}
<button type="button" class="btn btn-danger btn-delete-cloud" style="padding:5px 9px;font-size:0.76rem;border-radius:6px;" title="Delete Snapshot from Cloud & History">
<i data-lucide="trash-2" style="width:12px;height:12px;"></i>
</button>
</td>
`;
const btnDlDb = tr.querySelector('.btn-dl-db');
if (btnDlDb) btnDlDb.addEventListener('click', () => downloadCloudBackupItem(item.id, 'db', item.filenameDb));
const btnDlJson = tr.querySelector('.btn-dl-json');
if (btnDlJson) btnDlJson.addEventListener('click', () => downloadCloudBackupItem(item.id, 'json', item.filenameJson));
const btnRestore = tr.querySelector('.btn-restore-cloud');
if (btnRestore) btnRestore.addEventListener('click', () => restoreCloudBackupItem(item.id, dateStr));
const btnDelete = tr.querySelector('.btn-delete-cloud');
if (btnDelete) btnDelete.addEventListener('click', () => deleteCloudBackupItem(item.id, dateStr));
elements.adminBackupsHistoryList.appendChild(tr);
});
lucide.createIcons();
}
async function handleSaveBackupConfig(e) {
e.preventDefault();
try {
const payload = getBackupFormPayload();
const res = await fetch('/api/admin/backups/config', {
method: 'PUT',
headers: getAuthHeaders(),
body: JSON.stringify(payload)
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Failed to save backup configuration.');
showBackupStatus('Backup & schedule settings saved!', 'success');
fetchAdminBackupConfig();
} catch (err) {
showBackupStatus(err.message, 'error');
}
}
async function handleTestS3Connection() {
const btn = elements.btnBackupTestS3;
const origHtml = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = `<i data-lucide="loader" style="width:13px;height:13px;"></i> Testing...`;
lucide.createIcons();
try {
const payload = getBackupFormPayload();
const res = await fetch('/api/admin/backups/test-s3', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify(payload)
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Wasabi S3 connection test failed.');
showBackupStatus(data.message || 'Wasabi S3 connection verified!', 'success');
} catch (err) {
showBackupStatus(`Wasabi S3 Error: ${err.message}`, 'error');
} finally {
btn.disabled = false;
btn.innerHTML = origHtml;
lucide.createIcons();
}
}
async function handleTestGdriveConnection() {
const btn = elements.btnBackupTestGdrive;
const origHtml = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = `<i data-lucide="loader" style="width:13px;height:13px;"></i> Testing...`;
lucide.createIcons();
try {
const payload = getBackupFormPayload();
const res = await fetch('/api/admin/backups/test-gdrive', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify(payload)
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Google Drive connection test failed.');
if (data.folderId && elements.backupGdriveFolderId && !elements.backupGdriveFolderId.value) {
elements.backupGdriveFolderId.value = data.folderId;
}
showBackupStatus(data.message || 'Google Drive API connection verified!', 'success');
} catch (err) {
showBackupStatus(`Google Drive Error: ${err.message}`, 'error');
} finally {
btn.disabled = false;
btn.innerHTML = origHtml;
lucide.createIcons();
}
}
async function handleAuthorizeGoogleDrive() {
try {
const clientId = elements.backupGdriveClientId ? elements.backupGdriveClientId.value.trim() : '';
const clientSecret = elements.backupGdriveClientSecret ? elements.backupGdriveClientSecret.value.trim() : '';
if (!clientId || !clientSecret) {
showBackupStatus('Please enter your Google OAuth2 Client ID and Client Secret first.', 'error');
return;
}
const res = await fetch('/api/admin/backups/gdrive/auth-url', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({
clientId,
clientSecret,
origin: window.location.origin
})
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Failed to generate Google OAuth2 URL.');
window.open(data.authUrl, 'gdrive_oauth', 'width=560,height=680');
} catch (err) {
showBackupStatus(err.message, 'error');
}
}
async function handleRunCloudBackupNow() {
const btn = elements.btnBackupRunCloud;
const origHtml = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = `<i data-lucide="loader" style="width:15px;height:15px;"></i> Backing up to Cloud...`;
lucide.createIcons();
try {
// First save current form config so any newly toggled provider is persisted
const payload = getBackupFormPayload();
await fetch('/api/admin/backups/config', {
method: 'PUT',
headers: getAuthHeaders(),
body: JSON.stringify(payload)
});
const res = await fetch('/api/admin/backups/run', {
method: 'POST',
headers: getAuthHeaders()
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Cloud backup failed.');
showBackupStatus('Cloud backup completed! Temporary local snapshot was wiped after upload.', 'success');
fetchAdminBackupConfig();
} catch (err) {
showBackupStatus(err.message, 'error');
fetchAdminBackupConfig();
} finally {
btn.disabled = false;
btn.innerHTML = origHtml;
lucide.createIcons();
}
}
async function handleDownloadLocalBackup(format) {
const btn = format === 'json' ? elements.btnBackupDownloadJson : elements.btnBackupDownloadDb;
const origHtml = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = `<i data-lucide="loader" style="width:15px;height:15px;"></i> Preparing...`;
lucide.createIcons();
try {
const res = await fetch(`/api/admin/backups/download-local?format=${encodeURIComponent(format)}`, {
headers: getAuthHeaders()
});
if (!res.ok) {
const errData = await res.json().catch(() => ({}));
throw new Error(errData.error || 'Failed to generate local backup download.');
}
const blob = await res.blob();
const disposition = res.headers.get('Content-Disposition') || '';
const match = disposition.match(/filename="?([^"]+)"?/i);
const filename = match ? match[1] : `forkfolio-backup-${new Date().toISOString().replace(/[:.]/g, '-')}.${format === 'json' ? 'json' : 'db'}`;
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
showBackupStatus(`Downloaded ${filename} to your computer (no copy kept on server).`, 'success');
} catch (err) {
showBackupStatus(err.message, 'error');
} finally {
btn.disabled = false;
btn.innerHTML = origHtml;
lucide.createIcons();
}
}
async function downloadCloudBackupItem(id, format, fallbackName) {
try {
showBackupStatus(`Streaming ${format.toUpperCase()} snapshot from cloud storage...`, 'info');
const res = await fetch(`/api/admin/backups/${id}/download-cloud?format=${encodeURIComponent(format)}`, {
headers: getAuthHeaders()
});
if (!res.ok) {
const errData = await res.json().catch(() => ({}));
throw new Error(errData.error || 'Failed to download backup from cloud.');
}
const blob = await res.blob();
const filename = fallbackName || `forkfolio-cloud-backup-${id}.${format}`;
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
showBackupStatus(`Downloaded ${filename} from cloud storage.`, 'success');
} catch (err) {
showBackupStatus(err.message, 'error');
}
}
async function restoreCloudBackupItem(id, dateLabel) {
const confirmed = window.confirm(
`Are you sure you want to restore the entire ForkFolio database from the cloud backup taken on ${dateLabel}?\n\nThis will overwrite current users, recipes, and shopping lists with the snapshot.`
);
if (!confirmed) return;
try {
showBackupStatus('Downloading and restoring database from cloud snapshot...', 'info');
const res = await fetch(`/api/admin/backups/${id}/restore-cloud`, {
method: 'POST',
headers: getAuthHeaders()
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Cloud restore failed.');
showBackupStatus(
`Database restored (${data.restored.userCount} users, ${data.restored.recipeCount} recipes, ${data.restored.shoppingListCount} shopping lists)!`,
'success'
);
await fetchRecipes();
await fetchShoppingLists();
fetchAdminBackupConfig();
} catch (err) {
showBackupStatus(err.message, 'error');
}
}
async function deleteCloudBackupItem(id, dateLabel) {
const confirmed = window.confirm(`Delete the cloud backup from ${dateLabel} from remote storage and history?`);
if (!confirmed) return;
try {
const res = await fetch(`/api/admin/backups/${id}`, {
method: 'DELETE',
headers: getAuthHeaders()
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Failed to delete backup.');
showBackupStatus('Cloud backup deleted.', 'success');
fetchAdminBackupConfig();
} catch (err) {
showBackupStatus(err.message, 'error');
}
}
async function handleRestoreLocalFile(e) {
const file = e.target.files && e.target.files[0];
e.target.value = '';
if (!file) return;
const confirmed = window.confirm(
`Are you sure you want to restore the entire ForkFolio database from "${file.name}"?\n\nThis will replace current users, recipes, images, and shopping lists with the contents of this backup file.`
);
if (!confirmed) return;
try {
showBackupStatus(`Uploading and restoring "${file.name}"...`, 'info');
const base64Data = await new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const result = String(reader.result || '');
const commaIdx = result.indexOf(',');
resolve(commaIdx >= 0 ? result.slice(commaIdx + 1) : result);
};
reader.onerror = () => reject(new Error('Failed to read selected file.'));
reader.readAsDataURL(file);
});
const res = await fetch('/api/admin/backups/restore-upload', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({
filename: file.name,
dataBase64: base64Data
})
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Failed to restore from uploaded file.');
showBackupStatus(
`Restored from "${file.name}" (${data.restored.userCount} users, ${data.restored.recipeCount} recipes, ${data.restored.shoppingListCount} shopping lists)!`,
'success'
);
await fetchRecipes();
await fetchShoppingLists();
fetchAdminBackupConfig();
} catch (err) {
showBackupStatus(err.message, 'error');
}
}