// ForkFolio client application state let allRecipes = []; let activeRecipe = null; let currentMultiplier = 1; let selectedCoverImage = ''; let stopwatchInterval = null; let stopwatchSeconds = 0; let cookingActiveStep = 0; let currentUser = null; let activeTab = 'my-cookbook'; let shoppingLists = []; let activeShoppingListId = null; let consolidateItems = true; let shoppingGroupBy = 'aisle'; // Page elements caching const elements = { themeToggle: document.getElementById('theme-toggle'), urlScrapeForm: document.getElementById('url-scrape-form'), scrapeUrlInput: document.getElementById('scrape-url-input'), btnScrapeSubmit: document.getElementById('btn-scrape-submit'), btnManualAdd: document.getElementById('btn-manual-add'), btnSettings: document.getElementById('btn-settings'), settingsModal: document.getElementById('settings-modal'), settingsApiForm: document.getElementById('settings-api-form'), settingsGeminiKeyInput: document.getElementById('settings-gemini-key'), btnToggleGeminiKey: document.getElementById('btn-toggle-gemini-key'), settingsStatusMsg: document.getElementById('settings-status-msg'), settingsStatusText: document.getElementById('settings-status-text'), scanImageInput: document.getElementById('scan-image-input'), btnScanImage: document.getElementById('btn-scan-image'), recipesGrid: document.getElementById('recipes-grid'), emptyState: document.getElementById('empty-state'), librarySearch: document.getElementById('library-search'), // Auth caching authModal: document.getElementById('auth-modal'), loginForm: document.getElementById('login-form'), authErrorMsg: document.getElementById('auth-error-msg'), authErrorText: document.getElementById('auth-error-text'), loginUsernameInput: document.getElementById('login-username'), loginPasswordInput: document.getElementById('login-password'), userBadgeContainer: document.getElementById('user-badge-container'), userDisplayName: document.getElementById('user-display-name'), btnLogout: document.getElementById('btn-logout'), tabMyCookbook: document.getElementById('tab-my-cookbook'), tabCommunityVault: document.getElementById('tab-community-vault'), tabUserAdmin: document.getElementById('tab-user-admin'), userAdminPanel: document.getElementById('user-admin-panel'), adminUsersList: document.getElementById('admin-users-list'), btnAdminAddUser: document.getElementById('btn-admin-add-user'), btnAdminTabUsers: document.getElementById('btn-admin-tab-users'), btnAdminTabShares: document.getElementById('btn-admin-tab-shares'), btnAdminTabBackups: document.getElementById('btn-admin-tab-backups'), adminUsersSection: document.getElementById('admin-users-section'), adminSharesSection: document.getElementById('admin-shares-section'), adminBackupsSection: document.getElementById('admin-backups-section'), adminSharesList: document.getElementById('admin-shares-list'), // Admin Backups Console Elements btnBackupRunCloud: document.getElementById('btn-backup-run-cloud'), btnBackupDownloadDb: document.getElementById('btn-backup-download-db'), btnBackupDownloadJson: document.getElementById('btn-backup-download-json'), backupRestoreFileInput: document.getElementById('backup-restore-file-input'), btnBackupRestoreLocal: document.getElementById('btn-backup-restore-local'), backupStatusBanner: document.getElementById('backup-status-banner'), adminBackupConfigForm: document.getElementById('admin-backup-config-form'), backupLastRunBadge: document.getElementById('backup-last-run-badge'), backupScheduleFrequency: document.getElementById('backup-schedule-frequency'), backupScheduleTime: document.getElementById('backup-schedule-time'), backupScheduleDay: document.getElementById('backup-schedule-day'), backupRetentionCount: document.getElementById('backup-retention-count'), backupFormat: document.getElementById('backup-format'), backupEmbedImages: document.getElementById('backup-embed-images'), backupS3Enabled: document.getElementById('backup-s3-enabled'), backupS3Endpoint: document.getElementById('backup-s3-endpoint'), backupS3Region: document.getElementById('backup-s3-region'), backupS3Bucket: document.getElementById('backup-s3-bucket'), backupS3Prefix: document.getElementById('backup-s3-prefix'), backupS3AccessKey: document.getElementById('backup-s3-access-key'), backupS3SecretKey: document.getElementById('backup-s3-secret-key'), btnBackupTestS3: document.getElementById('btn-backup-test-s3'), backupGdriveEnabled: document.getElementById('backup-gdrive-enabled'), gdriveStatusDot: document.getElementById('gdrive-status-dot'), gdriveStatusTitle: document.getElementById('gdrive-status-title'), gdriveStatusSubtitle: document.getElementById('gdrive-status-subtitle'), btnAuthorizeGdriveLabel: document.getElementById('btn-authorize-gdrive-label'), btnBackupAuthorizeGdrive: document.getElementById('btn-backup-authorize-gdrive'), btnBackupDisconnectGdrive: document.getElementById('btn-backup-disconnect-gdrive'), backupGdriveFolderSelect: document.getElementById('backup-gdrive-folder-select'), btnGdriveNewFolder: document.getElementById('btn-gdrive-new-folder'), btnGdriveRefreshFolders: document.getElementById('btn-gdrive-refresh-folders'), gdriveFolderHint: document.getElementById('gdrive-folder-hint'), gdriveInlineStatus: document.getElementById('gdrive-inline-status'), btnBackupTestGdrive: document.getElementById('btn-backup-test-gdrive'), adminBackupsHistoryList: document.getElementById('admin-backups-history-list'), // Admin Create User Modal adminCreateUserModal: document.getElementById('admin-create-user-modal'), adminCreateUserForm: document.getElementById('admin-create-user-form'), adminNewUsername: document.getElementById('admin-new-username'), adminNewPassword: document.getElementById('admin-new-password'), adminCreateError: document.getElementById('admin-create-error'), adminCreateErrorText: document.getElementById('admin-create-error-text'), // Admin Delete User Modal adminDeleteUserModal: document.getElementById('admin-delete-user-modal'), deleteUserTargetId: document.getElementById('delete-user-target-id'), deleteUserTargetName: document.getElementById('delete-user-target-name'), deleteUserPreserveRecipes: document.getElementById('delete-user-preserve-recipes'), btnAdminConfirmDelete: document.getElementById('btn-admin-confirm-delete'), // Scrape / Edit Modal saveModal: document.getElementById('save-modal'), recipeEditForm: document.getElementById('recipe-edit-form'), editRecipeId: document.getElementById('edit-recipe-id'), editTitle: document.getElementById('edit-title'), editSource: document.getElementById('edit-source'), editDescription: document.getElementById('edit-description'), editServings: document.getElementById('edit-servings'), editPrepTime: document.getElementById('edit-prep-time'), editCookTime: document.getElementById('edit-cook-time'), imageSelectionGrid: document.getElementById('image-selection-grid'), editImageUrl: document.getElementById('edit-image-url'), editIngredients: document.getElementById('edit-ingredients'), editInstructions: document.getElementById('edit-instructions'), modalActionTitle: document.getElementById('modal-action-title'), btnSaveConfirm: document.getElementById('btn-save-confirm'), editIsPublic: document.getElementById('edit-is-public'), // Detail Modal detailModal: document.getElementById('detail-modal'), recipeDetailBanner: document.getElementById('recipe-detail-banner'), btnDetailEdit: document.getElementById('btn-detail-edit'), btnDetailDelete: document.getElementById('btn-detail-delete'), btnDetailClone: document.getElementById('btn-detail-clone'), btnDetailShare: document.getElementById('btn-detail-share'), btnDetailRevokeShare: document.getElementById('btn-detail-revoke-share'), tagShareStats: document.getElementById('tag-share-stats'), recipeDetailCreator: document.getElementById('recipe-detail-creator'), recipeDetailCreatorName: document.getElementById('recipe-detail-creator-name'), recipeDetailTitle: document.getElementById('recipe-detail-title'), tagServings: document.getElementById('tag-servings'), tagPrep: document.getElementById('tag-prep'), tagCook: document.getElementById('tag-cook'), tagSource: document.getElementById('tag-source'), recipeDetailDescription: document.getElementById('recipe-detail-description'), recipeDetailIngredients: document.getElementById('recipe-detail-ingredients'), recipeDetailInstructions: document.getElementById('recipe-detail-instructions'), scalerMinus: document.getElementById('scaler-minus'), scalerPlus: document.getElementById('scaler-plus'), currentServingsVal: document.getElementById('current-servings-val'), btnTriggerCookmode: document.getElementById('btn-trigger-cookmode'), // Cooking Mode Modal cookingModeModal: document.getElementById('cooking-mode-modal'), cookRecipeTitle: document.getElementById('cook-recipe-title'), cookTimerVal: document.getElementById('cook-timer-val'), cookTimerPlay: document.getElementById('cook-timer-play'), cookTimerReset: document.getElementById('cook-timer-reset'), cookProgressBar: document.getElementById('cook-progress-bar'), cookStepIndex: document.getElementById('cook-step-index'), cookStepInstruction: document.getElementById('cook-step-instruction'), cookBtnPrev: document.getElementById('cook-btn-prev'), cookBtnNext: document.getElementById('cook-btn-next'), cookStepDots: document.getElementById('cook-step-dots'), // Loader loadingOverlay: document.getElementById('loading-overlay'), loaderStepFetch: document.getElementById('loader-step-fetch'), loaderStepParse: document.getElementById('loader-step-parse'), loaderStepImages: document.getElementById('loader-step-images'), // Mobile Components fabAddRecipe: document.getElementById('mobile-header-btn-add'), mobileTabCookbook: document.getElementById('mobile-header-tab-cookbook'), mobileTabCommunity: document.getElementById('mobile-header-tab-community'), mobileTabShopping: document.getElementById('mobile-header-tab-shopping'), mobileShoppingTabBadge: document.getElementById('mobile-shopping-tab-badge'), mobileTabProfile: document.getElementById('mobile-header-btn-profile'), mobileProfileModal: document.getElementById('mobile-profile-modal'), mobileProfileUsername: document.getElementById('mobile-profile-username'), mobileProfileRole: document.getElementById('mobile-profile-role'), mobileBtnThemeToggle: document.getElementById('mobile-btn-theme-toggle'), mobileBtnSettings: document.getElementById('mobile-btn-settings'), mobileBtnAdmin: document.getElementById('mobile-btn-admin'), mobileBtnLogout: document.getElementById('mobile-btn-logout'), headerLogo: document.getElementById('header-logo'), libraryViewTitle: document.getElementById('library-view-title'), // Shopping Lists Components tabShoppingLists: document.getElementById('tab-shopping-lists'), shoppingTabBadge: document.getElementById('shopping-tab-badge'), shoppingListsPanel: document.getElementById('shopping-lists-panel'), shoppingListSelect: document.getElementById('shopping-list-select'), btnShoppingNewList: document.getElementById('btn-shopping-new-list'), btnShoppingRenameList: document.getElementById('btn-shopping-rename-list'), btnShoppingDeleteList: document.getElementById('btn-shopping-delete-list'), shoppingShareStatsBadge: document.getElementById('shopping-share-stats-badge'), btnShoppingShare: document.getElementById('btn-shopping-share'), btnShoppingRevokeShare: document.getElementById('btn-shopping-revoke-share'), btnShoppingCopyText: document.getElementById('btn-shopping-copy-text'), shoppingAddItemForm: document.getElementById('shopping-add-item-form'), shoppingAddItemInput: document.getElementById('shopping-add-item-input'), toggleConsolidateItems: document.getElementById('toggle-consolidate-items'), shoppingGroupBySelect: document.getElementById('shopping-group-by'), btnShoppingClearChecked: document.getElementById('btn-shopping-clear-checked'), btnShoppingClearAll: document.getElementById('btn-shopping-clear-all'), shoppingListsContent: document.getElementById('shopping-lists-content'), shoppingEmptyState: document.getElementById('shopping-empty-state'), detailShoppingListSelect: document.getElementById('detail-shopping-list-select'), btnAddRecipeToList: document.getElementById('btn-add-recipe-to-list'), settingsDefaultConsolidate: document.getElementById('settings-default-consolidate') }; // ------------------------------------------------------------- // LIFE CYCLE AND REGISTRATION // ------------------------------------------------------------- window.addEventListener('DOMContentLoaded', () => { initTheme(); registerEvents(); initAuth(); }); function registerEvents() { // Theme Toggle elements.themeToggle.addEventListener('click', toggleTheme); // URL Form Submission elements.urlScrapeForm.addEventListener('submit', handleScrapeSubmit); // Manual Add Form Opening elements.btnManualAdd.addEventListener('click', openManualAdd); // Settings Modal Opening elements.btnSettings.addEventListener('click', openSettingsModal); elements.settingsApiForm.addEventListener('submit', handleSaveSettings); elements.btnToggleGeminiKey.addEventListener('click', toggleSettingsKeyVisibility); // Image Scanning Actions elements.btnScanImage.addEventListener('click', () => elements.scanImageInput.click()); elements.scanImageInput.addEventListener('change', handleRecipeImageScan); document.addEventListener('paste', handleClipboardPaste); // Library Search elements.librarySearch.addEventListener('input', filterRecipes); // Recipe Form Save elements.recipeEditForm.addEventListener('submit', handleRecipeSave); // Action Buttons Detail Modal elements.btnDetailEdit.addEventListener('click', editActiveRecipe); elements.btnDetailDelete.addEventListener('click', deleteActiveRecipe); elements.btnDetailClone.addEventListener('click', cloneActiveRecipe); elements.btnDetailShare.addEventListener('click', shareActiveRecipe); elements.btnDetailRevokeShare.addEventListener('click', revokeActiveRecipeShare); // Auth Form Submissions elements.loginForm.addEventListener('submit', handleLogin); elements.btnLogout.addEventListener('click', logout); // Dashboard Tabs Switchers elements.tabMyCookbook.addEventListener('click', () => switchTab('my-cookbook')); elements.tabCommunityVault.addEventListener('click', () => switchTab('community-vault')); elements.tabShoppingLists.addEventListener('click', () => switchTab('shopping-lists')); elements.tabUserAdmin.addEventListener('click', () => switchTab('user-admin')); // Shopping Lists Events elements.shoppingListSelect.addEventListener('change', (e) => { activeShoppingListId = e.target.value; renderShoppingListsWorkspace(); }); elements.btnShoppingNewList.addEventListener('click', handleCreateShoppingList); elements.btnShoppingRenameList.addEventListener('click', handleRenameShoppingList); elements.btnShoppingDeleteList.addEventListener('click', handleDeleteShoppingList); elements.btnShoppingShare.addEventListener('click', handleShareShoppingList); elements.btnShoppingRevokeShare.addEventListener('click', handleRevokeShoppingListShare); elements.btnShoppingCopyText.addEventListener('click', handleCopyShoppingListText); elements.shoppingAddItemForm.addEventListener('submit', handleAddManualShoppingItem); elements.toggleConsolidateItems.addEventListener('change', (e) => { consolidateItems = e.target.checked; renderShoppingListsWorkspace(); }); elements.shoppingGroupBySelect.addEventListener('change', (e) => { shoppingGroupBy = e.target.value; renderShoppingListsWorkspace(); }); elements.btnShoppingClearChecked.addEventListener('click', () => handleClearShoppingList(true)); elements.btnShoppingClearAll.addEventListener('click', () => handleClearShoppingList(false)); elements.detailShoppingListSelect.addEventListener('change', handleDetailListSelectChange); elements.btnAddRecipeToList.addEventListener('click', handleAddRecipeToShoppingList); elements.settingsDefaultConsolidate.addEventListener('change', handleDefaultConsolidatePreferenceChange); // Admin Action Buttons & Forms elements.btnAdminAddUser.addEventListener('click', () => openModal('admin-create-user-modal')); elements.adminCreateUserForm.addEventListener('submit', handleAdminCreateUser); elements.btnAdminConfirmDelete.addEventListener('click', handleAdminDeleteUser); // Admin Sub-Tab Switchers elements.btnAdminTabUsers.addEventListener('click', () => switchAdminSubTab('users')); elements.btnAdminTabShares.addEventListener('click', () => switchAdminSubTab('shares')); if (elements.btnAdminTabBackups) { elements.btnAdminTabBackups.addEventListener('click', () => switchAdminSubTab('backups')); } // Admin Backups Console Actions if (elements.adminBackupConfigForm) { elements.adminBackupConfigForm.addEventListener('submit', handleSaveBackupConfig); elements.btnBackupRunCloud.addEventListener('click', handleRunCloudBackupNow); elements.btnBackupDownloadDb.addEventListener('click', () => handleDownloadLocalBackup('db')); elements.btnBackupDownloadJson.addEventListener('click', () => handleDownloadLocalBackup('json')); elements.btnBackupRestoreLocal.addEventListener('click', () => elements.backupRestoreFileInput.click()); elements.backupRestoreFileInput.addEventListener('change', handleRestoreLocalFile); elements.btnBackupTestS3.addEventListener('click', handleTestS3Connection); elements.btnBackupTestGdrive.addEventListener('click', handleTestGdriveConnection); elements.btnBackupAuthorizeGdrive.addEventListener('click', handleAuthorizeGoogleDrive); if (elements.btnBackupDisconnectGdrive) { elements.btnBackupDisconnectGdrive.addEventListener('click', handleDisconnectGoogleDrive); } if (elements.backupGdriveFolderSelect) { elements.backupGdriveFolderSelect.addEventListener('change', handleSelectGdriveFolder); } if (elements.btnGdriveNewFolder) { elements.btnGdriveNewFolder.addEventListener('click', handleCreateGdriveFolder); } if (elements.btnGdriveRefreshFolders) { elements.btnGdriveRefreshFolders.addEventListener('click', () => loadGoogleDriveFolders(true)); } } 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 ? { 'Authorization': `Bearer ${token}` } : {}; } // ------------------------------------------------------------- // CRUD & API HANDLERS // ------------------------------------------------------------- async function fetchRecipes() { try { let url = '/api/recipes'; if (activeTab === 'community-vault') { url = '/api/recipes/public'; } const response = await fetch(url, { headers: getAuthHeaders() }); if (response.status === 401) { logout(); return; } if (!response.ok) { throw new Error('Failed to load recipes'); } allRecipes = await response.json(); renderLibrary(); } catch (error) { console.error('Error fetching recipes library:', error); } } // Scrape link form submission async function handleScrapeSubmit(e) { e.preventDefault(); const url = elements.scrapeUrlInput.value.trim(); if (!url) return; showScrapeLoading(); try { const response = await fetch('/api/scrape', { method: 'POST', headers: { 'Content-Type': 'application/json', ...getAuthHeaders() }, body: JSON.stringify({ url }) }); if (response.status === 401) { logout(); hideScrapeLoading(); return; } if (!response.ok) { const errorData = await response.json().catch(() => ({})); if (response.status === 403 || errorData.isBlocked) { throw new Error('BLOCKED'); } throw new Error(errorData.error || 'Extraction error.'); } const data = await response.json(); hideScrapeLoading(); openDraftPreview(data); elements.scrapeUrlInput.value = ''; } catch (error) { hideScrapeLoading(); if (error.message === 'BLOCKED') { alert(`Access Blocked by Site Protection\n\nThis website uses Cloudflare or bot-defense screens to prevent automated scraping.\n\nDon't worry! You can still add this recipe by clicking the "Manual Recipe" button at the top to quickly paste your ingredients and directions.`); } else { alert(`Scraping failed: ${error.message}`); } } } // Save or Edit submit async function handleRecipeSave(e) { e.preventDefault(); const id = elements.editRecipeId.value; const recipePayload = { title: elements.editTitle.value.trim(), sourceUrl: elements.editSource.value.trim(), description: elements.editDescription.value.trim(), servings: elements.editServings.value.trim(), prepTime: elements.editPrepTime.value.trim(), cookTime: elements.editCookTime.value.trim(), isPublic: elements.editIsPublic.checked, image: elements.editImageUrl.value.trim(), images: Array.from(elements.imageSelectionGrid.querySelectorAll('.image-option img')).map(img => img.src), ingredients: elements.editIngredients.value.trim().split(/\r?\n/).map(line => line.trim()).filter(Boolean), instructions: elements.editInstructions.value.trim().split(/\r?\n/).map(line => line.trim()).filter(Boolean) }; try { let response; if (id) { // Edit response = await fetch(`/api/recipes/${id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json', ...getAuthHeaders() }, body: JSON.stringify(recipePayload) }); } else { // Create response = await fetch('/api/recipes', { method: 'POST', headers: { 'Content-Type': 'application/json', ...getAuthHeaders() }, body: JSON.stringify(recipePayload) }); } if (response.status === 401) { logout(); return; } if (!response.ok) throw new Error('Saving failed.'); const saved = await response.json(); closeModal('save-modal'); fetchRecipes(); // If active view was open, update it if (activeRecipe && activeRecipe.id === saved.id) { showRecipeDetails(saved); } } catch (error) { console.error('Error saving recipe:', error); alert('Error saving recipe.'); } } // Delete Active Recipe async function deleteActiveRecipe() { if (!activeRecipe) return; if (!confirm(`Are you sure you want to delete "${activeRecipe.title}"?`)) return; try { const response = await fetch(`/api/recipes/${activeRecipe.id}`, { method: 'DELETE', headers: getAuthHeaders() }); if (response.status === 401) { logout(); return; } if (!response.ok) throw new Error('Deletion failed.'); closeModal('detail-modal'); activeRecipe = null; fetchRecipes(); } catch (error) { console.error('Error deleting recipe:', error); alert('Deletion failed.'); } } // Clone a Shared Cookbook recipe to current user's collection async function cloneActiveRecipe() { if (!activeRecipe) return; try { const response = await fetch(`/api/recipes/clone/${activeRecipe.id}`, { method: 'POST', headers: getAuthHeaders() }); if (response.status === 401) { logout(); return; } if (!response.ok) { const err = await response.json().catch(() => ({})); throw new Error(err.error || 'Cloning failed.'); } const cloned = await response.json(); alert(`Successfully cloned "${activeRecipe.title}" into your cookbook!`); closeModal('detail-modal'); // Switch back to "My Cookbook" tab to let them see it immediately switchTab('my-cookbook'); } catch (error) { console.error('Error cloning recipe:', error); alert(`Error cloning recipe: ${error.message}`); } } async function copyTextToClipboard(text) { if (navigator.clipboard && navigator.clipboard.writeText) { await navigator.clipboard.writeText(text); return; } const textArea = document.createElement('textarea'); textArea.value = text; textArea.style.position = 'fixed'; textArea.style.opacity = '0'; document.body.appendChild(textArea); textArea.focus(); textArea.select(); document.execCommand('copy'); document.body.removeChild(textArea); } let shareBtnResetTimeout = null; async function shareActiveRecipe() { if (!activeRecipe) return; if (activeRecipe.isPublic === false) { if (confirm(`"${activeRecipe.title}" is currently private.\n\nTo share it with people outside ForkFolio, you must make it public first. Would you like to make this recipe public now?`)) { try { const response = await fetch(`/api/recipes/${activeRecipe.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json', ...getAuthHeaders() }, body: JSON.stringify({ isPublic: true }) }); if (response.status === 401) { logout(); return; } if (!response.ok) throw new Error('Failed to update privacy.'); const updated = await response.json(); activeRecipe = updated; fetchRecipes(); // Re-render detail badge visual state showRecipeDetails(updated); } catch (err) { console.error('Error toggling privacy for share link:', err); alert('Failed to make the recipe public.'); return; } } else { return; } } // If public and has a share token if (activeRecipe.shareToken) { const shareLink = `${window.location.origin}/share.html?token=${activeRecipe.shareToken}`; try { await copyTextToClipboard(shareLink); if (elements.btnDetailShare) { if (shareBtnResetTimeout) clearTimeout(shareBtnResetTimeout); elements.btnDetailShare.classList.add('copied-feedback'); elements.btnDetailShare.innerHTML = ` Copied!`; lucide.createIcons(); shareBtnResetTimeout = setTimeout(() => { elements.btnDetailShare.classList.remove('copied-feedback'); elements.btnDetailShare.innerHTML = ``; lucide.createIcons(); }, 2000); } } catch (clipErr) { console.warn('Clipboard write failed. Displaying fallback prompt:', clipErr); prompt('Public Share Link:', shareLink); } } else { alert('Sharing token was not generated. Please try saving this recipe as public again.'); } } async function revokeActiveRecipeShare() { if (!activeRecipe) return; if (!confirm(`Are you sure you want to revoke the shared link for "${activeRecipe.title}"?\n\nThis will make the recipe private, disable the public link immediately, and clear all visitor history.`)) return; try { const response = await fetch(`/api/recipes/${activeRecipe.id}/revoke-share`, { method: 'POST', headers: getAuthHeaders() }); if (response.status === 401) { logout(); return; } if (!response.ok) { const err = await response.json().catch(() => ({})); throw new Error(err.error || 'Failed to revoke shared link.'); } alert(`Successfully revoked the public share link for "${activeRecipe.title}".`); // Refresh list and details view const listUrl = activeTab === 'community-vault' ? '/api/recipes/public' : '/api/recipes'; const listRes = await fetch(listUrl, { headers: getAuthHeaders() }); if (listRes.ok) { allRecipes = await listRes.json(); renderLibrary(); const updated = allRecipes.find(r => r.id === activeRecipe.id); if (updated) { showRecipeDetails(updated); } else { closeModal('detail-modal'); } } } catch (error) { console.error('Error revoking recipe share:', error); alert(`Error: ${error.message}`); } } // ------------------------------------------------------------- // DRAFT / MANUAL CREATOR MODAL POPULATOR // ------------------------------------------------------------- function openManualAdd() { elements.modalActionTitle.textContent = 'Create New Recipe'; elements.editRecipeId.value = ''; elements.recipeEditForm.reset(); elements.imageSelectionGrid.innerHTML = ''; selectedCoverImage = ''; elements.editIsPublic.checked = true; openModal('save-modal'); } function openDraftPreview(data) { elements.modalActionTitle.textContent = 'Save Extracted Recipe'; elements.editRecipeId.value = ''; elements.editTitle.value = data.title || ''; elements.editSource.value = data.sourceUrl || ''; elements.editDescription.value = data.description || ''; elements.editServings.value = data.servings || ''; elements.editPrepTime.value = data.prepTime || ''; elements.editCookTime.value = data.cookTime || ''; elements.editIsPublic.checked = true; // Ingredients text area elements.editIngredients.value = data.ingredients ? data.ingredients.join('\n') : ''; // Instructions text area elements.editInstructions.value = data.instructions ? data.instructions.join('\n') : ''; // Render Image Selector renderImageSelector(data.images || []); openModal('save-modal'); } function editActiveRecipe() { if (!activeRecipe) return; elements.modalActionTitle.textContent = 'Edit Recipe Details'; elements.editRecipeId.value = activeRecipe.id; elements.editTitle.value = activeRecipe.title || ''; elements.editSource.value = activeRecipe.sourceUrl || ''; elements.editDescription.value = activeRecipe.description || ''; elements.editServings.value = activeRecipe.servings || ''; elements.editPrepTime.value = activeRecipe.prepTime || ''; elements.editCookTime.value = activeRecipe.cookTime || ''; elements.editIsPublic.checked = activeRecipe.isPublic !== false; elements.editIngredients.value = activeRecipe.ingredients ? activeRecipe.ingredients.join('\n') : ''; elements.editInstructions.value = activeRecipe.instructions ? activeRecipe.instructions.join('\n') : ''; renderImageSelector(activeRecipe.images || []); // Set active selection if (activeRecipe.image) { elements.editImageUrl.value = activeRecipe.image; selectedCoverImage = activeRecipe.image; selectImageThumbnail(activeRecipe.image); } openModal('save-modal'); } // ------------------------------------------------------------- // IMAGE EXTRACTION CAROUSEL & SELECTOR // ------------------------------------------------------------- function renderImageSelector(images) { elements.imageSelectionGrid.innerHTML = ''; selectedCoverImage = ''; if (!images || images.length === 0) { elements.editImageUrl.value = ''; return; } // Pre-select first image selectedCoverImage = images[0]; elements.editImageUrl.value = images[0]; images.forEach((imgSrc, index) => { const option = document.createElement('div'); option.className = `image-option ${index === 0 ? 'selected' : ''}`; option.innerHTML = `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 // ------------------------------------------------------------- async function openSettingsModal() { // Reset visibility states and statuses elements.settingsStatusMsg.style.display = 'none'; elements.settingsGeminiKeyInput.type = 'password'; const toggleIcon = elements.btnToggleGeminiKey.querySelector('i, svg'); if (toggleIcon) toggleIcon.setAttribute('data-lucide', 'eye'); lucide.createIcons(); elements.settingsGeminiKeyInput.value = ''; elements.settingsGeminiKeyInput.placeholder = 'Loading credentials...'; openModal('settings-modal'); try { const response = await fetch('/api/settings', { headers: getAuthHeaders() }); if (response.status === 401) { logout(); closeModal('settings-modal'); return; } if (!response.ok) throw new Error('Settings could not be loaded.'); const settings = await response.json(); const key = settings.geminiApiKey ? settings.geminiApiKey.trim() : ''; const isPlaceholder = key === 'PASTE_YOUR_GEMINI_API_KEY_HERE'; elements.settingsGeminiKeyInput.placeholder = 'Enter your Gemini API key...'; if (key && !isPlaceholder) { elements.settingsGeminiKeyInput.value = key; } if (elements.settingsDefaultConsolidate && typeof settings.defaultConsolidateLists === 'boolean') { elements.settingsDefaultConsolidate.checked = settings.defaultConsolidateLists; applyUserDefaultConsolidationPreference(settings.defaultConsolidateLists); } } catch (err) { console.error('Error loading settings:', err); elements.settingsGeminiKeyInput.placeholder = 'Failed to load settings'; } } async function handleSaveSettings(e) { e.preventDefault(); elements.settingsStatusMsg.style.display = 'none'; const key = elements.settingsGeminiKeyInput.value.trim(); const defaultConsolidate = elements.settingsDefaultConsolidate ? elements.settingsDefaultConsolidate.checked : true; try { const response = await fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json', ...getAuthHeaders() }, body: JSON.stringify({ geminiApiKey: key, defaultConsolidateLists: defaultConsolidate }) }); if (response.status === 401) { logout(); closeModal('settings-modal'); return; } if (!response.ok) throw new Error('Failed to save settings file.'); applyUserDefaultConsolidationPreference(defaultConsolidate); if (elements.settingsStatusText) { elements.settingsStatusText.textContent = 'Settings saved successfully.'; } // Show success status banner elements.settingsStatusMsg.style.display = 'flex'; setTimeout(() => { elements.settingsStatusMsg.style.display = 'none'; }, 4000); } catch (err) { console.error('Error saving settings:', err); alert(`Failed to save settings: ${err.message}`); } } function toggleSettingsKeyVisibility() { const input = elements.settingsGeminiKeyInput; const toggleIcon = elements.btnToggleGeminiKey.querySelector('i, svg'); if (input.type === 'password') { input.type = 'text'; if (toggleIcon) toggleIcon.setAttribute('data-lucide', 'eye-off'); } else { input.type = 'password'; if (toggleIcon) toggleIcon.setAttribute('data-lucide', 'eye'); } lucide.createIcons(); } function showScanLoading() { elements.loadingOverlay.classList.add('active'); const title = elements.loadingOverlay.querySelector('h3'); const subtitle = elements.loadingOverlay.querySelector('.loader-subtitle'); if (title) title.textContent = 'Scanning Recipe Photo'; if (subtitle) subtitle.textContent = 'Loading parameters and initializing scanner...'; elements.loaderStepFetch.className = 'loader-step active'; elements.loaderStepParse.className = 'loader-step'; elements.loaderStepImages.className = 'loader-step'; elements.loaderStepFetch.querySelector('span:last-child').textContent = 'Loading system configuration...'; elements.loaderStepParse.querySelector('span:last-child').textContent = 'Compressing and optimizing image...'; elements.loaderStepImages.querySelector('span:last-child').textContent = 'Digitizing with Gemini Vision AI...'; setLoaderStepIcon(elements.loaderStepFetch, 'circle-dashed', true); setLoaderStepIcon(elements.loaderStepParse, 'circle', false); setLoaderStepIcon(elements.loaderStepImages, 'circle', false); lucide.createIcons(); } function hideScanLoading() { elements.loadingOverlay.classList.remove('active'); // Restore original loading screen texts const title = elements.loadingOverlay.querySelector('h3'); const subtitle = elements.loadingOverlay.querySelector('.loader-subtitle'); if (title) title.textContent = 'Extracting Recipe Data'; if (subtitle) subtitle.textContent = 'Retrieving site contents and parsing culinary structures...'; elements.loaderStepFetch.querySelector('span:last-child').textContent = 'Connecting to source URL...'; elements.loaderStepParse.querySelector('span:last-child').textContent = 'Parsing schema structures...'; elements.loaderStepImages.querySelector('span:last-child').textContent = 'Extracting high-res dish photos...'; } function compressImage(file, maxDimension = 1600) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.readAsDataURL(file); reader.onload = (event) => { const img = new Image(); img.src = event.target.result; img.onload = () => { let width = img.width; let height = img.height; // Downscale maintaining aspect ratio if (width > maxDimension || height > maxDimension) { if (width > height) { height = Math.round((height * maxDimension) / width); width = maxDimension; } else { width = Math.round((width * maxDimension) / height); height = maxDimension; } } const canvas = document.createElement('canvas'); canvas.width = width; canvas.height = height; const ctx = canvas.getContext('2d'); ctx.drawImage(img, 0, 0, width, height); const dataUrl = canvas.toDataURL('image/jpeg', 0.85); const base64 = dataUrl.split(',')[1]; resolve({ dataUrl, base64, mimeType: 'image/jpeg' }); }; img.onerror = (err) => reject(err); }; reader.onerror = (err) => reject(err); }); } async function handleRecipeImageScan(e) { const file = e.target.files[0]; if (!file) return; scanRecipeFile(file); } async function handleClipboardPaste(e) { // Only trigger scan if logged in to prevent unauthenticated access attempts if (!currentUser) return; const items = (e.clipboardData || e.originalEvent?.clipboardData)?.items; if (!items) return; for (const item of items) { if (item.kind === 'file' && item.type.startsWith('image/')) { const file = item.getAsFile(); e.preventDefault(); // Intercept paste action scanRecipeFile(file); break; } } } async function scanRecipeFile(file) { showScanLoading(); try { // 1. Fetch system-wide configuration const settingsResponse = await fetch('/api/settings', { headers: getAuthHeaders() }); if (settingsResponse.status === 401) { logout(); hideScanLoading(); return; } if (!settingsResponse.ok) { throw new Error('Could not fetch settings. Please ensure system settings are configured.'); } const settings = await settingsResponse.json(); const apiKey = settings.geminiApiKey ? settings.geminiApiKey.trim() : ''; const isPlaceholder = apiKey === 'PASTE_YOUR_GEMINI_API_KEY_HERE' || apiKey === ''; if (!apiKey || isPlaceholder) { hideScanLoading(); elements.scanImageInput.value = ''; alert('Google Gemini API Key is not configured.\n\nPlease click the Settings button at the top right, configure your Google Gemini API Key, and try again.'); return; } // Step 1 Completed -> Activate Step 2 (Compression) elements.loaderStepFetch.className = 'loader-step completed'; setLoaderStepIcon(elements.loaderStepFetch, 'check-circle-2', false); elements.loaderStepParse.className = 'loader-step active'; setLoaderStepIcon(elements.loaderStepParse, 'circle-dashed', true); lucide.createIcons(); // 2. Compress image using Canvas const compressed = await compressImage(file, 1600); // Step 2 Completed -> Activate Step 3 (Gemini transcription) elements.loaderStepParse.className = 'loader-step completed'; setLoaderStepIcon(elements.loaderStepParse, 'check-circle-2', false); elements.loaderStepImages.className = 'loader-step active'; setLoaderStepIcon(elements.loaderStepImages, 'circle-dashed', true); lucide.createIcons(); // 3. Make direct call to Google Gemini Vision API const promptText = "You are a professional culinary transcriber. Analyze the image of a recipe (which could be a cookbook page, handwritten note, screenshot, or packaging) and extract it accurately. Fill in all fields as completely as possible. The response must be a JSON object matching this schema:\n{\n \"title\": \"Recipe Title\",\n \"description\": \"Brief description or review\",\n \"servings\": \"e.g., 4\",\n \"prepTime\": \"e.g., 15 mins\",\n \"cookTime\": \"e.g., 30 mins\",\n \"totalTime\": \"e.g., 45 mins\",\n \"ingredients\": [\"ingredient 1\", \"ingredient 2\"],\n \"instructions\": [\"step 1\", \"step 2\"]\n}\nDo not include any markdown styling (like ```json) or explanation. Return only the raw JSON string."; const geminiUrl = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${apiKey}`; const geminiResponse = await fetch(geminiUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ contents: [ { parts: [ { text: promptText }, { inlineData: { mimeType: compressed.mimeType, data: compressed.base64 } } ] } ], generationConfig: { responseMimeType: "application/json" } }) }); if (!geminiResponse.ok) { const errorObj = await geminiResponse.json().catch(() => ({})); const status = geminiResponse.status; let errMsg = 'Google Gemini API call failed.'; if (status === 400) { errMsg = 'Invalid parameters or model version. Please verify your settings.'; } else if (status === 403) { errMsg = 'Access denied. The Google Gemini API key is invalid or inactive.'; } else if (status === 429) { errMsg = 'Rate limit exceeded. You have made too many requests or hit your Gemini quota.'; } else if (errorObj.error && errorObj.error.message) { errMsg = `Gemini API Error: ${errorObj.error.message}`; } throw new Error(errMsg); } const result = await geminiResponse.json(); if (!result.candidates || result.candidates.length === 0 || !result.candidates[0].content) { throw new Error('Gemini API returned an empty response. The text in the image may be unreadable.'); } const rawText = result.candidates[0].content.parts[0].text; const recipeData = JSON.parse(rawText); // Auto-inject the scanned local picture as a custom visual preview option recipeData.images = [compressed.dataUrl]; recipeData.image = compressed.dataUrl; // 4. Finalize and trigger draft preview hideScanLoading(); elements.scanImageInput.value = ''; openDraftPreview(recipeData); } catch (error) { console.error('Photo scanning error occurred:', error); hideScanLoading(); elements.scanImageInput.value = ''; alert(`Scanning Failed: ${error.message}\n\nPlease verify your internet connection and check that your Gemini API Key is correct.`); } } // ------------------------------------------------------------- // MOBILE PORTAL CONTROLS & GESTURES // ------------------------------------------------------------- function openMobileProfile() { if (!currentUser) return; elements.mobileProfileUsername.textContent = `@${currentUser.username}`; // Set role text let roleText = 'Chef Account'; if (currentUser.isAdmin) { roleText = 'Administrator'; } else if (currentUser.isRecipeManager) { roleText = 'Recipe Manager'; } elements.mobileProfileRole.textContent = roleText; // Handle theme icon inside profile drawer const isDark = document.body.classList.contains('dark-theme'); const sunIcon = elements.mobileBtnThemeToggle.querySelector('.sun-icon'); const moonIcon = elements.mobileBtnThemeToggle.querySelector('.moon-icon'); if (sunIcon && moonIcon) { if (isDark) { sunIcon.style.display = 'block'; moonIcon.style.display = 'none'; } else { sunIcon.style.display = 'none'; moonIcon.style.display = 'block'; } } openModal('mobile-profile-modal'); } function handleFabClick() { // If not on cookbook tab, switch back to list first if (activeTab !== 'my-cookbook') { switchTab('my-cookbook'); } // Open the manual recipe creator openManualAdd(); } // ------------------------------------------------------------- // SHOPPING LISTS MANAGEMENT & CONSOLIDATION ENGINE // ------------------------------------------------------------- const AISLE_ORDER = [ 'Produce', 'Meat & Seafood', 'Dairy & Eggs', 'Canned & Jarred', 'Bakery & Grains', 'Pantry & Spices', 'Frozen', 'Other' ]; const AISLE_ICONS = { 'Produce': 'apple', 'Meat & Seafood': 'beef', 'Dairy & Eggs': 'milk', 'Canned & Jarred': 'Archive', 'Bakery & Grains': 'wheat', 'Pantry & Spices': 'flame', 'Frozen': 'snowflake', 'Other': 'shopping-bag' }; function applyUserDefaultConsolidationPreference(prefValue) { const shouldConsolidate = prefValue !== false; consolidateItems = shouldConsolidate; if (elements.toggleConsolidateItems) { elements.toggleConsolidateItems.checked = shouldConsolidate; } if (elements.settingsDefaultConsolidate) { elements.settingsDefaultConsolidate.checked = shouldConsolidate; } if (currentUser) { currentUser.defaultConsolidateLists = shouldConsolidate; localStorage.setItem('gourmet_user', JSON.stringify(currentUser)); } } async function syncUserPreferencesFromServer() { try { const response = await fetch('/api/settings', { headers: getAuthHeaders() }); if (response.ok) { const data = await response.json(); if (typeof data.defaultConsolidateLists === 'boolean') { applyUserDefaultConsolidationPreference(data.defaultConsolidateLists); } } } catch (err) { console.warn('Failed to sync user preferences:', err); } } async function handleDefaultConsolidatePreferenceChange(e) { const newPref = e.target.checked; try { const response = await fetch('/api/user/preferences', { method: 'PUT', headers: { 'Content-Type': 'application/json', ...getAuthHeaders() }, body: JSON.stringify({ defaultConsolidateLists: newPref }) }); if (response.status === 401) { logout(); return; } if (!response.ok) throw new Error('Failed to save default consolidation preference.'); applyUserDefaultConsolidationPreference(newPref); renderShoppingListsWorkspace(); if (elements.settingsStatusText && elements.settingsStatusMsg) { elements.settingsStatusText.textContent = 'Default consolidation preference updated.'; elements.settingsStatusMsg.style.display = 'flex'; setTimeout(() => { elements.settingsStatusMsg.style.display = 'none'; }, 3000); } } catch (err) { console.error('Error updating consolidation preference:', err); } } async function fetchShoppingLists() { if (!currentUser) return; try { const response = await fetch('/api/shopping-lists', { headers: getAuthHeaders() }); if (response.status === 401) { logout(); return; } if (!response.ok) throw new Error('Failed to load shopping lists.'); shoppingLists = await response.json(); if (!activeShoppingListId || !shoppingLists.some(l => l.id === activeShoppingListId)) { activeShoppingListId = shoppingLists.length > 0 ? shoppingLists[0].id : null; } renderShoppingListsWorkspace(); populateDetailShoppingListSelect(); } catch (error) { console.error('Error loading shopping lists:', error); } } function getActiveShoppingList() { return shoppingLists.find(l => l.id === activeShoppingListId) || shoppingLists[0] || null; } function updateShoppingListInState(updatedList) { const idx = shoppingLists.findIndex(l => l.id === updatedList.id); if (idx !== -1) { shoppingLists[idx] = updatedList; } else { shoppingLists.push(updatedList); } activeShoppingListId = updatedList.id; renderShoppingListsWorkspace(); populateDetailShoppingListSelect(); } function updateShoppingTabBadges() { const activeList = getActiveShoppingList(); const uncheckedCount = activeList && Array.isArray(activeList.items) ? activeList.items.filter(i => !i.checked).length : 0; if (elements.shoppingTabBadge) { if (uncheckedCount > 0) { elements.shoppingTabBadge.textContent = uncheckedCount; elements.shoppingTabBadge.style.display = 'inline-block'; } else { elements.shoppingTabBadge.style.display = 'none'; } } if (elements.mobileShoppingTabBadge) { if (uncheckedCount > 0) { elements.mobileShoppingTabBadge.textContent = uncheckedCount; elements.mobileShoppingTabBadge.style.display = 'flex'; } else { elements.mobileShoppingTabBadge.style.display = 'none'; } } } function populateDetailShoppingListSelect() { if (!elements.detailShoppingListSelect) return; elements.detailShoppingListSelect.innerHTML = ''; shoppingLists.forEach(list => { const opt = document.createElement('option'); opt.value = list.id; opt.textContent = list.name; if (list.id === activeShoppingListId) opt.selected = true; elements.detailShoppingListSelect.appendChild(opt); }); const newOpt = document.createElement('option'); newOpt.value = '__new__'; newOpt.textContent = '+ Create New List...'; elements.detailShoppingListSelect.appendChild(newOpt); } async function handleDetailListSelectChange(e) { if (e.target.value === '__new__') { const name = prompt('Enter a name for the new shopping list:', 'Weekend Groceries'); if (name && name.trim()) { try { const res = await fetch('/api/shopping-lists', { method: 'POST', headers: { 'Content-Type': 'application/json', ...getAuthHeaders() }, body: JSON.stringify({ name: name.trim() }) }); if (res.ok) { const created = await res.json(); updateShoppingListInState(created); elements.detailShoppingListSelect.value = created.id; return; } } catch (err) { console.error('Error creating list from detail modal:', err); } } populateDetailShoppingListSelect(); } else { activeShoppingListId = e.target.value; renderShoppingListsWorkspace(); } } let addRecipeBtnTimeout = null; async function handleAddRecipeToShoppingList() { if (!activeRecipe) return; let targetListId = elements.detailShoppingListSelect ? elements.detailShoppingListSelect.value : activeShoppingListId; if (!targetListId || targetListId === '__new__') { const activeList = getActiveShoppingList(); if (!activeList) return; targetListId = activeList.id; } // Gather unchecked ingredients from the Recipe Detail checklist (or all if all are checked) const listElements = Array.from(elements.recipeDetailIngredients.querySelectorAll('.ingredient-checkbox-item')); let selectedLines = listElements .filter(li => !li.classList.contains('checked')) .map(li => li.querySelector('.ingredient-text')?.textContent?.trim()) .filter(Boolean); if (selectedLines.length === 0 && listElements.length > 0) { selectedLines = listElements .map(li => li.querySelector('.ingredient-text')?.textContent?.trim()) .filter(Boolean); } if (selectedLines.length === 0) return; try { const response = await fetch(`/api/shopping-lists/${targetListId}/items`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...getAuthHeaders() }, body: JSON.stringify({ items: selectedLines, recipeId: activeRecipe.id, recipeTitle: activeRecipe.title }) }); if (response.status === 401) { logout(); return; } if (!response.ok) throw new Error('Failed to add ingredients to shopping list.'); const updatedList = await response.json(); updateShoppingListInState(updatedList); if (elements.btnAddRecipeToList) { if (addRecipeBtnTimeout) clearTimeout(addRecipeBtnTimeout); elements.btnAddRecipeToList.innerHTML = ` Added (${selectedLines.length})!`; elements.btnAddRecipeToList.style.backgroundColor = 'var(--accent)'; elements.btnAddRecipeToList.style.borderColor = 'var(--accent)'; lucide.createIcons(); addRecipeBtnTimeout = setTimeout(() => { elements.btnAddRecipeToList.innerHTML = ` Add to List`; elements.btnAddRecipeToList.style.backgroundColor = ''; elements.btnAddRecipeToList.style.borderColor = ''; lucide.createIcons(); }, 2000); } } catch (error) { console.error('Error adding recipe to shopping list:', error); alert('Failed to add ingredients to shopping list.'); } } async function handleCreateShoppingList() { const name = prompt('Enter a name for your new shopping list:', ''); if (!name || !name.trim()) return; try { const response = await fetch('/api/shopping-lists', { method: 'POST', headers: { 'Content-Type': 'application/json', ...getAuthHeaders() }, body: JSON.stringify({ name: name.trim() }) }); if (response.status === 401) { logout(); return; } if (!response.ok) throw new Error('Failed to create shopping list.'); const created = await response.json(); updateShoppingListInState(created); } catch (err) { console.error('Error creating shopping list:', err); alert('Could not create shopping list.'); } } async function handleRenameShoppingList() { const activeList = getActiveShoppingList(); if (!activeList) return; const newName = prompt('Rename shopping list:', activeList.name); if (!newName || !newName.trim() || newName.trim() === activeList.name) return; try { const response = await fetch(`/api/shopping-lists/${activeList.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json', ...getAuthHeaders() }, body: JSON.stringify({ name: newName.trim() }) }); if (response.status === 401) { logout(); return; } if (!response.ok) throw new Error('Failed to rename shopping list.'); const updated = await response.json(); updateShoppingListInState(updated); } catch (err) { console.error('Error renaming shopping list:', err); alert('Could not rename shopping list.'); } } async function handleDeleteShoppingList() { const activeList = getActiveShoppingList(); if (!activeList) return; if (!confirm(`Are you sure you want to delete the shopping list "${activeList.name}" and all its items?`)) return; try { const response = await fetch(`/api/shopping-lists/${activeList.id}`, { method: 'DELETE', headers: getAuthHeaders() }); if (response.status === 401) { logout(); return; } if (!response.ok) throw new Error('Failed to delete shopping list.'); activeShoppingListId = null; await fetchShoppingLists(); } catch (err) { console.error('Error deleting shopping list:', err); alert('Could not delete shopping list.'); } } async function handleAddManualShoppingItem(e) { e.preventDefault(); const activeList = getActiveShoppingList(); if (!activeList) return; const text = elements.shoppingAddItemInput.value.trim(); if (!text) return; try { const response = await fetch(`/api/shopping-lists/${activeList.id}/items`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...getAuthHeaders() }, body: JSON.stringify({ items: [text] }) }); if (response.status === 401) { logout(); return; } if (!response.ok) throw new Error('Failed to add item.'); const updated = await response.json(); elements.shoppingAddItemInput.value = ''; updateShoppingListInState(updated); } catch (err) { console.error('Error adding manual item:', err); alert('Could not add item.'); } } async function handleToggleShoppingItems(itemIds, checked) { const activeList = getActiveShoppingList(); if (!activeList || !Array.isArray(itemIds) || itemIds.length === 0) return; try { const response = await fetch(`/api/shopping-lists/${activeList.id}/items/check`, { method: 'PUT', headers: { 'Content-Type': 'application/json', ...getAuthHeaders() }, body: JSON.stringify({ itemIds, checked }) }); if (response.status === 401) { logout(); return; } if (!response.ok) throw new Error('Failed to toggle item.'); const updated = await response.json(); updateShoppingListInState(updated); } catch (err) { console.error('Error toggling shopping item:', err); } } async function handleDeleteShoppingItems(itemIds) { const activeList = getActiveShoppingList(); if (!activeList || !Array.isArray(itemIds) || itemIds.length === 0) return; try { const response = await fetch(`/api/shopping-lists/${activeList.id}/items/delete`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...getAuthHeaders() }, body: JSON.stringify({ itemIds }) }); if (response.status === 401) { logout(); return; } if (!response.ok) throw new Error('Failed to delete item.'); const updated = await response.json(); updateShoppingListInState(updated); } catch (err) { console.error('Error deleting shopping item:', err); } } async function handleClearShoppingList(onlyChecked) { const activeList = getActiveShoppingList(); if (!activeList) return; if (!onlyChecked && !confirm(`Are you sure you want to clear all items from "${activeList.name}"?`)) return; try { const response = await fetch(`/api/shopping-lists/${activeList.id}/clear`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...getAuthHeaders() }, body: JSON.stringify({ onlyChecked }) }); if (response.status === 401) { logout(); return; } if (!response.ok) throw new Error('Failed to clear items.'); const updated = await response.json(); updateShoppingListInState(updated); } catch (err) { console.error('Error clearing shopping list:', err); } } let shoppingShareBtnTimeout = null; let shoppingCopyTextTimeout = null; async function handleShareShoppingList() { const activeList = getActiveShoppingList(); if (!activeList) return; try { const response = await fetch(`/api/shopping-lists/${activeList.id}/share`, { method: 'POST', headers: getAuthHeaders() }); if (response.status === 401) { logout(); return; } if (!response.ok) throw new Error('Failed to generate share link.'); const updated = await response.json(); updateShoppingListInState(updated); const shareUrl = `${window.location.origin}/share-list.html?token=${updated.shareToken}`; await copyTextToClipboard(shareUrl); if (elements.btnShoppingShare) { if (shoppingShareBtnTimeout) clearTimeout(shoppingShareBtnTimeout); elements.btnShoppingShare.innerHTML = ` Copied!`; elements.btnShoppingShare.style.borderColor = 'var(--accent)'; elements.btnShoppingShare.style.color = 'var(--accent)'; lucide.createIcons(); shoppingShareBtnTimeout = setTimeout(() => { elements.btnShoppingShare.innerHTML = ` Share Link`; elements.btnShoppingShare.style.borderColor = ''; elements.btnShoppingShare.style.color = ''; lucide.createIcons(); }, 2000); } } catch (err) { console.error('Error sharing shopping list:', err); alert('Failed to generate or copy share link.'); } } async function handleRevokeShoppingListShare() { const activeList = getActiveShoppingList(); if (!activeList) return; if (!confirm(`Disable the public share link for "${activeList.name}" and reset visitor stats?`)) return; try { const response = await fetch(`/api/shopping-lists/${activeList.id}/revoke-share`, { method: 'POST', headers: getAuthHeaders() }); if (response.status === 401) { logout(); return; } if (!response.ok) throw new Error('Failed to revoke share link.'); const updated = await response.json(); updateShoppingListInState(updated); } catch (err) { console.error('Error revoking shopping list share:', err); } } async function handleCopyShoppingListText() { const activeList = getActiveShoppingList(); if (!activeList || !Array.isArray(activeList.items) || activeList.items.length === 0) return; const displayEntries = buildShoppingListDisplayEntries(activeList.items, consolidateItems); const grouped = groupShoppingEntries(displayEntries, shoppingGroupBy); const lines = [`๐Ÿ›’ ${activeList.name}`]; for (const [groupName, entries] of grouped.entries()) { lines.push(`\n[${groupName}]`); entries.forEach(entry => { const box = entry.checked ? '[x]' : '[ ]'; lines.push(`${box} ${entry.displayText}`); }); } try { await copyTextToClipboard(lines.join('\n')); if (elements.btnShoppingCopyText) { if (shoppingCopyTextTimeout) clearTimeout(shoppingCopyTextTimeout); elements.btnShoppingCopyText.innerHTML = ` Copied!`; elements.btnShoppingCopyText.style.borderColor = 'var(--accent)'; elements.btnShoppingCopyText.style.color = 'var(--accent)'; lucide.createIcons(); shoppingCopyTextTimeout = setTimeout(() => { elements.btnShoppingCopyText.innerHTML = ` Copy Text`; elements.btnShoppingCopyText.style.borderColor = ''; elements.btnShoppingCopyText.style.color = ''; lucide.createIcons(); }, 2000); } } catch (err) { console.error('Error copying shopping list text:', err); } } function buildShoppingListDisplayEntries(items, shouldConsolidate) { if (!shouldConsolidate) { return items.map(item => ({ itemIds: [item.id], displayText: item.rawText, category: item.category || 'Other', sources: item.recipeTitle ? [item.recipeTitle] : [], checked: !!item.checked })); } // Group items by normalized itemName + checked state + unit so compatible items sum cleanly const groups = new Map(); for (const item of items) { const normName = (item.itemName || item.rawText || '').toLowerCase().trim(); const normUnit = (item.unit || '').toLowerCase().trim(); const key = `${item.checked ? '1' : '0'}::${normName}::${normUnit}`; if (!groups.has(key)) { groups.set(key, { itemIds: [item.id], rawTexts: [item.rawText], itemName: normName, unit: normUnit, totalQty: item.quantity !== null && item.quantity !== undefined ? Number(item.quantity) : null, hasNullQty: item.quantity === null || item.quantity === undefined, category: item.category || 'Other', sources: item.recipeTitle ? [item.recipeTitle] : [], checked: !!item.checked }); } else { const g = groups.get(key); g.itemIds.push(item.id); g.rawTexts.push(item.rawText); if (item.quantity !== null && item.quantity !== undefined && !g.hasNullQty) { g.totalQty = (g.totalQty || 0) + Number(item.quantity); } else { g.hasNullQty = true; } if (item.recipeTitle && !g.sources.includes(item.recipeTitle)) { g.sources.push(item.recipeTitle); } } } const results = []; for (const g of groups.values()) { let displayText = g.rawTexts[0]; if (g.itemIds.length > 1) { if (!g.hasNullQty && g.totalQty !== null && g.totalQty > 0) { const qtyStr = formatScaledNumber(g.totalQty); const unitStr = g.unit ? (g.totalQty > 1 && !g.unit.endsWith('s') && ['clove', 'can', 'jar', 'bunch', 'slice', 'piece', 'head', 'stalk', 'sprig', 'stick', 'cup'].includes(g.unit) ? `${g.unit}s ` : `${g.unit} `) : ''; const displayItem = g.itemName.charAt(0).toUpperCase() + g.itemName.slice(1); displayText = `${qtyStr} ${unitStr}${displayItem}`; } else { const displayItem = g.itemName.charAt(0).toUpperCase() + g.itemName.slice(1); displayText = `${displayItem} (ร—${g.itemIds.length})`; } } results.push({ itemIds: g.itemIds, displayText, category: g.category, sources: g.sources, checked: g.checked }); } return results; } function groupShoppingEntries(entries, mode) { const grouped = new Map(); if (mode === 'recipe') { for (const entry of entries) { const groupLabels = entry.sources.length > 0 ? [entry.sources.join(' + ')] : ['Manual / Household Staples']; for (const label of groupLabels) { if (!grouped.has(label)) grouped.set(label, []); grouped.get(label).push(entry); } } return grouped; } // Default: Group by Grocery Aisle in store walk-through order for (const aisle of AISLE_ORDER) { const matching = entries.filter(e => (e.category || 'Other') === aisle); if (matching.length > 0) { // Sort unchecked first matching.sort((a, b) => Number(a.checked) - Number(b.checked)); grouped.set(aisle, matching); } } return grouped; } function renderShoppingListsWorkspace() { if (!elements.shoppingListSelect) return; // Populate select options elements.shoppingListSelect.innerHTML = ''; shoppingLists.forEach(list => { const opt = document.createElement('option'); opt.value = list.id; const unchecked = Array.isArray(list.items) ? list.items.filter(i => !i.checked).length : 0; opt.textContent = `${list.name} (${unchecked})`; if (list.id === activeShoppingListId) opt.selected = true; elements.shoppingListSelect.appendChild(opt); }); updateShoppingTabBadges(); const activeList = getActiveShoppingList(); if (!activeList) { elements.shoppingListsContent.innerHTML = ''; elements.shoppingEmptyState.style.display = 'flex'; return; } // Share status UI if (activeList.shareToken) { elements.btnShoppingRevokeShare.style.display = 'inline-flex'; elements.shoppingShareStatsBadge.style.display = 'inline-flex'; const vCount = activeList.visitorCount || 0; elements.shoppingShareStatsBadge.querySelector('.val').textContent = `${vCount} Unique ${vCount === 1 ? 'Visitor' : 'Visitors'}`; } else { elements.btnShoppingRevokeShare.style.display = 'none'; elements.shoppingShareStatsBadge.style.display = 'none'; } const items = activeList.items || []; if (items.length === 0) { elements.shoppingListsContent.innerHTML = ''; elements.shoppingEmptyState.style.display = 'flex'; lucide.createIcons(); return; } elements.shoppingEmptyState.style.display = 'none'; elements.shoppingListsContent.innerHTML = ''; const entries = buildShoppingListDisplayEntries(items, consolidateItems); const grouped = groupShoppingEntries(entries, shoppingGroupBy); for (const [groupName, groupEntries] of grouped.entries()) { const card = document.createElement('div'); card.className = 'shopping-category-card'; const iconName = AISLE_ICONS[groupName] || 'utensils'; const uncheckedInGroup = groupEntries.filter(e => !e.checked).length; card.innerHTML = `
${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 }; } 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) { elements.backupGdriveFolderSelect.innerHTML = ``; } } if (elements.btnGdriveNewFolder) { elements.btnGdriveNewFolder.disabled = !isConnected; } if (elements.btnGdriveRefreshFolders) { elements.btnGdriveRefreshFolders.disabled = !isConnected; } if (isConnected) { loadGoogleDriveFolders(false, cfg.gdriveFolderId, cfg.gdriveFolderName); } } async function loadGoogleDriveFolders(showFeedback = false, fallbackId = '', fallbackName = 'ForkFolio Backups') { if (!elements.backupGdriveFolderSelect) return; try { 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 || []); } 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 have been recorded yet. Configure Wasabi S3 or Google Drive above and click "Backup to Cloud Now", or use "Download .DB" / "Download .JSON" for immediate local computer downloads. `; return; } history.forEach(item => { const tr = document.createElement('tr'); const dateStr = new Date(item.createdAt).toLocaleString(); const triggerBadge = item.triggerType === 'scheduled' ? `Scheduled` : `Manual`; const destBadges = []; if (item.s3KeyDb) { destBadges.push(`Wasabi S3`); } if (item.gdriveFileIdDb) { destBadges.push(`Google Drive`); } const statusBadge = item.status === 'success' ? `โœ“ Success` : `โš  Error`; const hasCloudFiles = Boolean(item.s3KeyDb || item.gdriveFileIdDb); tr.innerHTML = ` ${dateStr} ${triggerBadge} ${item.userCount} users ยท ${item.recipeCount} recipes ยท ${item.shoppingListCount} lists DB: ${formatBytesHuman(item.dbSizeBytes)}
JSON: ${formatBytesHuman(item.jsonSizeBytes)} ${destBadges.join('') || 'None'} ${statusBadge}${item.errorMessage ? `
${item.errorMessage}
` : ''} ${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) { const confirmed = window.confirm( `Are you sure you want to restore the entire ForkFolio database from the cloud backup taken on ${dateLabel}?\n\nThis will overwrite current users, recipes, and shopping lists with the snapshot.` ); if (!confirmed) return; try { showBackupStatus('Downloading and restoring database from cloud snapshot...', 'info'); const res = await fetch(`/api/admin/backups/${id}/restore-cloud`, { method: 'POST', headers: getAuthHeaders() }); const data = await res.json(); if (!res.ok) throw new Error(data.error || 'Cloud restore failed.'); showBackupStatus( `Database restored (${data.restored.userCount} users, ${data.restored.recipeCount} recipes, ${data.restored.shoppingListCount} shopping lists)!`, 'success' ); await fetchRecipes(); await fetchShoppingLists(); fetchAdminBackupConfig(); } catch (err) { showBackupStatus(err.message, 'error'); } } async function deleteCloudBackupItem(id, dateLabel) { const confirmed = window.confirm(`Delete the cloud backup from ${dateLabel} from remote storage and history?`); if (!confirmed) return; try { const res = await fetch(`/api/admin/backups/${id}`, { method: 'DELETE', headers: getAuthHeaders() }); const data = await res.json(); if (!res.ok) throw new Error(data.error || 'Failed to delete backup.'); showBackupStatus('Cloud backup deleted.', 'success'); fetchAdminBackupConfig(); } catch (err) { showBackupStatus(err.message, 'error'); } } async function handleRestoreLocalFile(e) { const file = e.target.files && e.target.files[0]; e.target.value = ''; if (!file) return; const confirmed = window.confirm( `Are you sure you want to restore the entire ForkFolio database from "${file.name}"?\n\nThis will replace current users, recipes, images, and shopping lists with the contents of this backup file.` ); if (!confirmed) return; try { showBackupStatus(`Uploading and restoring "${file.name}"...`, 'info'); const base64Data = await new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => { const result = String(reader.result || ''); const commaIdx = result.indexOf(','); resolve(commaIdx >= 0 ? result.slice(commaIdx + 1) : result); }; reader.onerror = () => reject(new Error('Failed to read selected file.')); reader.readAsDataURL(file); }); const res = await fetch('/api/admin/backups/restore-upload', { method: 'POST', headers: getAuthHeaders(), body: JSON.stringify({ filename: file.name, dataBase64: base64Data }) }); const data = await res.json(); if (!res.ok) throw new Error(data.error || 'Failed to restore from uploaded file.'); showBackupStatus( `Restored from "${file.name}" (${data.restored.userCount} users, ${data.restored.recipeCount} recipes, ${data.restored.shoppingListCount} shopping lists)!`, 'success' ); await fetchRecipes(); await fetchShoppingLists(); fetchAdminBackupConfig(); } catch (err) { showBackupStatus(err.message, 'error'); } }