// ForkFolio client application state let allRecipes = []; let activeRecipe = null; let currentDraftRawCaption = ''; 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'), settingsGeminiModelSelect: document.getElementById('settings-gemini-model'), btnDiscoverModels: document.getElementById('btn-discover-models'), modelsStatusHint: document.getElementById('models-status-hint'), 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'), aiPolishBanner: document.getElementById('ai-polish-banner'), btnAiPolish: document.getElementById('btn-ai-polish'), aiPolishTitle: document.getElementById('ai-polish-title'), aiPolishSubtitle: document.getElementById('ai-polish-subtitle'), // 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); if (elements.btnDiscoverModels) { elements.btnDiscoverModels.addEventListener('click', () => handleDiscoverModels(true)); } // 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); elements.btnAiPolish?.addEventListener('click', handleAiPolish); // 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}`); } } } // User-initiated AI Polish action for draft recipe async function handleAiPolish() { if (!elements.btnAiPolish) return; const originalBtnHtml = elements.btnAiPolish.innerHTML; elements.btnAiPolish.disabled = true; elements.btnAiPolish.innerHTML = ' Polishing...'; lucide.createIcons(); try { const response = await fetch('/api/recipes/ai-polish', { method: 'POST', headers: { 'Content-Type': 'application/json', ...getAuthHeaders() }, body: JSON.stringify({ rawCaption: currentDraftRawCaption, title: elements.editTitle.value.trim(), ingredients: elements.editIngredients.value.trim().split(/\r?\n/).filter(Boolean), instructions: elements.editInstructions.value.trim().split(/\r?\n/).filter(Boolean) }) }); if (response.status === 401) { logout(); return; } const result = await response.json(); if (!response.ok) { throw new Error(result.error || 'Failed to polish recipe with AI.'); } // Populate polished fields if (result.title) elements.editTitle.value = result.title; if (result.description) elements.editDescription.value = result.description; if (result.servings) elements.editServings.value = result.servings; if (result.prepTime) elements.editPrepTime.value = result.prepTime; if (result.cookTime) elements.editCookTime.value = result.cookTime; if (Array.isArray(result.ingredients) && result.ingredients.length > 0) { elements.editIngredients.value = result.ingredients.join('\n'); } if (Array.isArray(result.instructions) && result.instructions.length > 0) { elements.editInstructions.value = result.instructions.join('\n'); } // Update banner to success state elements.aiPolishBanner.className = 'ai-polish-banner success'; elements.aiPolishTitle.textContent = 'Polished with Gemini AI'; elements.aiPolishSubtitle.textContent = 'Measurements and instruction steps have been organized.'; elements.btnAiPolish.style.display = 'none'; lucide.createIcons(); } catch (err) { elements.btnAiPolish.disabled = false; elements.btnAiPolish.innerHTML = originalBtnHtml; lucide.createIcons(); alert(err.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() { currentDraftRawCaption = ''; if (elements.aiPolishBanner) elements.aiPolishBanner.style.display = 'none'; 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 || []); // Handle AI Polish Suggestion Banner if (data.suggestAiPolish && data.rawCaption) { currentDraftRawCaption = data.rawCaption; if (elements.aiPolishBanner) { elements.aiPolishBanner.style.display = 'flex'; elements.aiPolishBanner.className = 'ai-polish-banner'; elements.aiPolishTitle.textContent = 'Extracted with local engine'; elements.aiPolishSubtitle.textContent = 'Want AI to polish measurements, separate run-on steps, and format times?'; elements.btnAiPolish.style.display = 'inline-flex'; elements.btnAiPolish.disabled = false; elements.btnAiPolish.innerHTML = ' Polish with AI'; lucide.createIcons(); } } else { currentDraftRawCaption = ''; if (elements.aiPolishBanner) elements.aiPolishBanner.style.display = 'none'; } openModal('save-modal'); } function editActiveRecipe() { if (!activeRecipe) return; currentDraftRawCaption = ''; if (elements.aiPolishBanner) elements.aiPolishBanner.style.display = 'none'; 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 = `extracted thumbnail`; option.addEventListener('click', () => { elements.imageSelectionGrid.querySelectorAll('.image-option').forEach(el => el.classList.remove('selected')); option.classList.add('selected'); selectedCoverImage = imgSrc; elements.editImageUrl.value = imgSrc; }); elements.imageSelectionGrid.appendChild(option); }); } function selectImageThumbnail(url) { elements.imageSelectionGrid.querySelectorAll('.image-option').forEach(el => { const img = el.querySelector('img'); if (img && img.src === url) { el.classList.add('selected'); } else { el.classList.remove('selected'); } }); } // ------------------------------------------------------------- // DASHBOARD RENDERING // ------------------------------------------------------------- function renderLibrary() { elements.recipesGrid.innerHTML = ''; const searchVal = elements.librarySearch.value.toLowerCase().trim(); const filtered = allRecipes.filter(recipe => { return recipe.title.toLowerCase().includes(searchVal) || (recipe.description && recipe.description.toLowerCase().includes(searchVal)) || (recipe.ingredients && recipe.ingredients.some(i => i.toLowerCase().includes(searchVal))); }); if (filtered.length === 0) { elements.emptyState.style.display = 'flex'; return; } elements.emptyState.style.display = 'none'; filtered.forEach(recipe => { const domain = recipe.sourceUrl ? new URL(recipe.sourceUrl).hostname.replace('www.', '') : 'Manual Recipe'; const card = document.createElement('div'); card.className = 'recipe-card'; card.addEventListener('click', () => showRecipeDetails(recipe)); let imgBlock = ''; if (recipe.image) { imgBlock = ` ${recipe.title} `; } 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 ? `` : `` } ${user.isRecipeManager ? `` : `` } `; 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'} `; 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 // ------------------------------------------------------------- function populateModelDropdown(models, selectedModel) { if (!elements.settingsGeminiModelSelect) return; const select = elements.settingsGeminiModelSelect; select.innerHTML = ''; const targetModel = selectedModel || 'gemini-2.5-flash'; let foundTarget = false; models.forEach(m => { const opt = document.createElement('option'); opt.value = m.id; opt.textContent = m.displayName || m.id; if (m.id === 'gemini-2.5-flash') { opt.textContent = `${m.displayName || 'Gemini 2.5 Flash'} (Stable Recommended)`; } else if (m.id === 'gemini-3.8-flash') { opt.textContent = `${m.displayName || 'Gemini 3.8 Flash'} (Newest Flash)`; } if (m.id === targetModel) { opt.selected = true; foundTarget = true; } select.appendChild(opt); }); if (!foundTarget && targetModel) { const customOpt = document.createElement('option'); customOpt.value = targetModel; customOpt.textContent = targetModel; customOpt.selected = true; select.appendChild(customOpt); } } async function fetchModelsDirectFromGoogle(key) { if (!key || key === 'PASTE_YOUR_GEMINI_API_KEY_HERE') { throw new Error('Please enter a valid Gemini API key first.'); } const googleRes = await fetch(`https://generativelanguage.googleapis.com/v1beta/models?key=${key}`); const googleData = await googleRes.json().catch(() => ({})); if (!googleRes.ok) { throw new Error(googleData.error?.message || `Google API returned status ${googleRes.status}`); } if (!Array.isArray(googleData.models)) { throw new Error('Unexpected response format from Google API.'); } const excludedPatterns = /embedding|aqa|imagen|tts|lyria|banana|-image|robotics|computer-use|deep-research|antigravity|customtools/i; const models = googleData.models .filter(m => Array.isArray(m.supportedGenerationMethods) && m.supportedGenerationMethods.includes('generateContent')) .filter(m => !excludedPatterns.test(m.name) && !excludedPatterns.test(m.displayName || '')) .map(m => { const modelId = m.name.replace(/^models\//, ''); return { id: modelId, displayName: m.displayName || modelId, description: m.description || '' }; }); models.sort((a, b) => { const aIsFlash = a.id.toLowerCase().includes('flash'); const bIsFlash = b.id.toLowerCase().includes('flash'); if (aIsFlash && !bIsFlash) return -1; if (!aIsFlash && bIsFlash) return 1; return b.id.localeCompare(a.id); }); return models; } async function handleDiscoverModels(showNotice = true) { const key = elements.settingsGeminiKeyInput ? elements.settingsGeminiKeyInput.value.trim() : ''; if (!key || key === 'PASTE_YOUR_GEMINI_API_KEY_HERE') { if (showNotice) { alert('Please enter your Google Gemini API key first.'); } return; } if (elements.modelsStatusHint) { elements.modelsStatusHint.textContent = 'Discovering available models from Google API...'; elements.modelsStatusHint.style.color = 'var(--primary)'; } if (elements.btnDiscoverModels) { elements.btnDiscoverModels.disabled = true; } try { let models = []; // 1. Attempt backend endpoint first try { const res = await fetch('/api/settings/discover-models', { method: 'POST', headers: { 'Content-Type': 'application/json', ...getAuthHeaders() }, body: JSON.stringify({ apiKey: key }) }); if (res.ok) { const text = await res.text(); try { const data = JSON.parse(text); if (Array.isArray(data.models)) { models = data.models; } } catch (parseErr) {} } } catch (serverErr) { // Backend may not have route if server hasn't restarted yet } // 2. Fallback to direct client-side call to Google API if backend returned HTML / 404 if (models.length === 0) { models = await fetchModelsDirectFromGoogle(key); } if (models.length > 0) { const currentSelected = elements.settingsGeminiModelSelect ? elements.settingsGeminiModelSelect.value : 'gemini-2.5-flash'; populateModelDropdown(models, currentSelected); if (elements.modelsStatusHint) { elements.modelsStatusHint.textContent = `Discovered ${models.length} compatible models.`; elements.modelsStatusHint.style.color = 'var(--text-muted)'; } } else { if (elements.modelsStatusHint) { elements.modelsStatusHint.textContent = 'No compatible models found for this key.'; elements.modelsStatusHint.style.color = '#ef4444'; } } } catch (err) { console.error('Discover models error:', err); if (elements.modelsStatusHint) { elements.modelsStatusHint.textContent = `Discovery error: ${err.message}`; elements.modelsStatusHint.style.color = '#ef4444'; } if (showNotice) { alert(`Model Discovery Failed: ${err.message}\n\nPlease check that your Gemini API Key is valid.`); } } finally { if (elements.btnDiscoverModels) { elements.btnDiscoverModels.disabled = false; } } } 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...'; if (elements.modelsStatusHint) { elements.modelsStatusHint.textContent = 'Used for both recipe photo scanning and AI text polishing.'; elements.modelsStatusHint.style.color = 'var(--text-muted)'; } 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.settingsGeminiModelSelect) { const savedModel = (!settings.geminiModel || settings.geminiModel === 'gemini-2.0-flash') ? 'gemini-2.5-flash' : settings.geminiModel; let exists = false; for (let i = 0; i < elements.settingsGeminiModelSelect.options.length; i++) { if (elements.settingsGeminiModelSelect.options[i].value === savedModel) { exists = true; break; } } if (!exists && savedModel) { const opt = document.createElement('option'); opt.value = savedModel; opt.textContent = savedModel; elements.settingsGeminiModelSelect.appendChild(opt); } elements.settingsGeminiModelSelect.value = savedModel; } 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 model = elements.settingsGeminiModelSelect ? elements.settingsGeminiModelSelect.value : 'gemini-2.5-flash'; 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, geminiModel: model, 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 rawModel = settings.geminiModel ? settings.geminiModel.trim() : ''; const geminiModel = (!rawModel || rawModel === 'gemini-2.0-flash') ? 'gemini-2.5-flash' : rawModel; 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/${geminiModel}: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 = (errorObj.error && errorObj.error.message) ? errorObj.error.message : 'Google Gemini API call failed.'; if (status === 400 && (!errorObj.error || !errorObj.error.message)) { errMsg = `Invalid parameters or model version (${geminiModel}). Please check your settings.`; } else if (status === 403 && (!errorObj.error || !errorObj.error.message)) { errMsg = 'Access denied. The Google Gemini API key is invalid or inactive.'; } else if (status === 429 && (!errorObj.error || !errorObj.error.message)) { errMsg = 'Rate limit exceeded. You have made too many requests or hit your Gemini quota.'; } 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 = `
${groupName}
${uncheckedInGroup} / ${groupEntries.length}
`; const ul = card.querySelector('.shopping-items-list'); groupEntries.forEach(entry => { const li = document.createElement('li'); li.className = `shopping-item-row ${entry.checked ? 'checked' : ''}`; const sourcesHtml = entry.sources.length > 0 ? `
${entry.sources.map(s => `${s}`).join('')}
` : ''; li.innerHTML = `
${entry.displayText} ${sourcesHtml}
`; li.querySelector('.shopping-item-main').addEventListener('click', () => { handleToggleShoppingItems(entry.itemIds, !entry.checked); }); li.querySelector('.shopping-item-delete').addEventListener('click', (e) => { e.stopPropagation(); handleDeleteShoppingItems(entry.itemIds); }); ul.appendChild(li); }); elements.shoppingListsContent.appendChild(card); } lucide.createIcons(); } // ========================================== // 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 = ``; } 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 && !cfg.gdriveFolderId && 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 ? ` ` : ''} ${hasJsonFile ? ` ` : ''} ${hasCloudFiles ? ` ` : ''} `; 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 = ``; } 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 = ``; } 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 query = silent ? '' : '?scan=1'; const res = await fetch(`/api/admin/backups/cloud-files${query}`, { 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 = `

Loading available cloud backups...

`; try { const query = showToast ? '?scan=1' : ''; const res = await fetch(`/api/admin/backups/cloud-files${query}`, { 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 ? ` ` : ''} ${hasJson ? ` ` : ''}
`; 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()})` }); } }