// 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'),
gdriveStatusDot: document.getElementById('gdrive-status-dot'),
gdriveStatusTitle: document.getElementById('gdrive-status-title'),
gdriveStatusSubtitle: document.getElementById('gdrive-status-subtitle'),
btnAuthorizeGdriveLabel: document.getElementById('btn-authorize-gdrive-label'),
btnBackupAuthorizeGdrive: document.getElementById('btn-backup-authorize-gdrive'),
btnBackupDisconnectGdrive: document.getElementById('btn-backup-disconnect-gdrive'),
backupGdriveFolderSelect: document.getElementById('backup-gdrive-folder-select'),
btnGdriveNewFolder: document.getElementById('btn-gdrive-new-folder'),
btnGdriveRefreshFolders: document.getElementById('btn-gdrive-refresh-folders'),
gdriveFolderHint: document.getElementById('gdrive-folder-hint'),
gdriveInlineStatus: document.getElementById('gdrive-inline-status'),
btnBackupTestGdrive: document.getElementById('btn-backup-test-gdrive'),
btnBackupRestoreCloudModal: document.getElementById('btn-backup-restore-cloud-modal'),
btnSyncCloudBackups: document.getElementById('btn-sync-cloud-backups'),
restoreCloudModal: document.getElementById('restore-cloud-modal'),
cloudRestoreProviderFilter: document.getElementById('cloud-restore-provider-filter'),
btnRefreshCloudModal: document.getElementById('btn-refresh-cloud-modal'),
cloudRestoreModalStatus: document.getElementById('cloud-restore-modal-status'),
cloudRestoreFilesContainer: document.getElementById('cloud-restore-files-container'),
restoreScopeModal: document.getElementById('restore-scope-modal'),
restoreScopeBackupSummary: document.getElementById('restore-scope-backup-summary'),
restoreScopeCardAll: document.getElementById('restore-scope-card-all'),
restoreScopeAll: document.getElementById('restore-scope-all'),
restoreScopeAllDesc: document.getElementById('restore-scope-all-desc'),
restoreScopeCardSingle: document.getElementById('restore-scope-card-single'),
restoreScopeSingle: document.getElementById('restore-scope-single'),
restoreTargetUserSelect: document.getElementById('restore-target-user-select'),
restoreScopeStatus: document.getElementById('restore-scope-status'),
btnConfirmScopedRestore: document.getElementById('btn-confirm-scoped-restore'),
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);
if (elements.btnBackupDisconnectGdrive) {
elements.btnBackupDisconnectGdrive.addEventListener('click', handleDisconnectGoogleDrive);
}
if (elements.backupGdriveFolderSelect) {
elements.backupGdriveFolderSelect.addEventListener('change', handleSelectGdriveFolder);
}
if (elements.btnGdriveNewFolder) {
elements.btnGdriveNewFolder.addEventListener('click', handleCreateGdriveFolder);
}
if (elements.btnGdriveRefreshFolders) {
elements.btnGdriveRefreshFolders.addEventListener('click', () => loadGoogleDriveFolders(true));
}
if (elements.btnBackupRestoreCloudModal) {
elements.btnBackupRestoreCloudModal.addEventListener('click', openCloudRestoreModal);
}
if (elements.btnSyncCloudBackups) {
elements.btnSyncCloudBackups.addEventListener('click', handleSyncCloudBackups);
}
if (elements.btnRefreshCloudModal) {
elements.btnRefreshCloudModal.addEventListener('click', () => loadLiveCloudBackupsForModal(true));
}
if (elements.cloudRestoreProviderFilter) {
elements.cloudRestoreProviderFilter.addEventListener('change', () => renderCloudRestoreModalList());
}
if (elements.restoreScopeAll) {
elements.restoreScopeAll.addEventListener('change', updateRestoreScopeModeUI);
}
if (elements.restoreScopeSingle) {
elements.restoreScopeSingle.addEventListener('change', updateRestoreScopeModeUI);
}
if (elements.btnConfirmScopedRestore) {
elements.btnConfirmScopedRestore.addEventListener('click', executeScopedRestore);
}
}
window.addEventListener('message', (event) => {
if (event.data && event.data.type === 'GDRIVE_OAUTH_SUCCESS') {
showGdriveInlineStatus('Google Drive connected! Select your backup destination folder below.', 'success');
showBackupStatus('Google Drive account connected!', '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 = ` `;
}
}
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
? { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }
: { 'Content-Type': 'application/json' };
}
// -------------------------------------------------------------
// 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 = ` Copied! `;
lucide.createIcons();
shareBtnResetTimeout = setTimeout(() => {
elements.btnDetailShare.classList.remove('copied-feedback');
elements.btnDetailShare.innerHTML = ` `;
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 = ` `;
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 = `
No dish photo
`;
} else {
imgBlock = `
No dish photo
`;
}
let creatorBadge = '';
if (activeTab === 'community-vault') {
const creator = recipe.createdBy || 'gourmet_system';
creatorBadge = `
@${creator}
`;
}
card.innerHTML = `
${imgBlock}
${domain}
${recipe.title}
${creatorBadge}
${recipe.description || 'No description available for this recipe.'}
${recipe.servings ? ` ${recipe.servings} Servings ` : ''}
${recipe.totalTime ? ` ${recipe.totalTime} ` : ''}
`;
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 = `
${scaledText}
`;
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 = ` `;
}
// 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 = `
${step.replace('###', '').trim()}
`;
} else {
li.className = 'instruction-step-item';
li.innerHTML = `
${index + 1}
${step}
`;
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 = ' ';
} else {
elements.cookBtnNext.querySelector('span').textContent = 'Next Step';
if (nextBtnIconWrapper) nextBtnIconWrapper.innerHTML = ' ';
}
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 = ' ';
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 = ' ';
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
? ' Admin '
: ' Chef ';
if (user.isRecipeManager) {
roleBadge += ' Manager ';
}
const dateStr = new Date(user.createdAt).toLocaleDateString(undefined, {
year: 'numeric',
month: 'long',
day: 'numeric'
});
const isSelf = user.id === currentUser.id;
const actionCell = isSelf
? `Active Session `
: `
${user.isAdmin
? ` Demote to Chef `
: ` Promote to Admin `
}
${user.isRecipeManager
? ` Revoke Manager `
: ` Grant Manager `
}
Delete
`;
tr.innerHTML = `
@${user.username}
${roleBadge}
${dateStr}
${actionCell}
`;
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 = `
No recipes are currently shared publicly.
`;
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 = `
${recipe.title}
@${recipe.createdBy}
${dateStr}
${recipe.visitorCount} Unique ${recipe.visitorCount === 1 ? 'Visitor' : 'Visitors'}
Copy Link
Revoke
`;
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 = ` Copied!`;
copyBtn.style.borderColor = 'var(--accent)';
copyBtn.style.color = 'var(--accent)';
lucide.createIcons();
rowCopyTimeout = setTimeout(() => {
copyBtn.innerHTML = ` 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 = ` Added (${selectedLines.length})! `;
elements.btnAddRecipeToList.style.backgroundColor = 'var(--accent)';
elements.btnAddRecipeToList.style.borderColor = 'var(--accent)';
lucide.createIcons();
addRecipeBtnTimeout = setTimeout(() => {
elements.btnAddRecipeToList.innerHTML = ` Add to List `;
elements.btnAddRecipeToList.style.backgroundColor = '';
elements.btnAddRecipeToList.style.borderColor = '';
lucide.createIcons();
}, 2000);
}
} catch (error) {
console.error('Error adding recipe to shopping list:', error);
alert('Failed to add ingredients to shopping list.');
}
}
async function handleCreateShoppingList() {
const name = prompt('Enter a name for your new shopping list:', '');
if (!name || !name.trim()) return;
try {
const response = await fetch('/api/shopping-lists', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...getAuthHeaders()
},
body: JSON.stringify({ name: name.trim() })
});
if (response.status === 401) {
logout();
return;
}
if (!response.ok) throw new Error('Failed to create shopping list.');
const created = await response.json();
updateShoppingListInState(created);
} catch (err) {
console.error('Error creating shopping list:', err);
alert('Could not create shopping list.');
}
}
async function handleRenameShoppingList() {
const activeList = getActiveShoppingList();
if (!activeList) return;
const newName = prompt('Rename shopping list:', activeList.name);
if (!newName || !newName.trim() || newName.trim() === activeList.name) return;
try {
const response = await fetch(`/api/shopping-lists/${activeList.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
...getAuthHeaders()
},
body: JSON.stringify({ name: newName.trim() })
});
if (response.status === 401) {
logout();
return;
}
if (!response.ok) throw new Error('Failed to rename shopping list.');
const updated = await response.json();
updateShoppingListInState(updated);
} catch (err) {
console.error('Error renaming shopping list:', err);
alert('Could not rename shopping list.');
}
}
async function handleDeleteShoppingList() {
const activeList = getActiveShoppingList();
if (!activeList) return;
if (!confirm(`Are you sure you want to delete the shopping list "${activeList.name}" and all its items?`)) return;
try {
const response = await fetch(`/api/shopping-lists/${activeList.id}`, {
method: 'DELETE',
headers: getAuthHeaders()
});
if (response.status === 401) {
logout();
return;
}
if (!response.ok) throw new Error('Failed to delete shopping list.');
activeShoppingListId = null;
await fetchShoppingLists();
} catch (err) {
console.error('Error deleting shopping list:', err);
alert('Could not delete shopping list.');
}
}
async function handleAddManualShoppingItem(e) {
e.preventDefault();
const activeList = getActiveShoppingList();
if (!activeList) return;
const text = elements.shoppingAddItemInput.value.trim();
if (!text) return;
try {
const response = await fetch(`/api/shopping-lists/${activeList.id}/items`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...getAuthHeaders()
},
body: JSON.stringify({ items: [text] })
});
if (response.status === 401) {
logout();
return;
}
if (!response.ok) throw new Error('Failed to add item.');
const updated = await response.json();
elements.shoppingAddItemInput.value = '';
updateShoppingListInState(updated);
} catch (err) {
console.error('Error adding manual item:', err);
alert('Could not add item.');
}
}
async function handleToggleShoppingItems(itemIds, checked) {
const activeList = getActiveShoppingList();
if (!activeList || !Array.isArray(itemIds) || itemIds.length === 0) return;
try {
const response = await fetch(`/api/shopping-lists/${activeList.id}/items/check`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
...getAuthHeaders()
},
body: JSON.stringify({ itemIds, checked })
});
if (response.status === 401) {
logout();
return;
}
if (!response.ok) throw new Error('Failed to toggle item.');
const updated = await response.json();
updateShoppingListInState(updated);
} catch (err) {
console.error('Error toggling shopping item:', err);
}
}
async function handleDeleteShoppingItems(itemIds) {
const activeList = getActiveShoppingList();
if (!activeList || !Array.isArray(itemIds) || itemIds.length === 0) return;
try {
const response = await fetch(`/api/shopping-lists/${activeList.id}/items/delete`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...getAuthHeaders()
},
body: JSON.stringify({ itemIds })
});
if (response.status === 401) {
logout();
return;
}
if (!response.ok) throw new Error('Failed to delete item.');
const updated = await response.json();
updateShoppingListInState(updated);
} catch (err) {
console.error('Error deleting shopping item:', err);
}
}
async function handleClearShoppingList(onlyChecked) {
const activeList = getActiveShoppingList();
if (!activeList) return;
if (!onlyChecked && !confirm(`Are you sure you want to clear all items from "${activeList.name}"?`)) return;
try {
const response = await fetch(`/api/shopping-lists/${activeList.id}/clear`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...getAuthHeaders()
},
body: JSON.stringify({ onlyChecked })
});
if (response.status === 401) {
logout();
return;
}
if (!response.ok) throw new Error('Failed to clear items.');
const updated = await response.json();
updateShoppingListInState(updated);
} catch (err) {
console.error('Error clearing shopping list:', err);
}
}
let shoppingShareBtnTimeout = null;
let shoppingCopyTextTimeout = null;
async function handleShareShoppingList() {
const activeList = getActiveShoppingList();
if (!activeList) return;
try {
const response = await fetch(`/api/shopping-lists/${activeList.id}/share`, {
method: 'POST',
headers: getAuthHeaders()
});
if (response.status === 401) {
logout();
return;
}
if (!response.ok) throw new Error('Failed to generate share link.');
const updated = await response.json();
updateShoppingListInState(updated);
const shareUrl = `${window.location.origin}/share-list.html?token=${updated.shareToken}`;
await copyTextToClipboard(shareUrl);
if (elements.btnShoppingShare) {
if (shoppingShareBtnTimeout) clearTimeout(shoppingShareBtnTimeout);
elements.btnShoppingShare.innerHTML = ` Copied! `;
elements.btnShoppingShare.style.borderColor = 'var(--accent)';
elements.btnShoppingShare.style.color = 'var(--accent)';
lucide.createIcons();
shoppingShareBtnTimeout = setTimeout(() => {
elements.btnShoppingShare.innerHTML = ` Share Link `;
elements.btnShoppingShare.style.borderColor = '';
elements.btnShoppingShare.style.color = '';
lucide.createIcons();
}, 2000);
}
} catch (err) {
console.error('Error sharing shopping list:', err);
alert('Failed to generate or copy share link.');
}
}
async function handleRevokeShoppingListShare() {
const activeList = getActiveShoppingList();
if (!activeList) return;
if (!confirm(`Disable the public share link for "${activeList.name}" and reset visitor stats?`)) return;
try {
const response = await fetch(`/api/shopping-lists/${activeList.id}/revoke-share`, {
method: 'POST',
headers: getAuthHeaders()
});
if (response.status === 401) {
logout();
return;
}
if (!response.ok) throw new Error('Failed to revoke share link.');
const updated = await response.json();
updateShoppingListInState(updated);
} catch (err) {
console.error('Error revoking shopping list share:', err);
}
}
async function handleCopyShoppingListText() {
const activeList = getActiveShoppingList();
if (!activeList || !Array.isArray(activeList.items) || activeList.items.length === 0) return;
const displayEntries = buildShoppingListDisplayEntries(activeList.items, consolidateItems);
const grouped = groupShoppingEntries(displayEntries, shoppingGroupBy);
const lines = [`🛒 ${activeList.name}`];
for (const [groupName, entries] of grouped.entries()) {
lines.push(`\n[${groupName}]`);
entries.forEach(entry => {
const box = entry.checked ? '[x]' : '[ ]';
lines.push(`${box} ${entry.displayText}`);
});
}
try {
await copyTextToClipboard(lines.join('\n'));
if (elements.btnShoppingCopyText) {
if (shoppingCopyTextTimeout) clearTimeout(shoppingCopyTextTimeout);
elements.btnShoppingCopyText.innerHTML = ` Copied! `;
elements.btnShoppingCopyText.style.borderColor = 'var(--accent)';
elements.btnShoppingCopyText.style.color = 'var(--accent)';
lucide.createIcons();
shoppingCopyTextTimeout = setTimeout(() => {
elements.btnShoppingCopyText.innerHTML = ` Copy Text `;
elements.btnShoppingCopyText.style.borderColor = '';
elements.btnShoppingCopyText.style.color = '';
lucide.createIcons();
}, 2000);
}
} catch (err) {
console.error('Error copying shopping list text:', err);
}
}
function buildShoppingListDisplayEntries(items, shouldConsolidate) {
if (!shouldConsolidate) {
return items.map(item => ({
itemIds: [item.id],
displayText: item.rawText,
category: item.category || 'Other',
sources: item.recipeTitle ? [item.recipeTitle] : [],
checked: !!item.checked
}));
}
// Group items by normalized itemName + checked state + unit so compatible items sum cleanly
const groups = new Map();
for (const item of items) {
const normName = (item.itemName || item.rawText || '').toLowerCase().trim();
const normUnit = (item.unit || '').toLowerCase().trim();
const key = `${item.checked ? '1' : '0'}::${normName}::${normUnit}`;
if (!groups.has(key)) {
groups.set(key, {
itemIds: [item.id],
rawTexts: [item.rawText],
itemName: normName,
unit: normUnit,
totalQty: item.quantity !== null && item.quantity !== undefined ? Number(item.quantity) : null,
hasNullQty: item.quantity === null || item.quantity === undefined,
category: item.category || 'Other',
sources: item.recipeTitle ? [item.recipeTitle] : [],
checked: !!item.checked
});
} else {
const g = groups.get(key);
g.itemIds.push(item.id);
g.rawTexts.push(item.rawText);
if (item.quantity !== null && item.quantity !== undefined && !g.hasNullQty) {
g.totalQty = (g.totalQty || 0) + Number(item.quantity);
} else {
g.hasNullQty = true;
}
if (item.recipeTitle && !g.sources.includes(item.recipeTitle)) {
g.sources.push(item.recipeTitle);
}
}
}
const results = [];
for (const g of groups.values()) {
let displayText = g.rawTexts[0];
if (g.itemIds.length > 1) {
if (!g.hasNullQty && g.totalQty !== null && g.totalQty > 0) {
const qtyStr = formatScaledNumber(g.totalQty);
const unitStr = g.unit ? (g.totalQty > 1 && !g.unit.endsWith('s') && ['clove', 'can', 'jar', 'bunch', 'slice', 'piece', 'head', 'stalk', 'sprig', 'stick', 'cup'].includes(g.unit) ? `${g.unit}s ` : `${g.unit} `) : '';
const displayItem = g.itemName.charAt(0).toUpperCase() + g.itemName.slice(1);
displayText = `${qtyStr} ${unitStr}${displayItem}`;
} else {
const displayItem = g.itemName.charAt(0).toUpperCase() + g.itemName.slice(1);
displayText = `${displayItem} (×${g.itemIds.length})`;
}
}
results.push({
itemIds: g.itemIds,
displayText,
category: g.category,
sources: g.sources,
checked: g.checked
});
}
return results;
}
function groupShoppingEntries(entries, mode) {
const grouped = new Map();
if (mode === 'recipe') {
for (const entry of entries) {
const groupLabels = entry.sources.length > 0 ? [entry.sources.join(' + ')] : ['Manual / Household Staples'];
for (const label of groupLabels) {
if (!grouped.has(label)) grouped.set(label, []);
grouped.get(label).push(entry);
}
}
return grouped;
}
// Default: Group by Grocery Aisle in store walk-through order
for (const aisle of AISLE_ORDER) {
const matching = entries.filter(e => (e.category || 'Other') === aisle);
if (matching.length > 0) {
// Sort unchecked first
matching.sort((a, b) => Number(a.checked) - Number(b.checked));
grouped.set(aisle, matching);
}
}
return grouped;
}
function renderShoppingListsWorkspace() {
if (!elements.shoppingListSelect) return;
// Populate select options
elements.shoppingListSelect.innerHTML = '';
shoppingLists.forEach(list => {
const opt = document.createElement('option');
opt.value = list.id;
const unchecked = Array.isArray(list.items) ? list.items.filter(i => !i.checked).length : 0;
opt.textContent = `${list.name} (${unchecked})`;
if (list.id === activeShoppingListId) opt.selected = true;
elements.shoppingListSelect.appendChild(opt);
});
updateShoppingTabBadges();
const activeList = getActiveShoppingList();
if (!activeList) {
elements.shoppingListsContent.innerHTML = '';
elements.shoppingEmptyState.style.display = 'flex';
return;
}
// Share status UI
if (activeList.shareToken) {
elements.btnShoppingRevokeShare.style.display = 'inline-flex';
elements.shoppingShareStatsBadge.style.display = 'inline-flex';
const vCount = activeList.visitorCount || 0;
elements.shoppingShareStatsBadge.querySelector('.val').textContent = `${vCount} Unique ${vCount === 1 ? 'Visitor' : 'Visitors'}`;
} else {
elements.btnShoppingRevokeShare.style.display = 'none';
elements.shoppingShareStatsBadge.style.display = 'none';
}
const items = activeList.items || [];
if (items.length === 0) {
elements.shoppingListsContent.innerHTML = '';
elements.shoppingEmptyState.style.display = 'flex';
lucide.createIcons();
return;
}
elements.shoppingEmptyState.style.display = 'none';
elements.shoppingListsContent.innerHTML = '';
const entries = buildShoppingListDisplayEntries(items, consolidateItems);
const grouped = groupShoppingEntries(entries, shoppingGroupBy);
for (const [groupName, groupEntries] of grouped.entries()) {
const card = document.createElement('div');
card.className = 'shopping-category-card';
const iconName = AISLE_ICONS[groupName] || 'utensils';
const uncheckedInGroup = groupEntries.filter(e => !e.checked).length;
card.innerHTML = `
`;
const ul = card.querySelector('.shopping-items-list');
groupEntries.forEach(entry => {
const li = document.createElement('li');
li.className = `shopping-item-row ${entry.checked ? 'checked' : ''}`;
const sourcesHtml = entry.sources.length > 0
? `${entry.sources.map(s => `${s} `).join('')}
`
: '';
li.innerHTML = `
${entry.displayText}
${sourcesHtml}
`;
li.querySelector('.shopping-item-main').addEventListener('click', () => {
handleToggleShoppingItems(entry.itemIds, !entry.checked);
});
li.querySelector('.shopping-item-delete').addEventListener('click', (e) => {
e.stopPropagation();
handleDeleteShoppingItems(entry.itemIds);
});
ul.appendChild(li);
});
elements.shoppingListsContent.appendChild(card);
}
lucide.createIcons();
}
// ==========================================
// ADMIN DATABASE BACKUPS & RECOVERY CONSOLE
// ==========================================
let backupStatusTimeout = null;
let gdriveInlineTimeout = null;
let gdriveAuthPollTimer = null;
function showBackupStatus(message, type = 'info') {
const banner = elements.backupStatusBanner;
if (!banner) return;
if (backupStatusTimeout) clearTimeout(backupStatusTimeout);
banner.className = `backup-status-alert status-${type}`;
const icon = type === 'success' ? 'check-circle-2' : type === 'error' ? 'alert-triangle' : 'info';
banner.innerHTML = `${message} `;
banner.style.display = 'flex';
lucide.createIcons();
if (type !== 'error') {
backupStatusTimeout = setTimeout(() => {
banner.style.display = 'none';
}, 8000);
}
}
function showGdriveInlineStatus(message, type = 'info') {
const box = elements.gdriveInlineStatus;
if (!box) return;
if (gdriveInlineTimeout) clearTimeout(gdriveInlineTimeout);
box.classList.remove('hidden');
if (type === 'success') {
box.style.background = 'rgba(16, 185, 129, 0.12)';
box.style.border = '1px solid rgba(16, 185, 129, 0.35)';
box.style.color = '#10b981';
} else if (type === 'error') {
box.style.background = 'rgba(239, 68, 68, 0.12)';
box.style.border = '1px solid rgba(239, 68, 68, 0.35)';
box.style.color = '#ef4444';
} else {
box.style.background = 'rgba(59, 130, 246, 0.12)';
box.style.border = '1px solid rgba(59, 130, 246, 0.35)';
box.style.color = '#3b82f6';
}
box.textContent = message;
if (type !== 'error') {
gdriveInlineTimeout = setTimeout(() => {
box.classList.add('hidden');
}, 7000);
}
}
function getBackupFormPayload() {
const selectedOpt = elements.backupGdriveFolderSelect && elements.backupGdriveFolderSelect.selectedOptions[0];
return {
scheduleFrequency: elements.backupScheduleFrequency ? elements.backupScheduleFrequency.value : 'off',
scheduleTime: elements.backupScheduleTime ? elements.backupScheduleTime.value : '02:00',
scheduleDay: elements.backupScheduleDay ? Number(elements.backupScheduleDay.value) || 0 : 0,
retentionCount: elements.backupRetentionCount ? Number(elements.backupRetentionCount.value) : 14,
backupFormat: elements.backupFormat ? elements.backupFormat.value : 'both',
embedRemoteImages: elements.backupEmbedImages ? elements.backupEmbedImages.checked : true,
s3Enabled: elements.backupS3Enabled ? elements.backupS3Enabled.checked : false,
s3Endpoint: elements.backupS3Endpoint ? elements.backupS3Endpoint.value.trim() : 'https://s3.us-east-1.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,
gdriveFolderId: elements.backupGdriveFolderSelect && !elements.backupGdriveFolderSelect.disabled ? elements.backupGdriveFolderSelect.value : undefined,
gdriveFolderName: selectedOpt && !elements.backupGdriveFolderSelect.disabled ? selectedOpt.dataset.folderName || selectedOpt.textContent : undefined
};
}
let lastGdriveFoldersFetchAt = 0;
function renderGdriveConnectionState(cfg) {
const isConnected = Boolean(cfg.gdriveConnected);
if (elements.gdriveStatusDot) {
elements.gdriveStatusDot.style.background = isConnected ? '#10b981' : 'var(--text-muted)';
elements.gdriveStatusDot.style.boxShadow = isConnected ? '0 0 8px rgba(16, 185, 129, 0.6)' : 'none';
}
if (elements.gdriveStatusTitle) {
elements.gdriveStatusTitle.textContent = isConnected
? `Connected to Google Drive${cfg.gdriveAccountEmail ? ` (${cfg.gdriveAccountEmail})` : ''}`
: 'Not Connected to Google Drive';
elements.gdriveStatusTitle.style.color = isConnected ? '#10b981' : 'var(--text-main)';
}
if (elements.gdriveStatusSubtitle) {
elements.gdriveStatusSubtitle.textContent = isConnected
? `Destination folder: "${cfg.gdriveFolderName || 'ForkFolio Backups'}"`
: 'Click Authorize with Google to link your account in one click.';
}
if (elements.btnAuthorizeGdriveLabel) {
elements.btnAuthorizeGdriveLabel.textContent = isConnected ? 'Switch Account' : 'Authorize with Google';
}
if (elements.btnBackupAuthorizeGdrive) {
if (isConnected) {
elements.btnBackupAuthorizeGdrive.classList.remove('btn-primary');
elements.btnBackupAuthorizeGdrive.classList.add('btn-outline');
} else {
elements.btnBackupAuthorizeGdrive.classList.add('btn-primary');
elements.btnBackupAuthorizeGdrive.classList.remove('btn-outline');
}
}
if (elements.btnBackupDisconnectGdrive) {
elements.btnBackupDisconnectGdrive.classList.toggle('hidden', !isConnected);
}
if (elements.backupGdriveFolderSelect) {
elements.backupGdriveFolderSelect.disabled = !isConnected;
if (!isConnected) {
lastGdriveFoldersFetchAt = 0;
elements.backupGdriveFolderSelect.innerHTML = `Authorize with Google first to select a folder... `;
} else if (cfg.gdriveFolderId && (!elements.backupGdriveFolderSelect.value || elements.backupGdriveFolderSelect.options.length <= 1)) {
elements.backupGdriveFolderSelect.innerHTML = '';
const opt = document.createElement('option');
opt.value = cfg.gdriveFolderId;
opt.dataset.folderName = cfg.gdriveFolderName || 'ForkFolio Backups';
opt.textContent = `📁 ${cfg.gdriveFolderName || 'ForkFolio Backups'}`;
opt.selected = true;
elements.backupGdriveFolderSelect.appendChild(opt);
}
}
if (elements.btnGdriveNewFolder) {
elements.btnGdriveNewFolder.disabled = !isConnected;
}
if (elements.btnGdriveRefreshFolders) {
elements.btnGdriveRefreshFolders.disabled = !isConnected;
}
if (isConnected && Date.now() - lastGdriveFoldersFetchAt > 60 * 1000) {
loadGoogleDriveFolders(false, cfg.gdriveFolderId, cfg.gdriveFolderName);
}
}
async function loadGoogleDriveFolders(showFeedback = false, fallbackId = '', fallbackName = 'ForkFolio Backups') {
if (!elements.backupGdriveFolderSelect) return;
try {
lastGdriveFoldersFetchAt = Date.now();
if (showFeedback) {
showGdriveInlineStatus('Loading folders from your Google Drive...', 'info');
}
const res = await fetch('/api/admin/backups/gdrive/folders', {
headers: getAuthHeaders()
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Failed to load Google Drive folders.');
const folders = Array.isArray(data.folders) ? data.folders : [];
const activeId = data.currentFolderId || fallbackId;
const activeName = data.currentFolderName || fallbackName || 'ForkFolio Backups';
if (data.accountEmail && elements.gdriveStatusTitle) {
elements.gdriveStatusTitle.textContent = `Connected to Google Drive (${data.accountEmail})`;
}
elements.backupGdriveFolderSelect.innerHTML = '';
// Ensure the active folder is present in the list
const hasActive = folders.some(f => f.id === activeId);
if (activeId && !hasActive) {
folders.unshift({ id: activeId, name: activeName });
}
folders.forEach(folder => {
const opt = document.createElement('option');
opt.value = folder.id;
opt.dataset.folderName = folder.name;
opt.textContent = `📁 ${folder.name}`;
if (folder.id === activeId) {
opt.selected = true;
}
elements.backupGdriveFolderSelect.appendChild(opt);
});
if (elements.gdriveStatusSubtitle) {
const selOpt = elements.backupGdriveFolderSelect.selectedOptions[0];
const dispName = selOpt ? (selOpt.dataset.folderName || activeName) : activeName;
elements.gdriveStatusSubtitle.textContent = `Destination folder: "${dispName}"`;
}
if (showFeedback) {
showGdriveInlineStatus(`Loaded ${folders.length} folder(s) from Google Drive.`, 'success');
}
} catch (err) {
console.warn('Error loading Google Drive folders:', err);
if (showFeedback) {
showGdriveInlineStatus(err.message, 'error');
}
}
}
async function handleSelectGdriveFolder() {
if (!elements.backupGdriveFolderSelect) return;
const folderId = elements.backupGdriveFolderSelect.value;
const selectedOpt = elements.backupGdriveFolderSelect.selectedOptions[0];
const folderName = selectedOpt ? (selectedOpt.dataset.folderName || selectedOpt.textContent.replace(/^📁\s*/, '')) : 'ForkFolio Backups';
if (!folderId) return;
try {
const res = await fetch('/api/admin/backups/gdrive/select-folder', {
method: 'PUT',
headers: getAuthHeaders(),
body: JSON.stringify({ folderId, folderName })
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Failed to save selected folder.');
if (elements.backupGdriveEnabled) elements.backupGdriveEnabled.checked = true;
if (elements.gdriveStatusSubtitle) {
elements.gdriveStatusSubtitle.textContent = `Destination folder: "${folderName}"`;
}
showGdriveInlineStatus(`✓ Backups will now be saved to "${folderName}" in Google Drive.`, 'success');
} catch (err) {
showGdriveInlineStatus(err.message, 'error');
}
}
async function handleCreateGdriveFolder() {
const folderName = window.prompt('Enter a name for the new Google Drive backup folder:', 'ForkFolio Backups');
if (!folderName || !folderName.trim()) return;
const btn = elements.btnGdriveNewFolder;
const origHtml = btn.innerHTML;
btn.disabled = true;
try {
showGdriveInlineStatus(`Creating folder "${folderName.trim()}" in Google Drive...`, 'info');
const res = await fetch('/api/admin/backups/gdrive/folders', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ name: folderName.trim() })
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Failed to create folder.');
await loadGoogleDriveFolders(false, data.folder.id, data.folder.name);
if (elements.backupGdriveEnabled) elements.backupGdriveEnabled.checked = true;
showGdriveInlineStatus(`✓ Created and selected folder "${data.folder.name}"!`, 'success');
} catch (err) {
showGdriveInlineStatus(err.message, 'error');
} finally {
btn.disabled = false;
btn.innerHTML = origHtml;
lucide.createIcons();
}
}
async function handleDisconnectGoogleDrive() {
const confirmed = window.confirm('Disconnect your Google Drive account from ForkFolio?');
if (!confirmed) return;
try {
const res = await fetch('/api/admin/backups/gdrive/disconnect', {
method: 'POST',
headers: getAuthHeaders()
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Failed to disconnect Google Drive.');
showGdriveInlineStatus('Google Drive account disconnected.', 'info');
fetchAdminBackupConfig();
} catch (err) {
showGdriveInlineStatus(err.message, 'error');
}
}
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.backupScheduleFrequency) elements.backupScheduleFrequency.value = cfg.scheduleFrequency || cfg.schedule || 'off';
if (elements.backupScheduleTime) elements.backupScheduleTime.value = cfg.scheduleTime || '02:00';
if (elements.backupScheduleDay) elements.backupScheduleDay.value = String(cfg.scheduleDay !== undefined ? cfg.scheduleDay : 0);
if (elements.backupRetentionCount) elements.backupRetentionCount.value = String(cfg.retentionCount !== undefined ? cfg.retentionCount : 14);
if (elements.backupFormat) elements.backupFormat.value = cfg.backupFormat || 'both';
if (elements.backupEmbedImages) elements.backupEmbedImages.checked = Boolean(cfg.embedRemoteImages);
if (elements.backupS3Enabled) elements.backupS3Enabled.checked = Boolean(cfg.s3Enabled);
if (elements.backupS3Endpoint) elements.backupS3Endpoint.value = cfg.s3Endpoint || 'https://s3.us-east-1.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);
renderGdriveConnectionState(cfg);
if (elements.backupLastRunBadge) {
elements.backupLastRunBadge.style.display = 'inline-flex';
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 = 'Never run';
}
}
renderAdminBackupHistory(data.history || []);
// Automatically scan cloud storage if connected and local history is empty
if ((!data.history || data.history.length === 0) && (cfg.gdriveConnected || cfg.s3Enabled)) {
handleSyncCloudBackups(true);
}
} 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 = `
No cloud backups found yet. Click "Backup to Cloud Now" to create one, or "Sync from Cloud" / "Restore from Cloud" to scan Google Drive & Wasabi S3.
`;
return;
}
history.forEach(item => {
const tr = document.createElement('tr');
const dateStr = new Date(item.createdAt).toLocaleString();
const triggerBadge = item.triggerType === 'scheduled'
? `Scheduled `
: item.triggerType === 'cloud'
? `Cloud Sync `
: `Manual `;
const destBadges = [];
if (item.s3KeyDb || item.s3KeyJson) {
destBadges.push(`Wasabi S3 `);
}
if (item.gdriveFileIdDb || item.gdriveFileIdJson) {
destBadges.push(`Google Drive `);
}
const statusBadge = item.status === 'success'
? `✓ Available `
: `⚠ Error `;
const hasDbFile = Boolean(item.s3KeyDb || item.gdriveFileIdDb);
const hasJsonFile = Boolean(item.s3KeyJson || item.gdriveFileIdJson);
const hasCloudFiles = hasDbFile || hasJsonFile;
const countsText = (item.userCount || item.recipeCount || item.shoppingListCount)
? `${item.userCount} users · ${item.recipeCount} recipes · ${item.shoppingListCount} lists`
: `${item.label || 'Cloud Snapshot'} `;
tr.innerHTML = `
${dateStr}
${triggerBadge}
${countsText}
${hasDbFile ? `DB: ${formatBytesHuman(item.dbSizeBytes)}` : ''}${hasDbFile && hasJsonFile ? ' ' : ''}${hasJsonFile ? `JSON: ${formatBytesHuman(item.jsonSizeBytes)}` : ''}
${destBadges.join('') || 'None '}
${statusBadge}${item.errorMessage ? `${item.errorMessage}
` : ''}
${hasDbFile ? `
.DB
` : ''}
${hasJsonFile ? `
.JSON
` : ''}
${hasCloudFiles ? `
Restore
` : ''}
`;
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 = ` 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 = ` 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.');
showGdriveInlineStatus(data.message || 'Google Drive connection verified!', 'success');
showBackupStatus(data.message || 'Google Drive connection verified!', 'success');
} catch (err) {
showGdriveInlineStatus(`Google Drive Error: ${err.message}`, 'error');
showBackupStatus(`Google Drive Error: ${err.message}`, 'error');
} finally {
btn.disabled = false;
btn.innerHTML = origHtml;
lucide.createIcons();
}
}
async function handleAuthorizeGoogleDrive() {
// Open popup synchronously inside user click gesture so browsers never block it
const popup = window.open('about:blank', 'gdrive_oauth', 'width=560,height=680');
if (popup) {
popup.document.write('Connecting to Google Drive... ');
}
try {
showGdriveInlineStatus('Opening Google sign-in window... Approve access to connect your Drive.', 'info');
const res = await fetch('/api/admin/backups/gdrive/auth-url', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({
origin: window.location.origin
})
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Failed to generate Google OAuth2 URL.');
if (popup && !popup.closed) {
popup.location.href = data.authUrl;
} else {
window.open(data.authUrl, '_blank');
}
// Poll backup config while popup is open so UI updates immediately once approved
if (gdriveAuthPollTimer) clearInterval(gdriveAuthPollTimer);
let attempts = 0;
gdriveAuthPollTimer = setInterval(async () => {
attempts++;
if ((popup && popup.closed) || attempts > 90) {
clearInterval(gdriveAuthPollTimer);
gdriveAuthPollTimer = null;
await fetchAdminBackupConfig();
return;
}
try {
const pollRes = await fetch('/api/admin/backups/config', { headers: getAuthHeaders() });
if (pollRes.ok) {
const pollData = await pollRes.json();
if (pollData.config && pollData.config.gdriveConnected) {
clearInterval(gdriveAuthPollTimer);
gdriveAuthPollTimer = null;
showGdriveInlineStatus('✓ Google Drive connected! Select your backup folder below.', 'success');
await fetchAdminBackupConfig();
}
}
} catch (e) {}
}, 1500);
} catch (err) {
if (popup && !popup.closed) popup.close();
showGdriveInlineStatus(err.message, 'error');
showBackupStatus(err.message, 'error');
}
}
async function handleRunCloudBackupNow() {
const btn = elements.btnBackupRunCloud;
const origHtml = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = ` 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 = ` 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) {
openScopedRestoreModal({
mode: 'history',
historyId: id,
format: 'db',
titleLabel: `Cloud Backup (${dateLabel})`
});
}
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;
try {
showBackupStatus(`Reading "${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);
});
openScopedRestoreModal({
mode: 'upload',
filename: file.name,
base64Data,
titleLabel: `Local File: "${file.name}"`
});
} catch (err) {
showBackupStatus(err.message, 'error');
}
}
// ==========================================
// LIVE CLOUD BACKUP BROWSER & RESTORE MODAL
// ==========================================
let cachedCloudBackups = [];
let activeRestoreScopeContext = null;
function showCloudRestoreModalStatus(message, type = 'info') {
const box = elements.cloudRestoreModalStatus;
if (!box) return;
box.classList.remove('hidden');
if (type === 'success') {
box.style.background = 'rgba(16, 185, 129, 0.12)';
box.style.border = '1px solid rgba(16, 185, 129, 0.35)';
box.style.color = '#10b981';
} else if (type === 'error') {
box.style.background = 'rgba(239, 68, 68, 0.12)';
box.style.border = '1px solid rgba(239, 68, 68, 0.35)';
box.style.color = '#ef4444';
} else {
box.style.background = 'rgba(59, 130, 246, 0.12)';
box.style.border = '1px solid rgba(59, 130, 246, 0.35)';
box.style.color = '#3b82f6';
}
box.textContent = message;
}
function showRestoreScopeStatus(message, type = 'info') {
const box = elements.restoreScopeStatus;
if (!box) return;
box.classList.remove('hidden');
if (type === 'success') {
box.style.background = 'rgba(16, 185, 129, 0.12)';
box.style.border = '1px solid rgba(16, 185, 129, 0.35)';
box.style.color = '#10b981';
} else if (type === 'error') {
box.style.background = 'rgba(239, 68, 68, 0.12)';
box.style.border = '1px solid rgba(239, 68, 68, 0.35)';
box.style.color = '#ef4444';
} else {
box.style.background = 'rgba(59, 130, 246, 0.12)';
box.style.border = '1px solid rgba(59, 130, 246, 0.35)';
box.style.color = '#3b82f6';
}
box.textContent = message;
}
function updateRestoreScopeModeUI() {
const isSingle = elements.restoreScopeSingle && elements.restoreScopeSingle.checked;
if (elements.restoreScopeCardAll) {
elements.restoreScopeCardAll.style.borderColor = isSingle ? 'var(--border)' : 'var(--accent)';
}
if (elements.restoreScopeCardSingle) {
elements.restoreScopeCardSingle.style.borderColor = isSingle ? 'var(--accent)' : 'var(--border)';
}
if (elements.restoreTargetUserSelect) {
elements.restoreTargetUserSelect.disabled = !isSingle;
}
}
async function openScopedRestoreModal(context) {
activeRestoreScopeContext = context;
if (elements.restoreScopeStatus) {
elements.restoreScopeStatus.classList.add('hidden');
}
if (elements.restoreScopeAll) elements.restoreScopeAll.checked = true;
if (elements.restoreScopeSingle) elements.restoreScopeSingle.checked = false;
updateRestoreScopeModeUI();
if (elements.restoreScopeBackupSummary) {
elements.restoreScopeBackupSummary.innerHTML = `Source: ${context.titleLabel}Inspecting users and recipes inside backup... `;
}
if (elements.restoreTargetUserSelect) {
elements.restoreTargetUserSelect.innerHTML = `Loading users from backup... `;
}
openModal('restore-scope-modal');
lucide.createIcons();
try {
const inspectBody = {};
if (context.mode === 'upload') {
inspectBody.filename = context.filename;
inspectBody.base64Data = context.base64Data;
} else if (context.mode === 'direct') {
inspectBody.provider = context.provider;
inspectBody.fileId = context.fileId;
inspectBody.objectKey = context.objectKey;
inspectBody.filename = context.filename;
} else if (context.mode === 'history') {
inspectBody.historyId = context.historyId;
inspectBody.format = context.format || 'db';
}
const res = await fetch('/api/admin/backups/inspect', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify(inspectBody)
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Failed to inspect backup contents.');
if (elements.restoreScopeBackupSummary) {
elements.restoreScopeBackupSummary.innerHTML = `
${context.titleLabel}
Contains ${data.totalUsers} users · ${data.totalRecipes} recipes · ${data.totalShoppingLists} shopping lists
`;
}
if (elements.restoreScopeAllDesc) {
elements.restoreScopeAllDesc.textContent = `Replaces all users (${data.totalUsers}), recipes (${data.totalRecipes}), images, and shopping lists (${data.totalShoppingLists}) with this backup.`;
}
if (elements.restoreTargetUserSelect) {
elements.restoreTargetUserSelect.innerHTML = '';
const users = Array.isArray(data.users) ? data.users : [];
if (users.length === 0) {
elements.restoreTargetUserSelect.innerHTML = `No individual users found in backup `;
} else {
users.forEach(u => {
const opt = document.createElement('option');
opt.value = u.username;
const roleLabel = u.isAdmin ? ' (Admin)' : u.isRecipeManager ? ' (Manager)' : '';
opt.textContent = `@${u.username}${roleLabel} — ${u.recipeCount} ${u.recipeCount === 1 ? 'recipe' : 'recipes'}, ${u.shoppingListCount} ${u.shoppingListCount === 1 ? 'shopping list' : 'shopping lists'}`;
elements.restoreTargetUserSelect.appendChild(opt);
});
}
}
} catch (err) {
if (elements.restoreScopeBackupSummary) {
elements.restoreScopeBackupSummary.innerHTML = `${context.titleLabel}
`;
}
showRestoreScopeStatus(`Could not pre-inspect user list: ${err.message}`, 'error');
}
}
async function executeScopedRestore() {
if (!activeRestoreScopeContext) return;
const isSingle = elements.restoreScopeSingle && elements.restoreScopeSingle.checked;
const targetUser = isSingle
? (elements.restoreTargetUserSelect ? elements.restoreTargetUserSelect.value : '')
: 'all';
if (isSingle && !targetUser) {
showRestoreScopeStatus('Please select which user to restore from the dropdown.', 'error');
return;
}
const btn = elements.btnConfirmScopedRestore;
const origHtml = btn ? btn.innerHTML : '';
if (btn) {
btn.disabled = true;
btn.innerHTML = ` Restoring... `;
lucide.createIcons();
}
try {
showRestoreScopeStatus(
isSingle
? `Restoring only @${targetUser}'s recipes, images, and shopping lists...`
: 'Restoring entire database (all users, recipes, and shopping lists)...',
'info'
);
const ctx = activeRestoreScopeContext;
let res;
if (ctx.mode === 'upload') {
res = await fetch('/api/admin/backups/restore-upload', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({
filename: ctx.filename,
dataBase64: ctx.base64Data,
targetUser
})
});
} else if (ctx.mode === 'direct') {
res = await fetch('/api/admin/backups/restore-cloud-file', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({
provider: ctx.provider,
fileId: ctx.fileId,
objectKey: ctx.objectKey,
filename: ctx.filename,
targetUser
})
});
} else {
res = await fetch(`/api/admin/backups/${ctx.historyId}/restore-cloud`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({
format: ctx.format || 'db',
targetUser
})
});
}
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Restore failed.');
const r = data.restored || {};
const msg =
r.mode === 'single_user'
? `✓ Restored data for @${r.targetUsername} (${r.recipeCount} recipes, ${r.shoppingListCount} shopping lists) without modifying other users!`
: `✓ Full database restored (${r.userCount} users, ${r.recipeCount} recipes, ${r.shoppingListCount} shopping lists)!`;
showRestoreScopeStatus(msg, 'success');
showBackupStatus(msg, 'success');
await fetchRecipes();
await fetchShoppingLists();
await fetchAdminBackupConfig();
setTimeout(() => {
closeModal('restore-scope-modal');
closeModal('restore-cloud-modal');
}, 1100);
} catch (err) {
showRestoreScopeStatus(`Restore failed: ${err.message}`, 'error');
showBackupStatus(`Restore failed: ${err.message}`, 'error');
} finally {
if (btn) {
btn.disabled = false;
btn.innerHTML = origHtml;
lucide.createIcons();
}
}
}
async function handleSyncCloudBackups(silent = false) {
const btn = elements.btnSyncCloudBackups;
const origHtml = btn ? btn.innerHTML : '';
if (btn) {
btn.disabled = true;
btn.innerHTML = ` Scanning Cloud... `;
lucide.createIcons();
}
try {
const res = await fetch('/api/admin/backups/cloud-files', {
headers: getAuthHeaders()
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Failed to scan cloud backups.');
cachedCloudBackups = data.backups || [];
renderAdminBackupHistory(cachedCloudBackups);
if (!silent) {
if (data.providerErrors && data.providerErrors.length > 0) {
showBackupStatus(`Synced ${cachedCloudBackups.length} cloud backup(s) (${data.providerErrors.join('; ')}).`, 'info');
} else {
showBackupStatus(`Synced ${cachedCloudBackups.length} cloud backup(s) from connected storage.`, 'success');
}
}
} catch (err) {
if (!silent) showBackupStatus(err.message, 'error');
} finally {
if (btn) {
btn.disabled = false;
btn.innerHTML = origHtml;
lucide.createIcons();
}
}
}
function openCloudRestoreModal() {
if (elements.cloudRestoreModalStatus) {
elements.cloudRestoreModalStatus.classList.add('hidden');
}
openModal('restore-cloud-modal');
loadLiveCloudBackupsForModal(false);
}
async function loadLiveCloudBackupsForModal(showToast = false) {
if (!elements.cloudRestoreFilesContainer) return;
elements.cloudRestoreFilesContainer.innerHTML = `
Scanning your Google Drive backup folder & Wasabi S3 bucket for available backups...
`;
try {
const res = await fetch('/api/admin/backups/cloud-files', {
headers: getAuthHeaders()
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Failed to scan cloud storage.');
cachedCloudBackups = data.backups || [];
renderAdminBackupHistory(cachedCloudBackups);
renderCloudRestoreModalList();
if (data.providerErrors && data.providerErrors.length > 0) {
showCloudRestoreModalStatus(`Note: ${data.providerErrors.join(' | ')}`, 'error');
} else if (showToast) {
showCloudRestoreModalStatus(`Found ${cachedCloudBackups.length} backup snapshot(s) in cloud storage.`, 'success');
}
} catch (err) {
showCloudRestoreModalStatus(err.message, 'error');
elements.cloudRestoreFilesContainer.innerHTML = `
Could not load cloud backups. Make sure Google Drive or Wasabi S3 is connected.
`;
}
}
function renderCloudRestoreModalList() {
if (!elements.cloudRestoreFilesContainer) return;
const filter = elements.cloudRestoreProviderFilter ? elements.cloudRestoreProviderFilter.value : 'all';
const filtered = cachedCloudBackups.filter(snap => {
const hasGdrive = Boolean(snap.gdriveFileIdDb || snap.gdriveFileIdJson);
const hasS3 = Boolean(snap.s3KeyDb || snap.s3KeyJson);
if (filter === 'gdrive') return hasGdrive;
if (filter === 's3') return hasS3;
return hasGdrive || hasS3;
});
elements.cloudRestoreFilesContainer.innerHTML = '';
if (filtered.length === 0) {
elements.cloudRestoreFilesContainer.innerHTML = `
No cloud backups found for this source. Click "Backup to Cloud Now" to create your first cloud snapshot, or verify your Google Drive destination folder.
`;
return;
}
filtered.forEach(snap => {
const card = document.createElement('div');
card.style.cssText = 'background: var(--bg-main); border: 1px solid var(--border); border-radius: 10px; padding: 14px 16px; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 12px;';
const dateStr = new Date(snap.createdAt).toLocaleString();
const badges = [];
if (snap.gdriveFileIdDb || snap.gdriveFileIdJson) {
badges.push(`Google Drive `);
}
if (snap.s3KeyDb || snap.s3KeyJson) {
badges.push(`Wasabi S3 `);
}
const statsLine = (snap.userCount || snap.recipeCount || snap.shoppingListCount)
? `${snap.userCount} users · ${snap.recipeCount} recipes · ${snap.shoppingListCount} shopping lists`
: snap.label;
const hasDb = Boolean(snap.gdriveFileIdDb || snap.s3KeyDb);
const hasJson = Boolean(snap.gdriveFileIdJson || snap.s3KeyJson);
card.innerHTML = `
${dateStr}
${badges.join('')}
${statsLine}
${hasDb ? ` · DB: ${formatBytesHuman(snap.dbSizeBytes)}` : ''}
${hasJson ? ` · JSON: ${formatBytesHuman(snap.jsonSizeBytes)}` : ''}
${hasDb ? `
Restore (.DB)
` : ''}
${hasJson ? `
Restore (.JSON)
` : ''}
`;
const btnRestoreDb = card.querySelector('.btn-modal-restore-db');
if (btnRestoreDb) {
btnRestoreDb.addEventListener('click', () => handleRestoreDirectCloudFile(snap, 'db', dateStr));
}
const btnRestoreJson = card.querySelector('.btn-modal-restore-json');
if (btnRestoreJson) {
btnRestoreJson.addEventListener('click', () => handleRestoreDirectCloudFile(snap, 'json', dateStr));
}
elements.cloudRestoreFilesContainer.appendChild(card);
});
lucide.createIcons();
}
function handleRestoreDirectCloudFile(snap, format, dateLabel) {
const gdriveId = format === 'json' ? (snap.gdriveFileIdJson || snap.gdriveFileIdDb) : (snap.gdriveFileIdDb || snap.gdriveFileIdJson);
const s3Key = format === 'json' ? (snap.s3KeyJson || snap.s3KeyDb) : (snap.s3KeyDb || snap.s3KeyJson);
const filename = format === 'json' ? snap.filenameJson : snap.filenameDb;
if (gdriveId) {
openScopedRestoreModal({
mode: 'direct',
provider: 'gdrive',
fileId: gdriveId,
filename,
titleLabel: `Google Drive (${dateLabel} · .${format.toUpperCase()})`
});
} else if (s3Key) {
openScopedRestoreModal({
mode: 'direct',
provider: 's3',
objectKey: s3Key,
filename,
titleLabel: `Wasabi S3 (${dateLabel} · .${format.toUpperCase()})`
});
} else {
openScopedRestoreModal({
mode: 'history',
historyId: snap.id,
format,
titleLabel: `Cloud Backup (${dateLabel} · .${format.toUpperCase()})`
});
}
}