recipe-manager/public/app.js

4173 lines
155 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

// ForkFolio client application state
let allRecipes = [];
let activeRecipe = null;
let currentMultiplier = 1;
let selectedCoverImage = '';
let stopwatchInterval = null;
let stopwatchSeconds = 0;
let cookingActiveStep = 0;
let currentUser = null;
let activeTab = 'my-cookbook';
let shoppingLists = [];
let activeShoppingListId = null;
let consolidateItems = true;
let shoppingGroupBy = 'aisle';
// Page elements caching
const elements = {
themeToggle: document.getElementById('theme-toggle'),
urlScrapeForm: document.getElementById('url-scrape-form'),
scrapeUrlInput: document.getElementById('scrape-url-input'),
btnScrapeSubmit: document.getElementById('btn-scrape-submit'),
btnManualAdd: document.getElementById('btn-manual-add'),
btnSettings: document.getElementById('btn-settings'),
settingsModal: document.getElementById('settings-modal'),
settingsApiForm: document.getElementById('settings-api-form'),
settingsGeminiKeyInput: document.getElementById('settings-gemini-key'),
btnToggleGeminiKey: document.getElementById('btn-toggle-gemini-key'),
settingsStatusMsg: document.getElementById('settings-status-msg'),
settingsStatusText: document.getElementById('settings-status-text'),
scanImageInput: document.getElementById('scan-image-input'),
btnScanImage: document.getElementById('btn-scan-image'),
recipesGrid: document.getElementById('recipes-grid'),
emptyState: document.getElementById('empty-state'),
librarySearch: document.getElementById('library-search'),
// Auth caching
authModal: document.getElementById('auth-modal'),
loginForm: document.getElementById('login-form'),
authErrorMsg: document.getElementById('auth-error-msg'),
authErrorText: document.getElementById('auth-error-text'),
loginUsernameInput: document.getElementById('login-username'),
loginPasswordInput: document.getElementById('login-password'),
userBadgeContainer: document.getElementById('user-badge-container'),
userDisplayName: document.getElementById('user-display-name'),
btnLogout: document.getElementById('btn-logout'),
tabMyCookbook: document.getElementById('tab-my-cookbook'),
tabCommunityVault: document.getElementById('tab-community-vault'),
tabUserAdmin: document.getElementById('tab-user-admin'),
userAdminPanel: document.getElementById('user-admin-panel'),
adminUsersList: document.getElementById('admin-users-list'),
btnAdminAddUser: document.getElementById('btn-admin-add-user'),
btnAdminTabUsers: document.getElementById('btn-admin-tab-users'),
btnAdminTabShares: document.getElementById('btn-admin-tab-shares'),
btnAdminTabBackups: document.getElementById('btn-admin-tab-backups'),
adminUsersSection: document.getElementById('admin-users-section'),
adminSharesSection: document.getElementById('admin-shares-section'),
adminBackupsSection: document.getElementById('admin-backups-section'),
adminSharesList: document.getElementById('admin-shares-list'),
// Admin Backups Console Elements
btnBackupRunCloud: document.getElementById('btn-backup-run-cloud'),
btnBackupDownloadDb: document.getElementById('btn-backup-download-db'),
btnBackupDownloadJson: document.getElementById('btn-backup-download-json'),
backupRestoreFileInput: document.getElementById('backup-restore-file-input'),
btnBackupRestoreLocal: document.getElementById('btn-backup-restore-local'),
backupStatusBanner: document.getElementById('backup-status-banner'),
adminBackupConfigForm: document.getElementById('admin-backup-config-form'),
backupLastRunBadge: document.getElementById('backup-last-run-badge'),
backupScheduleFrequency: document.getElementById('backup-schedule-frequency'),
backupScheduleTime: document.getElementById('backup-schedule-time'),
backupScheduleDay: document.getElementById('backup-schedule-day'),
backupRetentionCount: document.getElementById('backup-retention-count'),
backupFormat: document.getElementById('backup-format'),
backupEmbedImages: document.getElementById('backup-embed-images'),
backupS3Enabled: document.getElementById('backup-s3-enabled'),
backupS3Endpoint: document.getElementById('backup-s3-endpoint'),
backupS3Region: document.getElementById('backup-s3-region'),
backupS3Bucket: document.getElementById('backup-s3-bucket'),
backupS3Prefix: document.getElementById('backup-s3-prefix'),
backupS3AccessKey: document.getElementById('backup-s3-access-key'),
backupS3SecretKey: document.getElementById('backup-s3-secret-key'),
btnBackupTestS3: document.getElementById('btn-backup-test-s3'),
backupGdriveEnabled: document.getElementById('backup-gdrive-enabled'),
gdriveStatusDot: document.getElementById('gdrive-status-dot'),
gdriveStatusTitle: document.getElementById('gdrive-status-title'),
gdriveStatusSubtitle: document.getElementById('gdrive-status-subtitle'),
btnAuthorizeGdriveLabel: document.getElementById('btn-authorize-gdrive-label'),
btnBackupAuthorizeGdrive: document.getElementById('btn-backup-authorize-gdrive'),
btnBackupDisconnectGdrive: document.getElementById('btn-backup-disconnect-gdrive'),
backupGdriveFolderSelect: document.getElementById('backup-gdrive-folder-select'),
btnGdriveNewFolder: document.getElementById('btn-gdrive-new-folder'),
btnGdriveRefreshFolders: document.getElementById('btn-gdrive-refresh-folders'),
gdriveFolderHint: document.getElementById('gdrive-folder-hint'),
gdriveInlineStatus: document.getElementById('gdrive-inline-status'),
btnBackupTestGdrive: document.getElementById('btn-backup-test-gdrive'),
btnBackupRestoreCloudModal: document.getElementById('btn-backup-restore-cloud-modal'),
btnSyncCloudBackups: document.getElementById('btn-sync-cloud-backups'),
restoreCloudModal: document.getElementById('restore-cloud-modal'),
cloudRestoreProviderFilter: document.getElementById('cloud-restore-provider-filter'),
btnRefreshCloudModal: document.getElementById('btn-refresh-cloud-modal'),
cloudRestoreModalStatus: document.getElementById('cloud-restore-modal-status'),
cloudRestoreFilesContainer: document.getElementById('cloud-restore-files-container'),
restoreScopeModal: document.getElementById('restore-scope-modal'),
restoreScopeBackupSummary: document.getElementById('restore-scope-backup-summary'),
restoreScopeCardAll: document.getElementById('restore-scope-card-all'),
restoreScopeAll: document.getElementById('restore-scope-all'),
restoreScopeAllDesc: document.getElementById('restore-scope-all-desc'),
restoreScopeCardSingle: document.getElementById('restore-scope-card-single'),
restoreScopeSingle: document.getElementById('restore-scope-single'),
restoreTargetUserSelect: document.getElementById('restore-target-user-select'),
restoreScopeStatus: document.getElementById('restore-scope-status'),
btnConfirmScopedRestore: document.getElementById('btn-confirm-scoped-restore'),
adminBackupsHistoryList: document.getElementById('admin-backups-history-list'),
// Admin Create User Modal
adminCreateUserModal: document.getElementById('admin-create-user-modal'),
adminCreateUserForm: document.getElementById('admin-create-user-form'),
adminNewUsername: document.getElementById('admin-new-username'),
adminNewPassword: document.getElementById('admin-new-password'),
adminCreateError: document.getElementById('admin-create-error'),
adminCreateErrorText: document.getElementById('admin-create-error-text'),
// Admin Delete User Modal
adminDeleteUserModal: document.getElementById('admin-delete-user-modal'),
deleteUserTargetId: document.getElementById('delete-user-target-id'),
deleteUserTargetName: document.getElementById('delete-user-target-name'),
deleteUserPreserveRecipes: document.getElementById('delete-user-preserve-recipes'),
btnAdminConfirmDelete: document.getElementById('btn-admin-confirm-delete'),
// Scrape / Edit Modal
saveModal: document.getElementById('save-modal'),
recipeEditForm: document.getElementById('recipe-edit-form'),
editRecipeId: document.getElementById('edit-recipe-id'),
editTitle: document.getElementById('edit-title'),
editSource: document.getElementById('edit-source'),
editDescription: document.getElementById('edit-description'),
editServings: document.getElementById('edit-servings'),
editPrepTime: document.getElementById('edit-prep-time'),
editCookTime: document.getElementById('edit-cook-time'),
imageSelectionGrid: document.getElementById('image-selection-grid'),
editImageUrl: document.getElementById('edit-image-url'),
editIngredients: document.getElementById('edit-ingredients'),
editInstructions: document.getElementById('edit-instructions'),
modalActionTitle: document.getElementById('modal-action-title'),
btnSaveConfirm: document.getElementById('btn-save-confirm'),
editIsPublic: document.getElementById('edit-is-public'),
// Detail Modal
detailModal: document.getElementById('detail-modal'),
recipeDetailBanner: document.getElementById('recipe-detail-banner'),
btnDetailEdit: document.getElementById('btn-detail-edit'),
btnDetailDelete: document.getElementById('btn-detail-delete'),
btnDetailClone: document.getElementById('btn-detail-clone'),
btnDetailShare: document.getElementById('btn-detail-share'),
btnDetailRevokeShare: document.getElementById('btn-detail-revoke-share'),
tagShareStats: document.getElementById('tag-share-stats'),
recipeDetailCreator: document.getElementById('recipe-detail-creator'),
recipeDetailCreatorName: document.getElementById('recipe-detail-creator-name'),
recipeDetailTitle: document.getElementById('recipe-detail-title'),
tagServings: document.getElementById('tag-servings'),
tagPrep: document.getElementById('tag-prep'),
tagCook: document.getElementById('tag-cook'),
tagSource: document.getElementById('tag-source'),
recipeDetailDescription: document.getElementById('recipe-detail-description'),
recipeDetailIngredients: document.getElementById('recipe-detail-ingredients'),
recipeDetailInstructions: document.getElementById('recipe-detail-instructions'),
scalerMinus: document.getElementById('scaler-minus'),
scalerPlus: document.getElementById('scaler-plus'),
currentServingsVal: document.getElementById('current-servings-val'),
btnTriggerCookmode: document.getElementById('btn-trigger-cookmode'),
// Cooking Mode Modal
cookingModeModal: document.getElementById('cooking-mode-modal'),
cookRecipeTitle: document.getElementById('cook-recipe-title'),
cookTimerVal: document.getElementById('cook-timer-val'),
cookTimerPlay: document.getElementById('cook-timer-play'),
cookTimerReset: document.getElementById('cook-timer-reset'),
cookProgressBar: document.getElementById('cook-progress-bar'),
cookStepIndex: document.getElementById('cook-step-index'),
cookStepInstruction: document.getElementById('cook-step-instruction'),
cookBtnPrev: document.getElementById('cook-btn-prev'),
cookBtnNext: document.getElementById('cook-btn-next'),
cookStepDots: document.getElementById('cook-step-dots'),
// Loader
loadingOverlay: document.getElementById('loading-overlay'),
loaderStepFetch: document.getElementById('loader-step-fetch'),
loaderStepParse: document.getElementById('loader-step-parse'),
loaderStepImages: document.getElementById('loader-step-images'),
// Mobile Components
fabAddRecipe: document.getElementById('mobile-header-btn-add'),
mobileTabCookbook: document.getElementById('mobile-header-tab-cookbook'),
mobileTabCommunity: document.getElementById('mobile-header-tab-community'),
mobileTabShopping: document.getElementById('mobile-header-tab-shopping'),
mobileShoppingTabBadge: document.getElementById('mobile-shopping-tab-badge'),
mobileTabProfile: document.getElementById('mobile-header-btn-profile'),
mobileProfileModal: document.getElementById('mobile-profile-modal'),
mobileProfileUsername: document.getElementById('mobile-profile-username'),
mobileProfileRole: document.getElementById('mobile-profile-role'),
mobileBtnThemeToggle: document.getElementById('mobile-btn-theme-toggle'),
mobileBtnSettings: document.getElementById('mobile-btn-settings'),
mobileBtnAdmin: document.getElementById('mobile-btn-admin'),
mobileBtnLogout: document.getElementById('mobile-btn-logout'),
headerLogo: document.getElementById('header-logo'),
libraryViewTitle: document.getElementById('library-view-title'),
// Shopping Lists Components
tabShoppingLists: document.getElementById('tab-shopping-lists'),
shoppingTabBadge: document.getElementById('shopping-tab-badge'),
shoppingListsPanel: document.getElementById('shopping-lists-panel'),
shoppingListSelect: document.getElementById('shopping-list-select'),
btnShoppingNewList: document.getElementById('btn-shopping-new-list'),
btnShoppingRenameList: document.getElementById('btn-shopping-rename-list'),
btnShoppingDeleteList: document.getElementById('btn-shopping-delete-list'),
shoppingShareStatsBadge: document.getElementById('shopping-share-stats-badge'),
btnShoppingShare: document.getElementById('btn-shopping-share'),
btnShoppingRevokeShare: document.getElementById('btn-shopping-revoke-share'),
btnShoppingCopyText: document.getElementById('btn-shopping-copy-text'),
shoppingAddItemForm: document.getElementById('shopping-add-item-form'),
shoppingAddItemInput: document.getElementById('shopping-add-item-input'),
toggleConsolidateItems: document.getElementById('toggle-consolidate-items'),
shoppingGroupBySelect: document.getElementById('shopping-group-by'),
btnShoppingClearChecked: document.getElementById('btn-shopping-clear-checked'),
btnShoppingClearAll: document.getElementById('btn-shopping-clear-all'),
shoppingListsContent: document.getElementById('shopping-lists-content'),
shoppingEmptyState: document.getElementById('shopping-empty-state'),
detailShoppingListSelect: document.getElementById('detail-shopping-list-select'),
btnAddRecipeToList: document.getElementById('btn-add-recipe-to-list'),
settingsDefaultConsolidate: document.getElementById('settings-default-consolidate')
};
// -------------------------------------------------------------
// LIFE CYCLE AND REGISTRATION
// -------------------------------------------------------------
window.addEventListener('DOMContentLoaded', () => {
initTheme();
registerEvents();
initAuth();
});
function registerEvents() {
// Theme Toggle
elements.themeToggle.addEventListener('click', toggleTheme);
// URL Form Submission
elements.urlScrapeForm.addEventListener('submit', handleScrapeSubmit);
// Manual Add Form Opening
elements.btnManualAdd.addEventListener('click', openManualAdd);
// Settings Modal Opening
elements.btnSettings.addEventListener('click', openSettingsModal);
elements.settingsApiForm.addEventListener('submit', handleSaveSettings);
elements.btnToggleGeminiKey.addEventListener('click', toggleSettingsKeyVisibility);
// Image Scanning Actions
elements.btnScanImage.addEventListener('click', () => elements.scanImageInput.click());
elements.scanImageInput.addEventListener('change', handleRecipeImageScan);
document.addEventListener('paste', handleClipboardPaste);
// Library Search
elements.librarySearch.addEventListener('input', filterRecipes);
// Recipe Form Save
elements.recipeEditForm.addEventListener('submit', handleRecipeSave);
// Action Buttons Detail Modal
elements.btnDetailEdit.addEventListener('click', editActiveRecipe);
elements.btnDetailDelete.addEventListener('click', deleteActiveRecipe);
elements.btnDetailClone.addEventListener('click', cloneActiveRecipe);
elements.btnDetailShare.addEventListener('click', shareActiveRecipe);
elements.btnDetailRevokeShare.addEventListener('click', revokeActiveRecipeShare);
// Auth Form Submissions
elements.loginForm.addEventListener('submit', handleLogin);
elements.btnLogout.addEventListener('click', logout);
// Dashboard Tabs Switchers
elements.tabMyCookbook.addEventListener('click', () => switchTab('my-cookbook'));
elements.tabCommunityVault.addEventListener('click', () => switchTab('community-vault'));
elements.tabShoppingLists.addEventListener('click', () => switchTab('shopping-lists'));
elements.tabUserAdmin.addEventListener('click', () => switchTab('user-admin'));
// Shopping Lists Events
elements.shoppingListSelect.addEventListener('change', (e) => {
activeShoppingListId = e.target.value;
renderShoppingListsWorkspace();
});
elements.btnShoppingNewList.addEventListener('click', handleCreateShoppingList);
elements.btnShoppingRenameList.addEventListener('click', handleRenameShoppingList);
elements.btnShoppingDeleteList.addEventListener('click', handleDeleteShoppingList);
elements.btnShoppingShare.addEventListener('click', handleShareShoppingList);
elements.btnShoppingRevokeShare.addEventListener('click', handleRevokeShoppingListShare);
elements.btnShoppingCopyText.addEventListener('click', handleCopyShoppingListText);
elements.shoppingAddItemForm.addEventListener('submit', handleAddManualShoppingItem);
elements.toggleConsolidateItems.addEventListener('change', (e) => {
consolidateItems = e.target.checked;
renderShoppingListsWorkspace();
});
elements.shoppingGroupBySelect.addEventListener('change', (e) => {
shoppingGroupBy = e.target.value;
renderShoppingListsWorkspace();
});
elements.btnShoppingClearChecked.addEventListener('click', () => handleClearShoppingList(true));
elements.btnShoppingClearAll.addEventListener('click', () => handleClearShoppingList(false));
elements.detailShoppingListSelect.addEventListener('change', handleDetailListSelectChange);
elements.btnAddRecipeToList.addEventListener('click', handleAddRecipeToShoppingList);
elements.settingsDefaultConsolidate.addEventListener('change', handleDefaultConsolidatePreferenceChange);
// Admin Action Buttons & Forms
elements.btnAdminAddUser.addEventListener('click', () => openModal('admin-create-user-modal'));
elements.adminCreateUserForm.addEventListener('submit', handleAdminCreateUser);
elements.btnAdminConfirmDelete.addEventListener('click', handleAdminDeleteUser);
// Admin Sub-Tab Switchers
elements.btnAdminTabUsers.addEventListener('click', () => switchAdminSubTab('users'));
elements.btnAdminTabShares.addEventListener('click', () => switchAdminSubTab('shares'));
if (elements.btnAdminTabBackups) {
elements.btnAdminTabBackups.addEventListener('click', () => switchAdminSubTab('backups'));
}
// Admin Backups Console Actions
if (elements.adminBackupConfigForm) {
elements.adminBackupConfigForm.addEventListener('submit', handleSaveBackupConfig);
elements.btnBackupRunCloud.addEventListener('click', handleRunCloudBackupNow);
elements.btnBackupDownloadDb.addEventListener('click', () => handleDownloadLocalBackup('db'));
elements.btnBackupDownloadJson.addEventListener('click', () => handleDownloadLocalBackup('json'));
elements.btnBackupRestoreLocal.addEventListener('click', () => elements.backupRestoreFileInput.click());
elements.backupRestoreFileInput.addEventListener('change', handleRestoreLocalFile);
elements.btnBackupTestS3.addEventListener('click', handleTestS3Connection);
elements.btnBackupTestGdrive.addEventListener('click', handleTestGdriveConnection);
elements.btnBackupAuthorizeGdrive.addEventListener('click', handleAuthorizeGoogleDrive);
if (elements.btnBackupDisconnectGdrive) {
elements.btnBackupDisconnectGdrive.addEventListener('click', handleDisconnectGoogleDrive);
}
if (elements.backupGdriveFolderSelect) {
elements.backupGdriveFolderSelect.addEventListener('change', handleSelectGdriveFolder);
}
if (elements.btnGdriveNewFolder) {
elements.btnGdriveNewFolder.addEventListener('click', handleCreateGdriveFolder);
}
if (elements.btnGdriveRefreshFolders) {
elements.btnGdriveRefreshFolders.addEventListener('click', () => loadGoogleDriveFolders(true));
}
if (elements.btnBackupRestoreCloudModal) {
elements.btnBackupRestoreCloudModal.addEventListener('click', openCloudRestoreModal);
}
if (elements.btnSyncCloudBackups) {
elements.btnSyncCloudBackups.addEventListener('click', handleSyncCloudBackups);
}
if (elements.btnRefreshCloudModal) {
elements.btnRefreshCloudModal.addEventListener('click', () => loadLiveCloudBackupsForModal(true));
}
if (elements.cloudRestoreProviderFilter) {
elements.cloudRestoreProviderFilter.addEventListener('change', () => renderCloudRestoreModalList());
}
if (elements.restoreScopeAll) {
elements.restoreScopeAll.addEventListener('change', updateRestoreScopeModeUI);
}
if (elements.restoreScopeSingle) {
elements.restoreScopeSingle.addEventListener('change', updateRestoreScopeModeUI);
}
if (elements.btnConfirmScopedRestore) {
elements.btnConfirmScopedRestore.addEventListener('click', executeScopedRestore);
}
}
window.addEventListener('message', (event) => {
if (event.data && event.data.type === 'GDRIVE_OAUTH_SUCCESS') {
showGdriveInlineStatus('Google Drive connected! Select your backup destination folder below.', 'success');
showBackupStatus('Google Drive account connected!', 'success');
fetchAdminBackupConfig();
}
});
// Servings Scaler Buttons
elements.scalerMinus.addEventListener('click', () => adjustMultiplier(-0.5));
elements.scalerPlus.addEventListener('click', () => adjustMultiplier(0.5));
// Cooking Mode Trigger
elements.btnTriggerCookmode.addEventListener('click', enterCookingMode);
// Cooking Controls
elements.cookBtnPrev.addEventListener('click', prevCookingStep);
elements.cookBtnNext.addEventListener('click', nextCookingStep);
elements.cookTimerPlay.addEventListener('click', toggleTimer);
elements.cookTimerReset.addEventListener('click', resetTimer);
// Global Modals click to dismiss on overlay
document.querySelectorAll('.modal-overlay').forEach(overlay => {
overlay.addEventListener('click', (e) => {
if (e.target === overlay && !overlay.classList.contains('loading-overlay')) {
closeModal(overlay.id);
}
});
});
// Global key bindings for cooking mode
document.addEventListener('keydown', (e) => {
if (elements.cookingModeModal.classList.contains('active')) {
if (e.key === 'ArrowRight' || e.key === ' ') {
e.preventDefault();
nextCookingStep();
} else if (e.key === 'ArrowLeft') {
e.preventDefault();
prevCookingStep();
} else if (e.key === 'Escape') {
exitCookingMode();
}
}
});
// Mobile UI Events
elements.mobileTabCookbook.addEventListener('click', () => switchTab('my-cookbook'));
elements.mobileTabCommunity.addEventListener('click', () => switchTab('community-vault'));
if (elements.mobileTabShopping) {
elements.mobileTabShopping.addEventListener('click', () => switchTab('shopping-lists'));
}
elements.mobileTabProfile.addEventListener('click', openMobileProfile);
elements.headerLogo.addEventListener('click', () => switchTab('my-cookbook'));
elements.fabAddRecipe.addEventListener('click', handleFabClick);
elements.mobileBtnThemeToggle.addEventListener('click', toggleTheme);
elements.mobileBtnSettings.addEventListener('click', () => {
closeModal('mobile-profile-modal');
openSettingsModal();
});
elements.mobileBtnAdmin.addEventListener('click', () => {
closeModal('mobile-profile-modal');
switchTab('user-admin');
});
elements.mobileBtnLogout.addEventListener('click', () => {
closeModal('mobile-profile-modal');
logout();
});
}
// -------------------------------------------------------------
// UTILITIES: SYSTEM THEMES
// -------------------------------------------------------------
function initTheme() {
const savedTheme = localStorage.getItem('gourmet-theme') || 'dark';
if (savedTheme === 'dark') {
document.body.classList.add('dark-theme');
} else {
document.body.classList.remove('dark-theme');
}
}
function toggleTheme() {
document.body.classList.toggle('dark-theme');
const isDark = document.body.classList.contains('dark-theme');
localStorage.setItem('gourmet-theme', isDark ? 'dark' : 'light');
}
// -------------------------------------------------------------
// LOADING STATUS MACHINE
// -------------------------------------------------------------
function setLoaderStepIcon(stepElement, iconName, spin = false) {
const wrapper = stepElement.querySelector('.step-icon-wrapper');
if (wrapper) {
wrapper.innerHTML = `<i data-lucide="${iconName}" class="${spin ? 'spinning-icon' : ''}"></i>`;
}
}
function showScrapeLoading() {
elements.loadingOverlay.classList.add('active');
// Reset Steps to default
elements.loaderStepFetch.className = 'loader-step active';
elements.loaderStepParse.className = 'loader-step';
elements.loaderStepImages.className = 'loader-step';
setLoaderStepIcon(elements.loaderStepFetch, 'circle-dashed', true);
setLoaderStepIcon(elements.loaderStepParse, 'circle', false);
setLoaderStepIcon(elements.loaderStepImages, 'circle', false);
lucide.createIcons();
// Simulate process transitions
setTimeout(() => {
if (!elements.loadingOverlay.classList.contains('active')) return;
elements.loaderStepFetch.className = 'loader-step completed';
setLoaderStepIcon(elements.loaderStepFetch, 'check-circle-2', false);
elements.loaderStepParse.className = 'loader-step active';
setLoaderStepIcon(elements.loaderStepParse, 'circle-dashed', true);
lucide.createIcons();
}, 1800);
setTimeout(() => {
if (!elements.loadingOverlay.classList.contains('active')) return;
elements.loaderStepParse.className = 'loader-step completed';
setLoaderStepIcon(elements.loaderStepParse, 'check-circle-2', false);
elements.loaderStepImages.className = 'loader-step active';
setLoaderStepIcon(elements.loaderStepImages, 'circle-dashed', true);
lucide.createIcons();
}, 3500);
}
function hideScrapeLoading() {
elements.loadingOverlay.classList.remove('active');
}
// -------------------------------------------------------------
// CORE MODAL ROUTER
// -------------------------------------------------------------
function openModal(id) {
document.getElementById(id).classList.add('active');
}
function closeModal(id) {
document.getElementById(id).classList.remove('active');
if (id === 'cooking-mode-modal') {
exitCookingMode();
}
}
// Helper: get authentication headers for fetches
function getAuthHeaders() {
const token = localStorage.getItem('gourmet_token');
return token ? { 'Authorization': `Bearer ${token}` } : {};
}
// -------------------------------------------------------------
// CRUD & API HANDLERS
// -------------------------------------------------------------
async function fetchRecipes() {
try {
let url = '/api/recipes';
if (activeTab === 'community-vault') {
url = '/api/recipes/public';
}
const response = await fetch(url, {
headers: getAuthHeaders()
});
if (response.status === 401) {
logout();
return;
}
if (!response.ok) {
throw new Error('Failed to load recipes');
}
allRecipes = await response.json();
renderLibrary();
} catch (error) {
console.error('Error fetching recipes library:', error);
}
}
// Scrape link form submission
async function handleScrapeSubmit(e) {
e.preventDefault();
const url = elements.scrapeUrlInput.value.trim();
if (!url) return;
showScrapeLoading();
try {
const response = await fetch('/api/scrape', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...getAuthHeaders()
},
body: JSON.stringify({ url })
});
if (response.status === 401) {
logout();
hideScrapeLoading();
return;
}
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
if (response.status === 403 || errorData.isBlocked) {
throw new Error('BLOCKED');
}
throw new Error(errorData.error || 'Extraction error.');
}
const data = await response.json();
hideScrapeLoading();
openDraftPreview(data);
elements.scrapeUrlInput.value = '';
} catch (error) {
hideScrapeLoading();
if (error.message === 'BLOCKED') {
alert(`Access Blocked by Site Protection\n\nThis website uses Cloudflare or bot-defense screens to prevent automated scraping.\n\nDon't worry! You can still add this recipe by clicking the "Manual Recipe" button at the top to quickly paste your ingredients and directions.`);
} else {
alert(`Scraping failed: ${error.message}`);
}
}
}
// Save or Edit submit
async function handleRecipeSave(e) {
e.preventDefault();
const id = elements.editRecipeId.value;
const recipePayload = {
title: elements.editTitle.value.trim(),
sourceUrl: elements.editSource.value.trim(),
description: elements.editDescription.value.trim(),
servings: elements.editServings.value.trim(),
prepTime: elements.editPrepTime.value.trim(),
cookTime: elements.editCookTime.value.trim(),
isPublic: elements.editIsPublic.checked,
image: elements.editImageUrl.value.trim(),
images: Array.from(elements.imageSelectionGrid.querySelectorAll('.image-option img')).map(img => img.src),
ingredients: elements.editIngredients.value.trim().split(/\r?\n/).map(line => line.trim()).filter(Boolean),
instructions: elements.editInstructions.value.trim().split(/\r?\n/).map(line => line.trim()).filter(Boolean)
};
try {
let response;
if (id) {
// Edit
response = await fetch(`/api/recipes/${id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
...getAuthHeaders()
},
body: JSON.stringify(recipePayload)
});
} else {
// Create
response = await fetch('/api/recipes', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...getAuthHeaders()
},
body: JSON.stringify(recipePayload)
});
}
if (response.status === 401) {
logout();
return;
}
if (!response.ok) throw new Error('Saving failed.');
const saved = await response.json();
closeModal('save-modal');
fetchRecipes();
// If active view was open, update it
if (activeRecipe && activeRecipe.id === saved.id) {
showRecipeDetails(saved);
}
} catch (error) {
console.error('Error saving recipe:', error);
alert('Error saving recipe.');
}
}
// Delete Active Recipe
async function deleteActiveRecipe() {
if (!activeRecipe) return;
if (!confirm(`Are you sure you want to delete "${activeRecipe.title}"?`)) return;
try {
const response = await fetch(`/api/recipes/${activeRecipe.id}`, {
method: 'DELETE',
headers: getAuthHeaders()
});
if (response.status === 401) {
logout();
return;
}
if (!response.ok) throw new Error('Deletion failed.');
closeModal('detail-modal');
activeRecipe = null;
fetchRecipes();
} catch (error) {
console.error('Error deleting recipe:', error);
alert('Deletion failed.');
}
}
// Clone a Shared Cookbook recipe to current user's collection
async function cloneActiveRecipe() {
if (!activeRecipe) return;
try {
const response = await fetch(`/api/recipes/clone/${activeRecipe.id}`, {
method: 'POST',
headers: getAuthHeaders()
});
if (response.status === 401) {
logout();
return;
}
if (!response.ok) {
const err = await response.json().catch(() => ({}));
throw new Error(err.error || 'Cloning failed.');
}
const cloned = await response.json();
alert(`Successfully cloned "${activeRecipe.title}" into your cookbook!`);
closeModal('detail-modal');
// Switch back to "My Cookbook" tab to let them see it immediately
switchTab('my-cookbook');
} catch (error) {
console.error('Error cloning recipe:', error);
alert(`Error cloning recipe: ${error.message}`);
}
}
async function copyTextToClipboard(text) {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(text);
return;
}
const textArea = document.createElement('textarea');
textArea.value = text;
textArea.style.position = 'fixed';
textArea.style.opacity = '0';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
document.execCommand('copy');
document.body.removeChild(textArea);
}
let shareBtnResetTimeout = null;
async function shareActiveRecipe() {
if (!activeRecipe) return;
if (activeRecipe.isPublic === false) {
if (confirm(`"${activeRecipe.title}" is currently private.\n\nTo share it with people outside ForkFolio, you must make it public first. Would you like to make this recipe public now?`)) {
try {
const response = await fetch(`/api/recipes/${activeRecipe.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
...getAuthHeaders()
},
body: JSON.stringify({ isPublic: true })
});
if (response.status === 401) {
logout();
return;
}
if (!response.ok) throw new Error('Failed to update privacy.');
const updated = await response.json();
activeRecipe = updated;
fetchRecipes();
// Re-render detail badge visual state
showRecipeDetails(updated);
} catch (err) {
console.error('Error toggling privacy for share link:', err);
alert('Failed to make the recipe public.');
return;
}
} else {
return;
}
}
// If public and has a share token
if (activeRecipe.shareToken) {
const shareLink = `${window.location.origin}/share.html?token=${activeRecipe.shareToken}`;
try {
await copyTextToClipboard(shareLink);
if (elements.btnDetailShare) {
if (shareBtnResetTimeout) clearTimeout(shareBtnResetTimeout);
elements.btnDetailShare.classList.add('copied-feedback');
elements.btnDetailShare.innerHTML = `<i data-lucide="check"></i> <span>Copied!</span>`;
lucide.createIcons();
shareBtnResetTimeout = setTimeout(() => {
elements.btnDetailShare.classList.remove('copied-feedback');
elements.btnDetailShare.innerHTML = `<i data-lucide="share-2"></i>`;
lucide.createIcons();
}, 2000);
}
} catch (clipErr) {
console.warn('Clipboard write failed. Displaying fallback prompt:', clipErr);
prompt('Public Share Link:', shareLink);
}
} else {
alert('Sharing token was not generated. Please try saving this recipe as public again.');
}
}
async function revokeActiveRecipeShare() {
if (!activeRecipe) return;
if (!confirm(`Are you sure you want to revoke the shared link for "${activeRecipe.title}"?\n\nThis will make the recipe private, disable the public link immediately, and clear all visitor history.`)) return;
try {
const response = await fetch(`/api/recipes/${activeRecipe.id}/revoke-share`, {
method: 'POST',
headers: getAuthHeaders()
});
if (response.status === 401) {
logout();
return;
}
if (!response.ok) {
const err = await response.json().catch(() => ({}));
throw new Error(err.error || 'Failed to revoke shared link.');
}
alert(`Successfully revoked the public share link for "${activeRecipe.title}".`);
// Refresh list and details view
const listUrl = activeTab === 'community-vault' ? '/api/recipes/public' : '/api/recipes';
const listRes = await fetch(listUrl, { headers: getAuthHeaders() });
if (listRes.ok) {
allRecipes = await listRes.json();
renderLibrary();
const updated = allRecipes.find(r => r.id === activeRecipe.id);
if (updated) {
showRecipeDetails(updated);
} else {
closeModal('detail-modal');
}
}
} catch (error) {
console.error('Error revoking recipe share:', error);
alert(`Error: ${error.message}`);
}
}
// -------------------------------------------------------------
// DRAFT / MANUAL CREATOR MODAL POPULATOR
// -------------------------------------------------------------
function openManualAdd() {
elements.modalActionTitle.textContent = 'Create New Recipe';
elements.editRecipeId.value = '';
elements.recipeEditForm.reset();
elements.imageSelectionGrid.innerHTML = '';
selectedCoverImage = '';
elements.editIsPublic.checked = true;
openModal('save-modal');
}
function openDraftPreview(data) {
elements.modalActionTitle.textContent = 'Save Extracted Recipe';
elements.editRecipeId.value = '';
elements.editTitle.value = data.title || '';
elements.editSource.value = data.sourceUrl || '';
elements.editDescription.value = data.description || '';
elements.editServings.value = data.servings || '';
elements.editPrepTime.value = data.prepTime || '';
elements.editCookTime.value = data.cookTime || '';
elements.editIsPublic.checked = true;
// Ingredients text area
elements.editIngredients.value = data.ingredients ? data.ingredients.join('\n') : '';
// Instructions text area
elements.editInstructions.value = data.instructions ? data.instructions.join('\n') : '';
// Render Image Selector
renderImageSelector(data.images || []);
openModal('save-modal');
}
function editActiveRecipe() {
if (!activeRecipe) return;
elements.modalActionTitle.textContent = 'Edit Recipe Details';
elements.editRecipeId.value = activeRecipe.id;
elements.editTitle.value = activeRecipe.title || '';
elements.editSource.value = activeRecipe.sourceUrl || '';
elements.editDescription.value = activeRecipe.description || '';
elements.editServings.value = activeRecipe.servings || '';
elements.editPrepTime.value = activeRecipe.prepTime || '';
elements.editCookTime.value = activeRecipe.cookTime || '';
elements.editIsPublic.checked = activeRecipe.isPublic !== false;
elements.editIngredients.value = activeRecipe.ingredients ? activeRecipe.ingredients.join('\n') : '';
elements.editInstructions.value = activeRecipe.instructions ? activeRecipe.instructions.join('\n') : '';
renderImageSelector(activeRecipe.images || []);
// Set active selection
if (activeRecipe.image) {
elements.editImageUrl.value = activeRecipe.image;
selectedCoverImage = activeRecipe.image;
selectImageThumbnail(activeRecipe.image);
}
openModal('save-modal');
}
// -------------------------------------------------------------
// IMAGE EXTRACTION CAROUSEL & SELECTOR
// -------------------------------------------------------------
function renderImageSelector(images) {
elements.imageSelectionGrid.innerHTML = '';
selectedCoverImage = '';
if (!images || images.length === 0) {
elements.editImageUrl.value = '';
return;
}
// Pre-select first image
selectedCoverImage = images[0];
elements.editImageUrl.value = images[0];
images.forEach((imgSrc, index) => {
const option = document.createElement('div');
option.className = `image-option ${index === 0 ? 'selected' : ''}`;
option.innerHTML = `<img src="${imgSrc}" alt="extracted thumbnail">`;
option.addEventListener('click', () => {
elements.imageSelectionGrid.querySelectorAll('.image-option').forEach(el => el.classList.remove('selected'));
option.classList.add('selected');
selectedCoverImage = imgSrc;
elements.editImageUrl.value = imgSrc;
});
elements.imageSelectionGrid.appendChild(option);
});
}
function selectImageThumbnail(url) {
elements.imageSelectionGrid.querySelectorAll('.image-option').forEach(el => {
const img = el.querySelector('img');
if (img && img.src === url) {
el.classList.add('selected');
} else {
el.classList.remove('selected');
}
});
}
// -------------------------------------------------------------
// DASHBOARD RENDERING
// -------------------------------------------------------------
function renderLibrary() {
elements.recipesGrid.innerHTML = '';
const searchVal = elements.librarySearch.value.toLowerCase().trim();
const filtered = allRecipes.filter(recipe => {
return recipe.title.toLowerCase().includes(searchVal) ||
(recipe.description && recipe.description.toLowerCase().includes(searchVal)) ||
(recipe.ingredients && recipe.ingredients.some(i => i.toLowerCase().includes(searchVal)));
});
if (filtered.length === 0) {
elements.emptyState.style.display = 'flex';
return;
}
elements.emptyState.style.display = 'none';
filtered.forEach(recipe => {
const domain = recipe.sourceUrl ? new URL(recipe.sourceUrl).hostname.replace('www.', '') : 'Manual Recipe';
const card = document.createElement('div');
card.className = 'recipe-card';
card.addEventListener('click', () => showRecipeDetails(recipe));
let imgBlock = '';
if (recipe.image) {
imgBlock = `
<img src="${recipe.image}" class="recipe-card-img" alt="${recipe.title}" loading="lazy" onerror="this.style.display='none'; this.nextElementSibling.style.display='flex';">
<div class="recipe-card-img-placeholder" style="display: none;">
<i data-lucide="cooking-pot"></i>
<span>No dish photo</span>
</div>
`;
} else {
imgBlock = `
<div class="recipe-card-img-placeholder">
<i data-lucide="cooking-pot"></i>
<span>No dish photo</span>
</div>
`;
}
let creatorBadge = '';
if (activeTab === 'community-vault') {
const creator = recipe.createdBy || 'gourmet_system';
creatorBadge = `
<div class="recipe-card-creator" style="margin-top: 4px; margin-bottom: 8px;">
<i data-lucide="user"></i>
<span>@${creator}</span>
</div>
`;
}
card.innerHTML = `
<div class="recipe-card-img-wrapper">
${imgBlock}
</div>
<div class="recipe-card-content">
<span class="recipe-card-origin">${domain}</span>
<h3 class="recipe-card-title">${recipe.title}</h3>
${creatorBadge}
<p class="recipe-card-desc">${recipe.description || 'No description available for this recipe.'}</p>
<div class="recipe-card-meta">
${recipe.servings ? `<span><i data-lucide="users"></i> ${recipe.servings} Servings</span>` : ''}
${recipe.totalTime ? `<span><i data-lucide="clock"></i> ${recipe.totalTime}</span>` : ''}
</div>
</div>
`;
elements.recipesGrid.appendChild(card);
});
lucide.createIcons();
}
function filterRecipes() {
renderLibrary();
}
// -------------------------------------------------------------
// DYNAMIC SERVINGS QUANTITY SCALER
// -------------------------------------------------------------
function scaleIngredientText(text, multiplier) {
if (multiplier === 1) return text;
// Mixed number regex: e.g. "1 1/2" or "2 1/4"
const mixedRegex = /^(\d+)\s+(\d+\/\d+)\s*(.*)/;
const mixedMatch = text.match(mixedRegex);
if (mixedMatch) {
const whole = parseInt(mixedMatch[1], 10);
const fracParts = mixedMatch[2].split('/');
const fracVal = parseInt(fracParts[0], 10) / parseInt(fracParts[1], 10);
const total = (whole + fracVal) * multiplier;
return `${formatScaledNumber(total)} ${mixedMatch[3]}`;
}
// Pure Fraction regex: e.g. "1/2" or "3/4"
const fracRegex = /^(\d+\/\d+)\s*(.*)/;
const fracMatch = text.match(fracRegex);
if (fracMatch) {
const parts = fracMatch[1].split('/');
const val = (parseInt(parts[0], 10) / parseInt(parts[1], 10)) * multiplier;
return `${formatScaledNumber(val)} ${fracMatch[2]}`;
}
// Decimals & Integers regex: e.g. "1.5" or "4"
const numRegex = /^(\d+(?:\.\d+)?)\s*(.*)/;
const numMatch = text.match(numRegex);
if (numMatch) {
const val = parseFloat(numMatch[1]) * multiplier;
return `${formatScaledNumber(val)} ${numMatch[2]}`;
}
return text;
}
function formatScaledNumber(num) {
if (num % 1 === 0) return num.toString();
const rounded = Math.round(num * 100) / 100;
const decimal = rounded % 1;
const integer = Math.floor(rounded);
// Common fraction mappings for nice reading
if (Math.abs(decimal - 0.25) < 0.05) return (integer > 0 ? integer + ' ' : '') + '1/4';
if (Math.abs(decimal - 0.5) < 0.05) return (integer > 0 ? integer + ' ' : '') + '1/2';
if (Math.abs(decimal - 0.75) < 0.05) return (integer > 0 ? integer + ' ' : '') + '3/4';
if (Math.abs(decimal - 0.33) < 0.05) return (integer > 0 ? integer + ' ' : '') + '1/3';
if (Math.abs(decimal - 0.66) < 0.05) return (integer > 0 ? integer + ' ' : '') + '2/3';
return rounded.toString();
}
function adjustMultiplier(change) {
const newMulti = currentMultiplier + change;
if (newMulti < 0.5 || newMulti > 10) return;
currentMultiplier = newMulti;
elements.currentServingsVal.textContent = `${currentMultiplier}x`;
renderScaledIngredients();
}
function renderScaledIngredients() {
elements.recipeDetailIngredients.innerHTML = '';
if (!activeRecipe || !activeRecipe.ingredients) return;
activeRecipe.ingredients.forEach((ing, index) => {
const scaledText = scaleIngredientText(ing, currentMultiplier);
const li = document.createElement('li');
li.className = 'ingredient-checkbox-item';
li.innerHTML = `
<div class="checkbox-custom"><i data-lucide="check" style="width:12px;height:12px;"></i></div>
<span class="ingredient-text">${scaledText}</span>
`;
li.addEventListener('click', () => {
li.classList.toggle('checked');
});
elements.recipeDetailIngredients.appendChild(li);
});
lucide.createIcons();
}
// -------------------------------------------------------------
// RECIPE DETAIL VIEWER COMPONENT
// -------------------------------------------------------------
function showRecipeDetails(recipe) {
activeRecipe = recipe;
currentMultiplier = 1;
elements.currentServingsVal.textContent = '1x';
if (shareBtnResetTimeout) {
clearTimeout(shareBtnResetTimeout);
shareBtnResetTimeout = null;
}
if (elements.btnDetailShare) {
elements.btnDetailShare.classList.remove('copied-feedback');
elements.btnDetailShare.innerHTML = `<i data-lucide="share-2"></i>`;
}
// Set hero cover banner image
if (recipe.image) {
elements.recipeDetailBanner.style.backgroundImage = `url('${recipe.image}')`;
} else {
elements.recipeDetailBanner.style.backgroundImage = 'none';
elements.recipeDetailBanner.style.backgroundColor = 'var(--bg-app)';
}
elements.recipeDetailTitle.textContent = recipe.title;
elements.recipeDetailDescription.textContent = recipe.description || 'No description available for this recipe.';
// Ownership and Permission controls
const isOwner = recipe.userId === currentUser.id;
const canEdit = isOwner || currentUser.isRecipeManager || currentUser.isAdmin;
if (canEdit) {
elements.btnDetailEdit.style.display = 'flex';
elements.btnDetailDelete.style.display = 'flex';
// Revoke share button toggle
if (recipe.isPublic && recipe.shareToken) {
elements.btnDetailRevokeShare.style.display = 'flex';
} else {
elements.btnDetailRevokeShare.style.display = 'none';
}
} else {
elements.btnDetailEdit.style.display = 'none';
elements.btnDetailDelete.style.display = 'none';
elements.btnDetailRevokeShare.style.display = 'none';
}
if (isOwner) {
elements.btnDetailClone.style.display = 'none';
elements.recipeDetailCreator.style.display = 'none';
} else {
elements.btnDetailClone.style.display = 'flex';
elements.recipeDetailCreator.style.display = 'inline-flex';
elements.recipeDetailCreatorName.textContent = `@${recipe.createdBy || 'gourmet_system'}`;
}
// Setup meta tags
if (recipe.servings) {
elements.tagServings.style.display = 'flex';
elements.tagServings.querySelector('.val').textContent = `${recipe.servings} Servings`;
} else {
elements.tagServings.style.display = 'none';
}
if (recipe.prepTime) {
elements.tagPrep.style.display = 'flex';
elements.tagPrep.querySelector('.val').textContent = recipe.prepTime;
} else {
elements.tagPrep.style.display = 'none';
}
if (recipe.cookTime) {
elements.tagCook.style.display = 'flex';
elements.tagCook.querySelector('.val').textContent = recipe.cookTime;
} else {
elements.tagCook.style.display = 'none';
}
if (recipe.sourceUrl) {
elements.tagSource.style.display = 'flex';
elements.tagSource.href = recipe.sourceUrl;
} else {
elements.tagSource.style.display = 'none';
}
// Public unique visitors count stats badge
if (recipe.isPublic && recipe.shareToken && recipe.visitorCount !== undefined) {
elements.tagShareStats.style.display = 'flex';
elements.tagShareStats.querySelector('.val').textContent = `${recipe.visitorCount} Unique ${recipe.visitorCount === 1 ? 'Visitor' : 'Visitors'}`;
} else {
elements.tagShareStats.style.display = 'none';
}
// Populate Ingredients with scaler
renderScaledIngredients();
populateDetailShoppingListSelect();
// Populate Directions Steps
elements.recipeDetailInstructions.innerHTML = '';
if (recipe.instructions) {
recipe.instructions.forEach((step, index) => {
const li = document.createElement('li');
// Look for section headers (e.g. ### Sauce)
if (step.startsWith('###')) {
li.className = 'instruction-step-item section-header-step';
li.innerHTML = `
<div class="step-text-block">${step.replace('###', '').trim()}</div>
`;
} else {
li.className = 'instruction-step-item';
li.innerHTML = `
<div class="step-number-bullet">${index + 1}</div>
<div class="step-text-block">${step}</div>
`;
li.addEventListener('click', () => {
li.classList.toggle('checked');
});
}
elements.recipeDetailInstructions.appendChild(li);
});
}
lucide.createIcons();
openModal('detail-modal');
}
// -------------------------------------------------------------
// INTERACTIVE FULLSCREEN COOKING ASSISTANT
// -------------------------------------------------------------
function enterCookingMode() {
if (!activeRecipe || !activeRecipe.instructions || activeRecipe.instructions.length === 0) return;
elements.cookRecipeTitle.textContent = activeRecipe.title;
cookingActiveStep = 0;
// Filter out header steps from the cooking carousel to keep steps clean
const cleanInstructions = activeRecipe.instructions.filter(step => !step.startsWith('###'));
if (cleanInstructions.length === 0) return;
elements.cookingModeModal.classList.add('active');
document.body.style.overflow = 'hidden'; // Lock background scrolling
renderCookingStep(cleanInstructions);
// Reset Timer
resetTimer();
startTimer();
}
function exitCookingMode() {
elements.cookingModeModal.classList.remove('active');
document.body.style.overflow = 'auto'; // Unlock background scrolling
stopTimer();
}
function renderCookingStep(instructions) {
const stepText = instructions[cookingActiveStep];
elements.cookStepIndex.textContent = `Step ${cookingActiveStep + 1} of ${instructions.length}`;
elements.cookStepInstruction.textContent = stepText;
// Update progress bar
const progressPercent = ((cookingActiveStep + 1) / instructions.length) * 100;
elements.cookProgressBar.style.width = `${progressPercent}%`;
// Render step navigation dot indicators
elements.cookStepDots.innerHTML = '';
instructions.forEach((_, idx) => {
const dot = document.createElement('div');
dot.className = `dot ${idx === cookingActiveStep ? 'active' : ''}`;
dot.addEventListener('click', () => {
cookingActiveStep = idx;
renderCookingStep(instructions);
});
elements.cookStepDots.appendChild(dot);
});
// Enable/disable buttons
elements.cookBtnPrev.disabled = cookingActiveStep === 0;
const nextBtnIconWrapper = elements.cookBtnNext.querySelector('.btn-icon-wrapper');
if (cookingActiveStep === instructions.length - 1) {
elements.cookBtnNext.querySelector('span').textContent = 'Finish Cooking';
if (nextBtnIconWrapper) nextBtnIconWrapper.innerHTML = '<i data-lucide="check"></i>';
} else {
elements.cookBtnNext.querySelector('span').textContent = 'Next Step';
if (nextBtnIconWrapper) nextBtnIconWrapper.innerHTML = '<i data-lucide="chevron-right"></i>';
}
lucide.createIcons();
}
function prevCookingStep() {
const cleanInstructions = activeRecipe.instructions.filter(step => !step.startsWith('###'));
if (cookingActiveStep > 0) {
cookingActiveStep--;
renderCookingStep(cleanInstructions);
}
}
function nextCookingStep() {
const cleanInstructions = activeRecipe.instructions.filter(step => !step.startsWith('###'));
if (cookingActiveStep < cleanInstructions.length - 1) {
cookingActiveStep++;
renderCookingStep(cleanInstructions);
} else {
// Finished cooking
exitCookingMode();
}
}
// Timer/Stopwatch Functions
function startTimer() {
if (stopwatchInterval) return;
elements.cookTimerPlay.innerHTML = '<i data-lucide="pause"></i>';
lucide.createIcons();
stopwatchInterval = setInterval(() => {
stopwatchSeconds++;
const mins = Math.floor(stopwatchSeconds / 60).toString().padStart(2, '0');
const secs = (stopwatchSeconds % 60).toString().padStart(2, '0');
elements.cookTimerVal.textContent = `${mins}:${secs}`;
}, 1000);
}
function stopTimer() {
if (stopwatchInterval) {
clearInterval(stopwatchInterval);
stopwatchInterval = null;
}
elements.cookTimerPlay.innerHTML = '<i data-lucide="play"></i>';
lucide.createIcons();
}
function toggleTimer() {
if (stopwatchInterval) {
stopTimer();
} else {
startTimer();
}
}
function resetTimer() {
stopTimer();
stopwatchSeconds = 0;
elements.cookTimerVal.textContent = '00:00';
}
// -------------------------------------------------------------
// USER AUTHENTICATION & SESSION MANAGEMENT
// -------------------------------------------------------------
function initAuth() {
const token = localStorage.getItem('gourmet_token');
const userJson = localStorage.getItem('gourmet_user');
if (token && userJson) {
currentUser = JSON.parse(userJson);
applyUserDefaultConsolidationPreference(currentUser.defaultConsolidateLists);
elements.authModal.classList.remove('active');
elements.authModal.style.display = 'none';
elements.userBadgeContainer.style.display = 'flex';
elements.userDisplayName.textContent = `@${currentUser.username}`;
// Toggle Admin visibility
if (currentUser.isAdmin) {
elements.tabUserAdmin.style.display = 'flex';
elements.mobileBtnAdmin.style.display = 'block';
} else {
elements.tabUserAdmin.style.display = 'none';
elements.mobileBtnAdmin.style.display = 'none';
}
// Preload shopping lists and user preferences in background
fetchShoppingLists();
syncUserPreferencesFromServer();
// Reset view to My Cookbook on init
switchTab('my-cookbook');
} else {
elements.authModal.classList.add('active');
elements.authModal.style.display = 'flex';
elements.userBadgeContainer.style.display = 'none';
elements.tabUserAdmin.style.display = 'none';
}
}
async function handleLogin(e) {
e.preventDefault();
elements.authErrorMsg.style.display = 'none';
const username = elements.loginUsernameInput.value.trim();
const password = elements.loginPasswordInput.value;
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
if (!response.ok) {
const err = await response.json().catch(() => ({}));
throw new Error(err.error || 'Login failed.');
}
const data = await response.json();
localStorage.setItem('gourmet_token', data.token);
localStorage.setItem('gourmet_user', JSON.stringify(data.user));
currentUser = data.user;
applyUserDefaultConsolidationPreference(currentUser.defaultConsolidateLists);
elements.authModal.classList.remove('active');
elements.authModal.style.display = 'none';
elements.userBadgeContainer.style.display = 'flex';
elements.userDisplayName.textContent = `@${currentUser.username}`;
// Toggle Admin visibility
if (currentUser.isAdmin) {
elements.tabUserAdmin.style.display = 'flex';
elements.mobileBtnAdmin.style.display = 'block';
} else {
elements.tabUserAdmin.style.display = 'none';
elements.mobileBtnAdmin.style.display = 'none';
}
elements.loginForm.reset();
fetchShoppingLists();
// Default back to My Cookbook on fresh login
switchTab('my-cookbook');
} catch (error) {
elements.authErrorText.textContent = error.message;
elements.authErrorMsg.style.display = 'flex';
}
}
function logout() {
localStorage.removeItem('gourmet_token');
localStorage.removeItem('gourmet_user');
currentUser = null;
elements.authModal.classList.add('active');
elements.authModal.style.display = 'flex';
elements.userBadgeContainer.style.display = 'none';
elements.mobileBtnAdmin.style.display = 'none';
allRecipes = [];
shoppingLists = [];
activeShoppingListId = null;
updateShoppingTabBadges();
// Reset tabs visual state
elements.tabMyCookbook.classList.add('active');
elements.tabCommunityVault.classList.remove('active');
if (elements.tabShoppingLists) elements.tabShoppingLists.classList.remove('active');
elements.tabUserAdmin.classList.remove('btn-primary');
elements.tabUserAdmin.classList.add('btn-outline');
elements.tabUserAdmin.classList.remove('active');
elements.userAdminPanel.style.display = 'none';
if (elements.shoppingListsPanel) elements.shoppingListsPanel.style.display = 'none';
elements.recipesGrid.style.display = 'grid';
const filters = elements.librarySearch.closest('.filter-controls');
if (filters) filters.style.display = 'flex';
renderLibrary();
}
function switchTab(tabName) {
elements.tabMyCookbook.classList.remove('active');
elements.tabCommunityVault.classList.remove('active');
if (elements.tabShoppingLists) elements.tabShoppingLists.classList.remove('active');
elements.tabUserAdmin.classList.remove('active');
elements.tabUserAdmin.classList.remove('btn-primary');
elements.tabUserAdmin.classList.add('btn-outline');
// Mobile Tab Bar sync
elements.mobileTabCookbook.classList.remove('active');
elements.mobileTabCommunity.classList.remove('active');
if (elements.mobileTabShopping) elements.mobileTabShopping.classList.remove('active');
elements.mobileTabProfile.classList.remove('active');
elements.recipesGrid.style.display = 'none';
elements.emptyState.style.display = 'none';
elements.userAdminPanel.style.display = 'none';
if (elements.shoppingListsPanel) elements.shoppingListsPanel.style.display = 'none';
const filters = elements.librarySearch.closest('.filter-controls');
if (filters) filters.style.display = 'flex';
// Update Dynamic Mobile Header Title
if (elements.libraryViewTitle) {
if (tabName === 'my-cookbook') {
elements.libraryViewTitle.textContent = 'My Cookbook';
} else if (tabName === 'community-vault') {
elements.libraryViewTitle.textContent = 'Shared Cookbook';
} else if (tabName === 'shopping-lists') {
elements.libraryViewTitle.textContent = 'Shopping Lists';
} else if (tabName === 'user-admin') {
elements.libraryViewTitle.textContent = 'User Administration';
}
}
if (tabName === 'my-cookbook') {
elements.tabMyCookbook.classList.add('active');
elements.mobileTabCookbook.classList.add('active');
elements.recipesGrid.style.display = 'grid';
activeTab = 'my-cookbook';
fetchRecipes();
} else if (tabName === 'community-vault') {
elements.tabCommunityVault.classList.add('active');
elements.mobileTabCommunity.classList.add('active');
elements.recipesGrid.style.display = 'grid';
activeTab = 'community-vault';
fetchRecipes();
} else if (tabName === 'shopping-lists') {
if (elements.tabShoppingLists) elements.tabShoppingLists.classList.add('active');
if (elements.mobileTabShopping) elements.mobileTabShopping.classList.add('active');
if (elements.shoppingListsPanel) elements.shoppingListsPanel.style.display = 'flex';
if (filters) filters.style.display = 'none';
activeTab = 'shopping-lists';
fetchShoppingLists();
} else if (tabName === 'user-admin') {
elements.tabUserAdmin.classList.add('btn-primary');
elements.tabUserAdmin.classList.remove('btn-outline');
elements.mobileTabProfile.classList.add('active');
elements.userAdminPanel.style.display = 'block';
if (filters) filters.style.display = 'none';
activeTab = 'user-admin';
switchAdminSubTab('users');
}
lucide.createIcons();
}
// -------------------------------------------------------------
// USER ADMINISTRATION MANAGEMENT PANEL (ADMIN ONLY)
// -------------------------------------------------------------
async function fetchAdminUsers() {
try {
const response = await fetch('/api/admin/users', {
headers: getAuthHeaders()
});
if (response.status === 401) {
logout();
return;
}
if (!response.ok) throw new Error('Failed to load registered chef accounts.');
const users = await response.json();
renderAdminUsers(users);
} catch (error) {
console.error('Error fetching admin users:', error);
}
}
function renderAdminUsers(users) {
elements.adminUsersList.innerHTML = '';
users.forEach(user => {
const tr = document.createElement('tr');
let roleBadge = user.isAdmin
? '<span class="admin-role-badge admin"><i data-lucide="shield"></i> Admin</span>'
: '<span class="admin-role-badge chef"><i data-lucide="chef-hat"></i> Chef</span>';
if (user.isRecipeManager) {
roleBadge += ' <span class="admin-role-badge manager" style="margin-left: 6px;"><i data-lucide="shield-check"></i> Manager</span>';
}
const dateStr = new Date(user.createdAt).toLocaleDateString(undefined, {
year: 'numeric',
month: 'long',
day: 'numeric'
});
const isSelf = user.id === currentUser.id;
const actionCell = isSelf
? `<span style="font-size: 0.88rem; color: var(--text-muted); font-style: italic;">Active Session</span>`
: `
${user.isAdmin
? `<button class="btn btn-outline" style="padding: 6px 12px; font-size: 0.8rem; border-radius: 6px; margin-right: 8px;" onclick="toggleUserRole('${user.id}', false)"><i data-lucide="user-check" style="width: 12px; height: 12px;"></i> Demote to Chef</button>`
: `<button class="btn btn-accent" style="padding: 6px 12px; font-size: 0.8rem; border-radius: 6px; margin-right: 8px;" onclick="toggleUserRole('${user.id}', true)"><i data-lucide="shield" style="width: 12px; height: 12px;"></i> Promote to Admin</button>`
}
${user.isRecipeManager
? `<button class="btn btn-outline" style="padding: 6px 12px; font-size: 0.8rem; border-radius: 6px; margin-right: 8px; background-color: rgba(99, 102, 241, 0.1); border-color: #6366f1; color: #6366f1;" onclick="toggleRecipeManagerRole('${user.id}', false)"><i data-lucide="user-x" style="width: 12px; height: 12px;"></i> Revoke Manager</button>`
: `<button class="btn btn-outline" style="padding: 6px 12px; font-size: 0.8rem; border-radius: 6px; margin-right: 8px; border-color: rgba(99, 102, 241, 0.5); color: #6366f1;" onclick="toggleRecipeManagerRole('${user.id}', true)"><i data-lucide="user-cog" style="width: 12px; height: 12px;"></i> Grant Manager</button>`
}
<button class="btn btn-danger" style="padding: 6px 12px; font-size: 0.8rem; border-radius: 6px;" onclick="confirmDeleteUser('${user.id}', '${user.username}')"><i data-lucide="trash-2" style="width: 12px; height: 12px;"></i> Delete</button>
`;
tr.innerHTML = `
<td style="font-weight: 600; color: var(--text-main);">@${user.username}</td>
<td>${roleBadge}</td>
<td style="color: var(--text-muted); font-size: 0.9rem;">${dateStr}</td>
<td style="text-align: right;">${actionCell}</td>
`;
elements.adminUsersList.appendChild(tr);
});
lucide.createIcons();
}
function switchAdminSubTab(subTabName) {
const tabs = [
{ name: 'users', btn: elements.btnAdminTabUsers, section: elements.adminUsersSection },
{ name: 'shares', btn: elements.btnAdminTabShares, section: elements.adminSharesSection },
{ name: 'backups', btn: elements.btnAdminTabBackups, section: elements.adminBackupsSection }
];
tabs.forEach(t => {
if (!t.btn || !t.section) return;
if (t.name === subTabName) {
t.btn.classList.add('btn-primary');
t.btn.classList.remove('btn-outline');
t.section.style.display = 'block';
} else {
t.btn.classList.add('btn-outline');
t.btn.classList.remove('btn-primary');
t.section.style.display = 'none';
}
});
if (subTabName === 'users') {
fetchAdminUsers();
} else if (subTabName === 'shares') {
fetchAdminSharedRecipes();
} else if (subTabName === 'backups') {
fetchAdminBackupConfig();
}
lucide.createIcons();
}
async function fetchAdminSharedRecipes() {
try {
const response = await fetch('/api/admin/shared-recipes', {
headers: getAuthHeaders()
});
if (response.status === 401) {
logout();
return;
}
if (!response.ok) throw new Error('Failed to load shared recipe records.');
const recipes = await response.json();
renderAdminSharedRecipes(recipes);
} catch (error) {
console.error('Error fetching admin shared recipes:', error);
}
}
function renderAdminSharedRecipes(recipes) {
elements.adminSharesList.innerHTML = '';
if (recipes.length === 0) {
const tr = document.createElement('tr');
tr.innerHTML = `
<td colspan="5" style="text-align: center; color: var(--text-muted); padding: 24px; font-style: italic;">
No recipes are currently shared publicly.
</td>
`;
elements.adminSharesList.appendChild(tr);
return;
}
recipes.forEach(recipe => {
const tr = document.createElement('tr');
const dateStr = new Date(recipe.createdAt).toLocaleDateString(undefined, {
year: 'numeric',
month: 'long',
day: 'numeric'
});
const shareLink = `${window.location.origin}/share.html?token=${recipe.shareToken}`;
const copyActionId = `btn-copy-share-${recipe.id}`;
const revokeActionId = `btn-revoke-share-${recipe.id}`;
tr.innerHTML = `
<td style="font-weight: 600; color: var(--text-main);">${recipe.title}</td>
<td style="font-weight: 500; color: var(--text-main);">@${recipe.createdBy}</td>
<td style="color: var(--text-muted); font-size: 0.9rem;">${dateStr}</td>
<td>
<span class="admin-role-badge" style="background: rgba(99, 102, 241, 0.12); border: 1px dashed rgba(99, 102, 241, 0.3); color: #6366f1; padding: 4px 8px; border-radius: 6px; display: inline-flex; align-items: center; gap: 4px;">
<i data-lucide="eye" style="width: 12px; height: 12px;"></i>
<span>${recipe.visitorCount} Unique ${recipe.visitorCount === 1 ? 'Visitor' : 'Visitors'}</span>
</span>
</td>
<td style="text-align: right;">
<button class="btn btn-outline" id="${copyActionId}" style="padding: 6px 12px; font-size: 0.8rem; border-radius: 6px; margin-right: 8px;">
<i data-lucide="copy" style="width: 12px; height: 12px;"></i> Copy Link
</button>
<button class="btn btn-danger" id="${revokeActionId}" style="padding: 6px 12px; font-size: 0.8rem; border-radius: 6px;">
<i data-lucide="link-2-off" style="width: 12px; height: 12px;"></i> Revoke
</button>
</td>
`;
elements.adminSharesList.appendChild(tr);
// Bind click events inside the row
const copyBtn = document.getElementById(copyActionId);
let rowCopyTimeout = null;
copyBtn.addEventListener('click', async () => {
try {
await copyTextToClipboard(shareLink);
if (rowCopyTimeout) clearTimeout(rowCopyTimeout);
copyBtn.innerHTML = `<i data-lucide="check" style="width: 12px; height: 12px;"></i> Copied!`;
copyBtn.style.borderColor = 'var(--accent)';
copyBtn.style.color = 'var(--accent)';
lucide.createIcons();
rowCopyTimeout = setTimeout(() => {
copyBtn.innerHTML = `<i data-lucide="copy" style="width: 12px; height: 12px;"></i> Copy Link`;
copyBtn.style.borderColor = '';
copyBtn.style.color = '';
lucide.createIcons();
}, 2000);
} catch (clipErr) {
prompt('Public Share Link:', shareLink);
}
});
document.getElementById(revokeActionId).addEventListener('click', () => {
revokeSharedRecipeFromList(recipe.id, recipe.title);
});
});
lucide.createIcons();
}
async function revokeSharedRecipeFromList(recipeId, recipeTitle) {
if (!confirm(`Are you sure you want to revoke the public share link for "${recipeTitle}" as an Administrator?\n\nThis will make the recipe private, disable the public link immediately, and clear all visitor history.`)) return;
try {
const response = await fetch(`/api/recipes/${recipeId}/revoke-share`, {
method: 'POST',
headers: getAuthHeaders()
});
if (response.status === 401) {
logout();
return;
}
if (!response.ok) {
const err = await response.json().catch(() => ({}));
throw new Error(err.error || 'Failed to revoke shared link.');
}
alert(`Successfully revoked the public share link for "${recipeTitle}".`);
fetchAdminSharedRecipes();
} catch (error) {
console.error('Error revoking recipe share from admin console:', error);
alert(`Error: ${error.message}`);
}
}
async function handleAdminCreateUser(e) {
e.preventDefault();
elements.adminCreateError.style.display = 'none';
const username = elements.adminNewUsername.value.trim();
const password = elements.adminNewPassword.value;
try {
const response = await fetch('/api/auth/register', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...getAuthHeaders()
},
body: JSON.stringify({ username, password })
});
if (response.status === 401) {
logout();
return;
}
if (!response.ok) {
const err = await response.json().catch(() => ({}));
throw new Error(err.error || 'Failed to create chef account.');
}
closeModal('admin-create-user-modal');
elements.adminCreateUserForm.reset();
fetchAdminUsers();
} catch (error) {
elements.adminCreateErrorText.textContent = error.message;
elements.adminCreateError.style.display = 'flex';
}
}
window.confirmDeleteUser = function(id, username) {
elements.deleteUserTargetId.value = id;
elements.deleteUserTargetName.textContent = `@${username}`;
elements.deleteUserPreserveRecipes.checked = true;
openModal('admin-delete-user-modal');
};
async function handleAdminDeleteUser() {
const id = elements.deleteUserTargetId.value;
const preserveRecipes = elements.deleteUserPreserveRecipes.checked;
try {
const response = await fetch(`/api/admin/users/${id}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
...getAuthHeaders()
},
body: JSON.stringify({ preserveRecipes })
});
if (response.status === 401) {
logout();
return;
}
if (!response.ok) {
const err = await response.json().catch(() => ({}));
throw new Error(err.error || 'Failed to delete user.');
}
closeModal('admin-delete-user-modal');
fetchAdminUsers();
} catch (error) {
alert(`Error deleting user: ${error.message}`);
}
}
window.toggleUserRole = async function(id, makeAdmin) {
try {
const response = await fetch(`/api/admin/users/${id}/role`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
...getAuthHeaders()
},
body: JSON.stringify({ isAdmin: makeAdmin })
});
if (response.status === 401) {
logout();
return;
}
if (!response.ok) {
const err = await response.json().catch(() => ({}));
throw new Error(err.error || 'Failed to update user role.');
}
fetchAdminUsers();
} catch (error) {
alert(`Error updating role: ${error.message}`);
}
};
window.toggleRecipeManagerRole = async function(id, makeManager) {
try {
const response = await fetch(`/api/admin/users/${id}/recipe-manager`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
...getAuthHeaders()
},
body: JSON.stringify({ isRecipeManager: makeManager })
});
if (response.status === 401) {
logout();
return;
}
if (!response.ok) {
const err = await response.json().catch(() => ({}));
throw new Error(err.error || 'Failed to update recipe manager permission.');
}
fetchAdminUsers();
} catch (error) {
alert(`Error updating permission: ${error.message}`);
}
};
// -------------------------------------------------------------
// SYSTEM SETTINGS & GEMINI AI PHOTO SCANNER
// -------------------------------------------------------------
async function openSettingsModal() {
// Reset visibility states and statuses
elements.settingsStatusMsg.style.display = 'none';
elements.settingsGeminiKeyInput.type = 'password';
const toggleIcon = elements.btnToggleGeminiKey.querySelector('i, svg');
if (toggleIcon) toggleIcon.setAttribute('data-lucide', 'eye');
lucide.createIcons();
elements.settingsGeminiKeyInput.value = '';
elements.settingsGeminiKeyInput.placeholder = 'Loading credentials...';
openModal('settings-modal');
try {
const response = await fetch('/api/settings', {
headers: getAuthHeaders()
});
if (response.status === 401) {
logout();
closeModal('settings-modal');
return;
}
if (!response.ok) throw new Error('Settings could not be loaded.');
const settings = await response.json();
const key = settings.geminiApiKey ? settings.geminiApiKey.trim() : '';
const isPlaceholder = key === 'PASTE_YOUR_GEMINI_API_KEY_HERE';
elements.settingsGeminiKeyInput.placeholder = 'Enter your Gemini API key...';
if (key && !isPlaceholder) {
elements.settingsGeminiKeyInput.value = key;
}
if (elements.settingsDefaultConsolidate && typeof settings.defaultConsolidateLists === 'boolean') {
elements.settingsDefaultConsolidate.checked = settings.defaultConsolidateLists;
applyUserDefaultConsolidationPreference(settings.defaultConsolidateLists);
}
} catch (err) {
console.error('Error loading settings:', err);
elements.settingsGeminiKeyInput.placeholder = 'Failed to load settings';
}
}
async function handleSaveSettings(e) {
e.preventDefault();
elements.settingsStatusMsg.style.display = 'none';
const key = elements.settingsGeminiKeyInput.value.trim();
const defaultConsolidate = elements.settingsDefaultConsolidate ? elements.settingsDefaultConsolidate.checked : true;
try {
const response = await fetch('/api/settings', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...getAuthHeaders()
},
body: JSON.stringify({
geminiApiKey: key,
defaultConsolidateLists: defaultConsolidate
})
});
if (response.status === 401) {
logout();
closeModal('settings-modal');
return;
}
if (!response.ok) throw new Error('Failed to save settings file.');
applyUserDefaultConsolidationPreference(defaultConsolidate);
if (elements.settingsStatusText) {
elements.settingsStatusText.textContent = 'Settings saved successfully.';
}
// Show success status banner
elements.settingsStatusMsg.style.display = 'flex';
setTimeout(() => {
elements.settingsStatusMsg.style.display = 'none';
}, 4000);
} catch (err) {
console.error('Error saving settings:', err);
alert(`Failed to save settings: ${err.message}`);
}
}
function toggleSettingsKeyVisibility() {
const input = elements.settingsGeminiKeyInput;
const toggleIcon = elements.btnToggleGeminiKey.querySelector('i, svg');
if (input.type === 'password') {
input.type = 'text';
if (toggleIcon) toggleIcon.setAttribute('data-lucide', 'eye-off');
} else {
input.type = 'password';
if (toggleIcon) toggleIcon.setAttribute('data-lucide', 'eye');
}
lucide.createIcons();
}
function showScanLoading() {
elements.loadingOverlay.classList.add('active');
const title = elements.loadingOverlay.querySelector('h3');
const subtitle = elements.loadingOverlay.querySelector('.loader-subtitle');
if (title) title.textContent = 'Scanning Recipe Photo';
if (subtitle) subtitle.textContent = 'Loading parameters and initializing scanner...';
elements.loaderStepFetch.className = 'loader-step active';
elements.loaderStepParse.className = 'loader-step';
elements.loaderStepImages.className = 'loader-step';
elements.loaderStepFetch.querySelector('span:last-child').textContent = 'Loading system configuration...';
elements.loaderStepParse.querySelector('span:last-child').textContent = 'Compressing and optimizing image...';
elements.loaderStepImages.querySelector('span:last-child').textContent = 'Digitizing with Gemini Vision AI...';
setLoaderStepIcon(elements.loaderStepFetch, 'circle-dashed', true);
setLoaderStepIcon(elements.loaderStepParse, 'circle', false);
setLoaderStepIcon(elements.loaderStepImages, 'circle', false);
lucide.createIcons();
}
function hideScanLoading() {
elements.loadingOverlay.classList.remove('active');
// Restore original loading screen texts
const title = elements.loadingOverlay.querySelector('h3');
const subtitle = elements.loadingOverlay.querySelector('.loader-subtitle');
if (title) title.textContent = 'Extracting Recipe Data';
if (subtitle) subtitle.textContent = 'Retrieving site contents and parsing culinary structures...';
elements.loaderStepFetch.querySelector('span:last-child').textContent = 'Connecting to source URL...';
elements.loaderStepParse.querySelector('span:last-child').textContent = 'Parsing schema structures...';
elements.loaderStepImages.querySelector('span:last-child').textContent = 'Extracting high-res dish photos...';
}
function compressImage(file, maxDimension = 1600) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = (event) => {
const img = new Image();
img.src = event.target.result;
img.onload = () => {
let width = img.width;
let height = img.height;
// Downscale maintaining aspect ratio
if (width > maxDimension || height > maxDimension) {
if (width > height) {
height = Math.round((height * maxDimension) / width);
width = maxDimension;
} else {
width = Math.round((width * maxDimension) / height);
height = maxDimension;
}
}
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0, width, height);
const dataUrl = canvas.toDataURL('image/jpeg', 0.85);
const base64 = dataUrl.split(',')[1];
resolve({
dataUrl,
base64,
mimeType: 'image/jpeg'
});
};
img.onerror = (err) => reject(err);
};
reader.onerror = (err) => reject(err);
});
}
async function handleRecipeImageScan(e) {
const file = e.target.files[0];
if (!file) return;
scanRecipeFile(file);
}
async function handleClipboardPaste(e) {
// Only trigger scan if logged in to prevent unauthenticated access attempts
if (!currentUser) return;
const items = (e.clipboardData || e.originalEvent?.clipboardData)?.items;
if (!items) return;
for (const item of items) {
if (item.kind === 'file' && item.type.startsWith('image/')) {
const file = item.getAsFile();
e.preventDefault(); // Intercept paste action
scanRecipeFile(file);
break;
}
}
}
async function scanRecipeFile(file) {
showScanLoading();
try {
// 1. Fetch system-wide configuration
const settingsResponse = await fetch('/api/settings', {
headers: getAuthHeaders()
});
if (settingsResponse.status === 401) {
logout();
hideScanLoading();
return;
}
if (!settingsResponse.ok) {
throw new Error('Could not fetch settings. Please ensure system settings are configured.');
}
const settings = await settingsResponse.json();
const apiKey = settings.geminiApiKey ? settings.geminiApiKey.trim() : '';
const isPlaceholder = apiKey === 'PASTE_YOUR_GEMINI_API_KEY_HERE' || apiKey === '';
if (!apiKey || isPlaceholder) {
hideScanLoading();
elements.scanImageInput.value = '';
alert('Google Gemini API Key is not configured.\n\nPlease click the Settings button at the top right, configure your Google Gemini API Key, and try again.');
return;
}
// Step 1 Completed -> Activate Step 2 (Compression)
elements.loaderStepFetch.className = 'loader-step completed';
setLoaderStepIcon(elements.loaderStepFetch, 'check-circle-2', false);
elements.loaderStepParse.className = 'loader-step active';
setLoaderStepIcon(elements.loaderStepParse, 'circle-dashed', true);
lucide.createIcons();
// 2. Compress image using Canvas
const compressed = await compressImage(file, 1600);
// Step 2 Completed -> Activate Step 3 (Gemini transcription)
elements.loaderStepParse.className = 'loader-step completed';
setLoaderStepIcon(elements.loaderStepParse, 'check-circle-2', false);
elements.loaderStepImages.className = 'loader-step active';
setLoaderStepIcon(elements.loaderStepImages, 'circle-dashed', true);
lucide.createIcons();
// 3. Make direct call to Google Gemini Vision API
const promptText = "You are a professional culinary transcriber. Analyze the image of a recipe (which could be a cookbook page, handwritten note, screenshot, or packaging) and extract it accurately. Fill in all fields as completely as possible. The response must be a JSON object matching this schema:\n{\n \"title\": \"Recipe Title\",\n \"description\": \"Brief description or review\",\n \"servings\": \"e.g., 4\",\n \"prepTime\": \"e.g., 15 mins\",\n \"cookTime\": \"e.g., 30 mins\",\n \"totalTime\": \"e.g., 45 mins\",\n \"ingredients\": [\"ingredient 1\", \"ingredient 2\"],\n \"instructions\": [\"step 1\", \"step 2\"]\n}\nDo not include any markdown styling (like ```json) or explanation. Return only the raw JSON string.";
const geminiUrl = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${apiKey}`;
const geminiResponse = await fetch(geminiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
contents: [
{
parts: [
{ text: promptText },
{
inlineData: {
mimeType: compressed.mimeType,
data: compressed.base64
}
}
]
}
],
generationConfig: {
responseMimeType: "application/json"
}
})
});
if (!geminiResponse.ok) {
const errorObj = await geminiResponse.json().catch(() => ({}));
const status = geminiResponse.status;
let errMsg = 'Google Gemini API call failed.';
if (status === 400) {
errMsg = 'Invalid parameters or model version. Please verify your settings.';
} else if (status === 403) {
errMsg = 'Access denied. The Google Gemini API key is invalid or inactive.';
} else if (status === 429) {
errMsg = 'Rate limit exceeded. You have made too many requests or hit your Gemini quota.';
} else if (errorObj.error && errorObj.error.message) {
errMsg = `Gemini API Error: ${errorObj.error.message}`;
}
throw new Error(errMsg);
}
const result = await geminiResponse.json();
if (!result.candidates || result.candidates.length === 0 || !result.candidates[0].content) {
throw new Error('Gemini API returned an empty response. The text in the image may be unreadable.');
}
const rawText = result.candidates[0].content.parts[0].text;
const recipeData = JSON.parse(rawText);
// Auto-inject the scanned local picture as a custom visual preview option
recipeData.images = [compressed.dataUrl];
recipeData.image = compressed.dataUrl;
// 4. Finalize and trigger draft preview
hideScanLoading();
elements.scanImageInput.value = '';
openDraftPreview(recipeData);
} catch (error) {
console.error('Photo scanning error occurred:', error);
hideScanLoading();
elements.scanImageInput.value = '';
alert(`Scanning Failed: ${error.message}\n\nPlease verify your internet connection and check that your Gemini API Key is correct.`);
}
}
// -------------------------------------------------------------
// MOBILE PORTAL CONTROLS & GESTURES
// -------------------------------------------------------------
function openMobileProfile() {
if (!currentUser) return;
elements.mobileProfileUsername.textContent = `@${currentUser.username}`;
// Set role text
let roleText = 'Chef Account';
if (currentUser.isAdmin) {
roleText = 'Administrator';
} else if (currentUser.isRecipeManager) {
roleText = 'Recipe Manager';
}
elements.mobileProfileRole.textContent = roleText;
// Handle theme icon inside profile drawer
const isDark = document.body.classList.contains('dark-theme');
const sunIcon = elements.mobileBtnThemeToggle.querySelector('.sun-icon');
const moonIcon = elements.mobileBtnThemeToggle.querySelector('.moon-icon');
if (sunIcon && moonIcon) {
if (isDark) {
sunIcon.style.display = 'block';
moonIcon.style.display = 'none';
} else {
sunIcon.style.display = 'none';
moonIcon.style.display = 'block';
}
}
openModal('mobile-profile-modal');
}
function handleFabClick() {
// If not on cookbook tab, switch back to list first
if (activeTab !== 'my-cookbook') {
switchTab('my-cookbook');
}
// Open the manual recipe creator
openManualAdd();
}
// -------------------------------------------------------------
// SHOPPING LISTS MANAGEMENT & CONSOLIDATION ENGINE
// -------------------------------------------------------------
const AISLE_ORDER = [
'Produce',
'Meat & Seafood',
'Dairy & Eggs',
'Canned & Jarred',
'Bakery & Grains',
'Pantry & Spices',
'Frozen',
'Other'
];
const AISLE_ICONS = {
'Produce': 'apple',
'Meat & Seafood': 'beef',
'Dairy & Eggs': 'milk',
'Canned & Jarred': 'Archive',
'Bakery & Grains': 'wheat',
'Pantry & Spices': 'flame',
'Frozen': 'snowflake',
'Other': 'shopping-bag'
};
function applyUserDefaultConsolidationPreference(prefValue) {
const shouldConsolidate = prefValue !== false;
consolidateItems = shouldConsolidate;
if (elements.toggleConsolidateItems) {
elements.toggleConsolidateItems.checked = shouldConsolidate;
}
if (elements.settingsDefaultConsolidate) {
elements.settingsDefaultConsolidate.checked = shouldConsolidate;
}
if (currentUser) {
currentUser.defaultConsolidateLists = shouldConsolidate;
localStorage.setItem('gourmet_user', JSON.stringify(currentUser));
}
}
async function syncUserPreferencesFromServer() {
try {
const response = await fetch('/api/settings', { headers: getAuthHeaders() });
if (response.ok) {
const data = await response.json();
if (typeof data.defaultConsolidateLists === 'boolean') {
applyUserDefaultConsolidationPreference(data.defaultConsolidateLists);
}
}
} catch (err) {
console.warn('Failed to sync user preferences:', err);
}
}
async function handleDefaultConsolidatePreferenceChange(e) {
const newPref = e.target.checked;
try {
const response = await fetch('/api/user/preferences', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
...getAuthHeaders()
},
body: JSON.stringify({ defaultConsolidateLists: newPref })
});
if (response.status === 401) {
logout();
return;
}
if (!response.ok) throw new Error('Failed to save default consolidation preference.');
applyUserDefaultConsolidationPreference(newPref);
renderShoppingListsWorkspace();
if (elements.settingsStatusText && elements.settingsStatusMsg) {
elements.settingsStatusText.textContent = 'Default consolidation preference updated.';
elements.settingsStatusMsg.style.display = 'flex';
setTimeout(() => {
elements.settingsStatusMsg.style.display = 'none';
}, 3000);
}
} catch (err) {
console.error('Error updating consolidation preference:', err);
}
}
async function fetchShoppingLists() {
if (!currentUser) return;
try {
const response = await fetch('/api/shopping-lists', {
headers: getAuthHeaders()
});
if (response.status === 401) {
logout();
return;
}
if (!response.ok) throw new Error('Failed to load shopping lists.');
shoppingLists = await response.json();
if (!activeShoppingListId || !shoppingLists.some(l => l.id === activeShoppingListId)) {
activeShoppingListId = shoppingLists.length > 0 ? shoppingLists[0].id : null;
}
renderShoppingListsWorkspace();
populateDetailShoppingListSelect();
} catch (error) {
console.error('Error loading shopping lists:', error);
}
}
function getActiveShoppingList() {
return shoppingLists.find(l => l.id === activeShoppingListId) || shoppingLists[0] || null;
}
function updateShoppingListInState(updatedList) {
const idx = shoppingLists.findIndex(l => l.id === updatedList.id);
if (idx !== -1) {
shoppingLists[idx] = updatedList;
} else {
shoppingLists.push(updatedList);
}
activeShoppingListId = updatedList.id;
renderShoppingListsWorkspace();
populateDetailShoppingListSelect();
}
function updateShoppingTabBadges() {
const activeList = getActiveShoppingList();
const uncheckedCount = activeList && Array.isArray(activeList.items)
? activeList.items.filter(i => !i.checked).length
: 0;
if (elements.shoppingTabBadge) {
if (uncheckedCount > 0) {
elements.shoppingTabBadge.textContent = uncheckedCount;
elements.shoppingTabBadge.style.display = 'inline-block';
} else {
elements.shoppingTabBadge.style.display = 'none';
}
}
if (elements.mobileShoppingTabBadge) {
if (uncheckedCount > 0) {
elements.mobileShoppingTabBadge.textContent = uncheckedCount;
elements.mobileShoppingTabBadge.style.display = 'flex';
} else {
elements.mobileShoppingTabBadge.style.display = 'none';
}
}
}
function populateDetailShoppingListSelect() {
if (!elements.detailShoppingListSelect) return;
elements.detailShoppingListSelect.innerHTML = '';
shoppingLists.forEach(list => {
const opt = document.createElement('option');
opt.value = list.id;
opt.textContent = list.name;
if (list.id === activeShoppingListId) opt.selected = true;
elements.detailShoppingListSelect.appendChild(opt);
});
const newOpt = document.createElement('option');
newOpt.value = '__new__';
newOpt.textContent = '+ Create New List...';
elements.detailShoppingListSelect.appendChild(newOpt);
}
async function handleDetailListSelectChange(e) {
if (e.target.value === '__new__') {
const name = prompt('Enter a name for the new shopping list:', 'Weekend Groceries');
if (name && name.trim()) {
try {
const res = await fetch('/api/shopping-lists', {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
body: JSON.stringify({ name: name.trim() })
});
if (res.ok) {
const created = await res.json();
updateShoppingListInState(created);
elements.detailShoppingListSelect.value = created.id;
return;
}
} catch (err) {
console.error('Error creating list from detail modal:', err);
}
}
populateDetailShoppingListSelect();
} else {
activeShoppingListId = e.target.value;
renderShoppingListsWorkspace();
}
}
let addRecipeBtnTimeout = null;
async function handleAddRecipeToShoppingList() {
if (!activeRecipe) return;
let targetListId = elements.detailShoppingListSelect ? elements.detailShoppingListSelect.value : activeShoppingListId;
if (!targetListId || targetListId === '__new__') {
const activeList = getActiveShoppingList();
if (!activeList) return;
targetListId = activeList.id;
}
// Gather unchecked ingredients from the Recipe Detail checklist (or all if all are checked)
const listElements = Array.from(elements.recipeDetailIngredients.querySelectorAll('.ingredient-checkbox-item'));
let selectedLines = listElements
.filter(li => !li.classList.contains('checked'))
.map(li => li.querySelector('.ingredient-text')?.textContent?.trim())
.filter(Boolean);
if (selectedLines.length === 0 && listElements.length > 0) {
selectedLines = listElements
.map(li => li.querySelector('.ingredient-text')?.textContent?.trim())
.filter(Boolean);
}
if (selectedLines.length === 0) return;
try {
const response = await fetch(`/api/shopping-lists/${targetListId}/items`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...getAuthHeaders()
},
body: JSON.stringify({
items: selectedLines,
recipeId: activeRecipe.id,
recipeTitle: activeRecipe.title
})
});
if (response.status === 401) {
logout();
return;
}
if (!response.ok) throw new Error('Failed to add ingredients to shopping list.');
const updatedList = await response.json();
updateShoppingListInState(updatedList);
if (elements.btnAddRecipeToList) {
if (addRecipeBtnTimeout) clearTimeout(addRecipeBtnTimeout);
elements.btnAddRecipeToList.innerHTML = `<i data-lucide="check"></i> <span>Added (${selectedLines.length})!</span>`;
elements.btnAddRecipeToList.style.backgroundColor = 'var(--accent)';
elements.btnAddRecipeToList.style.borderColor = 'var(--accent)';
lucide.createIcons();
addRecipeBtnTimeout = setTimeout(() => {
elements.btnAddRecipeToList.innerHTML = `<i data-lucide="shopping-cart"></i> <span>Add to List</span>`;
elements.btnAddRecipeToList.style.backgroundColor = '';
elements.btnAddRecipeToList.style.borderColor = '';
lucide.createIcons();
}, 2000);
}
} catch (error) {
console.error('Error adding recipe to shopping list:', error);
alert('Failed to add ingredients to shopping list.');
}
}
async function handleCreateShoppingList() {
const name = prompt('Enter a name for your new shopping list:', '');
if (!name || !name.trim()) return;
try {
const response = await fetch('/api/shopping-lists', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...getAuthHeaders()
},
body: JSON.stringify({ name: name.trim() })
});
if (response.status === 401) {
logout();
return;
}
if (!response.ok) throw new Error('Failed to create shopping list.');
const created = await response.json();
updateShoppingListInState(created);
} catch (err) {
console.error('Error creating shopping list:', err);
alert('Could not create shopping list.');
}
}
async function handleRenameShoppingList() {
const activeList = getActiveShoppingList();
if (!activeList) return;
const newName = prompt('Rename shopping list:', activeList.name);
if (!newName || !newName.trim() || newName.trim() === activeList.name) return;
try {
const response = await fetch(`/api/shopping-lists/${activeList.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
...getAuthHeaders()
},
body: JSON.stringify({ name: newName.trim() })
});
if (response.status === 401) {
logout();
return;
}
if (!response.ok) throw new Error('Failed to rename shopping list.');
const updated = await response.json();
updateShoppingListInState(updated);
} catch (err) {
console.error('Error renaming shopping list:', err);
alert('Could not rename shopping list.');
}
}
async function handleDeleteShoppingList() {
const activeList = getActiveShoppingList();
if (!activeList) return;
if (!confirm(`Are you sure you want to delete the shopping list "${activeList.name}" and all its items?`)) return;
try {
const response = await fetch(`/api/shopping-lists/${activeList.id}`, {
method: 'DELETE',
headers: getAuthHeaders()
});
if (response.status === 401) {
logout();
return;
}
if (!response.ok) throw new Error('Failed to delete shopping list.');
activeShoppingListId = null;
await fetchShoppingLists();
} catch (err) {
console.error('Error deleting shopping list:', err);
alert('Could not delete shopping list.');
}
}
async function handleAddManualShoppingItem(e) {
e.preventDefault();
const activeList = getActiveShoppingList();
if (!activeList) return;
const text = elements.shoppingAddItemInput.value.trim();
if (!text) return;
try {
const response = await fetch(`/api/shopping-lists/${activeList.id}/items`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...getAuthHeaders()
},
body: JSON.stringify({ items: [text] })
});
if (response.status === 401) {
logout();
return;
}
if (!response.ok) throw new Error('Failed to add item.');
const updated = await response.json();
elements.shoppingAddItemInput.value = '';
updateShoppingListInState(updated);
} catch (err) {
console.error('Error adding manual item:', err);
alert('Could not add item.');
}
}
async function handleToggleShoppingItems(itemIds, checked) {
const activeList = getActiveShoppingList();
if (!activeList || !Array.isArray(itemIds) || itemIds.length === 0) return;
try {
const response = await fetch(`/api/shopping-lists/${activeList.id}/items/check`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
...getAuthHeaders()
},
body: JSON.stringify({ itemIds, checked })
});
if (response.status === 401) {
logout();
return;
}
if (!response.ok) throw new Error('Failed to toggle item.');
const updated = await response.json();
updateShoppingListInState(updated);
} catch (err) {
console.error('Error toggling shopping item:', err);
}
}
async function handleDeleteShoppingItems(itemIds) {
const activeList = getActiveShoppingList();
if (!activeList || !Array.isArray(itemIds) || itemIds.length === 0) return;
try {
const response = await fetch(`/api/shopping-lists/${activeList.id}/items/delete`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...getAuthHeaders()
},
body: JSON.stringify({ itemIds })
});
if (response.status === 401) {
logout();
return;
}
if (!response.ok) throw new Error('Failed to delete item.');
const updated = await response.json();
updateShoppingListInState(updated);
} catch (err) {
console.error('Error deleting shopping item:', err);
}
}
async function handleClearShoppingList(onlyChecked) {
const activeList = getActiveShoppingList();
if (!activeList) return;
if (!onlyChecked && !confirm(`Are you sure you want to clear all items from "${activeList.name}"?`)) return;
try {
const response = await fetch(`/api/shopping-lists/${activeList.id}/clear`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...getAuthHeaders()
},
body: JSON.stringify({ onlyChecked })
});
if (response.status === 401) {
logout();
return;
}
if (!response.ok) throw new Error('Failed to clear items.');
const updated = await response.json();
updateShoppingListInState(updated);
} catch (err) {
console.error('Error clearing shopping list:', err);
}
}
let shoppingShareBtnTimeout = null;
let shoppingCopyTextTimeout = null;
async function handleShareShoppingList() {
const activeList = getActiveShoppingList();
if (!activeList) return;
try {
const response = await fetch(`/api/shopping-lists/${activeList.id}/share`, {
method: 'POST',
headers: getAuthHeaders()
});
if (response.status === 401) {
logout();
return;
}
if (!response.ok) throw new Error('Failed to generate share link.');
const updated = await response.json();
updateShoppingListInState(updated);
const shareUrl = `${window.location.origin}/share-list.html?token=${updated.shareToken}`;
await copyTextToClipboard(shareUrl);
if (elements.btnShoppingShare) {
if (shoppingShareBtnTimeout) clearTimeout(shoppingShareBtnTimeout);
elements.btnShoppingShare.innerHTML = `<i data-lucide="check"></i> <span>Copied!</span>`;
elements.btnShoppingShare.style.borderColor = 'var(--accent)';
elements.btnShoppingShare.style.color = 'var(--accent)';
lucide.createIcons();
shoppingShareBtnTimeout = setTimeout(() => {
elements.btnShoppingShare.innerHTML = `<i data-lucide="share-2"></i> <span>Share Link</span>`;
elements.btnShoppingShare.style.borderColor = '';
elements.btnShoppingShare.style.color = '';
lucide.createIcons();
}, 2000);
}
} catch (err) {
console.error('Error sharing shopping list:', err);
alert('Failed to generate or copy share link.');
}
}
async function handleRevokeShoppingListShare() {
const activeList = getActiveShoppingList();
if (!activeList) return;
if (!confirm(`Disable the public share link for "${activeList.name}" and reset visitor stats?`)) return;
try {
const response = await fetch(`/api/shopping-lists/${activeList.id}/revoke-share`, {
method: 'POST',
headers: getAuthHeaders()
});
if (response.status === 401) {
logout();
return;
}
if (!response.ok) throw new Error('Failed to revoke share link.');
const updated = await response.json();
updateShoppingListInState(updated);
} catch (err) {
console.error('Error revoking shopping list share:', err);
}
}
async function handleCopyShoppingListText() {
const activeList = getActiveShoppingList();
if (!activeList || !Array.isArray(activeList.items) || activeList.items.length === 0) return;
const displayEntries = buildShoppingListDisplayEntries(activeList.items, consolidateItems);
const grouped = groupShoppingEntries(displayEntries, shoppingGroupBy);
const lines = [`🛒 ${activeList.name}`];
for (const [groupName, entries] of grouped.entries()) {
lines.push(`\n[${groupName}]`);
entries.forEach(entry => {
const box = entry.checked ? '[x]' : '[ ]';
lines.push(`${box} ${entry.displayText}`);
});
}
try {
await copyTextToClipboard(lines.join('\n'));
if (elements.btnShoppingCopyText) {
if (shoppingCopyTextTimeout) clearTimeout(shoppingCopyTextTimeout);
elements.btnShoppingCopyText.innerHTML = `<i data-lucide="check"></i> <span>Copied!</span>`;
elements.btnShoppingCopyText.style.borderColor = 'var(--accent)';
elements.btnShoppingCopyText.style.color = 'var(--accent)';
lucide.createIcons();
shoppingCopyTextTimeout = setTimeout(() => {
elements.btnShoppingCopyText.innerHTML = `<i data-lucide="copy"></i> <span>Copy Text</span>`;
elements.btnShoppingCopyText.style.borderColor = '';
elements.btnShoppingCopyText.style.color = '';
lucide.createIcons();
}, 2000);
}
} catch (err) {
console.error('Error copying shopping list text:', err);
}
}
function buildShoppingListDisplayEntries(items, shouldConsolidate) {
if (!shouldConsolidate) {
return items.map(item => ({
itemIds: [item.id],
displayText: item.rawText,
category: item.category || 'Other',
sources: item.recipeTitle ? [item.recipeTitle] : [],
checked: !!item.checked
}));
}
// Group items by normalized itemName + checked state + unit so compatible items sum cleanly
const groups = new Map();
for (const item of items) {
const normName = (item.itemName || item.rawText || '').toLowerCase().trim();
const normUnit = (item.unit || '').toLowerCase().trim();
const key = `${item.checked ? '1' : '0'}::${normName}::${normUnit}`;
if (!groups.has(key)) {
groups.set(key, {
itemIds: [item.id],
rawTexts: [item.rawText],
itemName: normName,
unit: normUnit,
totalQty: item.quantity !== null && item.quantity !== undefined ? Number(item.quantity) : null,
hasNullQty: item.quantity === null || item.quantity === undefined,
category: item.category || 'Other',
sources: item.recipeTitle ? [item.recipeTitle] : [],
checked: !!item.checked
});
} else {
const g = groups.get(key);
g.itemIds.push(item.id);
g.rawTexts.push(item.rawText);
if (item.quantity !== null && item.quantity !== undefined && !g.hasNullQty) {
g.totalQty = (g.totalQty || 0) + Number(item.quantity);
} else {
g.hasNullQty = true;
}
if (item.recipeTitle && !g.sources.includes(item.recipeTitle)) {
g.sources.push(item.recipeTitle);
}
}
}
const results = [];
for (const g of groups.values()) {
let displayText = g.rawTexts[0];
if (g.itemIds.length > 1) {
if (!g.hasNullQty && g.totalQty !== null && g.totalQty > 0) {
const qtyStr = formatScaledNumber(g.totalQty);
const unitStr = g.unit ? (g.totalQty > 1 && !g.unit.endsWith('s') && ['clove', 'can', 'jar', 'bunch', 'slice', 'piece', 'head', 'stalk', 'sprig', 'stick', 'cup'].includes(g.unit) ? `${g.unit}s ` : `${g.unit} `) : '';
const displayItem = g.itemName.charAt(0).toUpperCase() + g.itemName.slice(1);
displayText = `${qtyStr} ${unitStr}${displayItem}`;
} else {
const displayItem = g.itemName.charAt(0).toUpperCase() + g.itemName.slice(1);
displayText = `${displayItem} (×${g.itemIds.length})`;
}
}
results.push({
itemIds: g.itemIds,
displayText,
category: g.category,
sources: g.sources,
checked: g.checked
});
}
return results;
}
function groupShoppingEntries(entries, mode) {
const grouped = new Map();
if (mode === 'recipe') {
for (const entry of entries) {
const groupLabels = entry.sources.length > 0 ? [entry.sources.join(' + ')] : ['Manual / Household Staples'];
for (const label of groupLabels) {
if (!grouped.has(label)) grouped.set(label, []);
grouped.get(label).push(entry);
}
}
return grouped;
}
// Default: Group by Grocery Aisle in store walk-through order
for (const aisle of AISLE_ORDER) {
const matching = entries.filter(e => (e.category || 'Other') === aisle);
if (matching.length > 0) {
// Sort unchecked first
matching.sort((a, b) => Number(a.checked) - Number(b.checked));
grouped.set(aisle, matching);
}
}
return grouped;
}
function renderShoppingListsWorkspace() {
if (!elements.shoppingListSelect) return;
// Populate select options
elements.shoppingListSelect.innerHTML = '';
shoppingLists.forEach(list => {
const opt = document.createElement('option');
opt.value = list.id;
const unchecked = Array.isArray(list.items) ? list.items.filter(i => !i.checked).length : 0;
opt.textContent = `${list.name} (${unchecked})`;
if (list.id === activeShoppingListId) opt.selected = true;
elements.shoppingListSelect.appendChild(opt);
});
updateShoppingTabBadges();
const activeList = getActiveShoppingList();
if (!activeList) {
elements.shoppingListsContent.innerHTML = '';
elements.shoppingEmptyState.style.display = 'flex';
return;
}
// Share status UI
if (activeList.shareToken) {
elements.btnShoppingRevokeShare.style.display = 'inline-flex';
elements.shoppingShareStatsBadge.style.display = 'inline-flex';
const vCount = activeList.visitorCount || 0;
elements.shoppingShareStatsBadge.querySelector('.val').textContent = `${vCount} Unique ${vCount === 1 ? 'Visitor' : 'Visitors'}`;
} else {
elements.btnShoppingRevokeShare.style.display = 'none';
elements.shoppingShareStatsBadge.style.display = 'none';
}
const items = activeList.items || [];
if (items.length === 0) {
elements.shoppingListsContent.innerHTML = '';
elements.shoppingEmptyState.style.display = 'flex';
lucide.createIcons();
return;
}
elements.shoppingEmptyState.style.display = 'none';
elements.shoppingListsContent.innerHTML = '';
const entries = buildShoppingListDisplayEntries(items, consolidateItems);
const grouped = groupShoppingEntries(entries, shoppingGroupBy);
for (const [groupName, groupEntries] of grouped.entries()) {
const card = document.createElement('div');
card.className = 'shopping-category-card';
const iconName = AISLE_ICONS[groupName] || 'utensils';
const uncheckedInGroup = groupEntries.filter(e => !e.checked).length;
card.innerHTML = `
<div class="shopping-category-header">
<div class="shopping-category-title">
<i data-lucide="${iconName.toLowerCase()}"></i>
<span>${groupName}</span>
</div>
<span class="shopping-category-count">${uncheckedInGroup} / ${groupEntries.length}</span>
</div>
<ul class="shopping-items-list"></ul>
`;
const ul = card.querySelector('.shopping-items-list');
groupEntries.forEach(entry => {
const li = document.createElement('li');
li.className = `shopping-item-row ${entry.checked ? 'checked' : ''}`;
const sourcesHtml = entry.sources.length > 0
? `<div class="shopping-item-sources">${entry.sources.map(s => `<span class="shopping-source-tag">${s}</span>`).join('')}</div>`
: '';
li.innerHTML = `
<div class="shopping-item-main">
<div class="checkbox-custom"><i data-lucide="check" style="width:12px;height:12px;"></i></div>
<div class="shopping-item-text-wrap">
<span class="shopping-item-text">${entry.displayText}</span>
${sourcesHtml}
</div>
</div>
<button type="button" class="shopping-item-delete" title="Remove Item">
<i data-lucide="x"></i>
</button>
`;
li.querySelector('.shopping-item-main').addEventListener('click', () => {
handleToggleShoppingItems(entry.itemIds, !entry.checked);
});
li.querySelector('.shopping-item-delete').addEventListener('click', (e) => {
e.stopPropagation();
handleDeleteShoppingItems(entry.itemIds);
});
ul.appendChild(li);
});
elements.shoppingListsContent.appendChild(card);
}
lucide.createIcons();
}
// ==========================================
// ADMIN DATABASE BACKUPS & RECOVERY CONSOLE
// ==========================================
let backupStatusTimeout = null;
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 = `<i data-lucide="${icon}" style="width:18px;height:18px;flex-shrink:0;"></i><span>${message}</span>`;
banner.style.display = 'flex';
lucide.createIcons();
if (type !== 'error') {
backupStatusTimeout = setTimeout(() => {
banner.style.display = 'none';
}, 8000);
}
}
function showGdriveInlineStatus(message, type = 'info') {
const box = elements.gdriveInlineStatus;
if (!box) return;
if (gdriveInlineTimeout) clearTimeout(gdriveInlineTimeout);
box.classList.remove('hidden');
if (type === 'success') {
box.style.background = 'rgba(16, 185, 129, 0.12)';
box.style.border = '1px solid rgba(16, 185, 129, 0.35)';
box.style.color = '#10b981';
} else if (type === 'error') {
box.style.background = 'rgba(239, 68, 68, 0.12)';
box.style.border = '1px solid rgba(239, 68, 68, 0.35)';
box.style.color = '#ef4444';
} else {
box.style.background = 'rgba(59, 130, 246, 0.12)';
box.style.border = '1px solid rgba(59, 130, 246, 0.35)';
box.style.color = '#3b82f6';
}
box.textContent = message;
if (type !== 'error') {
gdriveInlineTimeout = setTimeout(() => {
box.classList.add('hidden');
}, 7000);
}
}
function getBackupFormPayload() {
const selectedOpt = elements.backupGdriveFolderSelect && elements.backupGdriveFolderSelect.selectedOptions[0];
return {
scheduleFrequency: elements.backupScheduleFrequency ? elements.backupScheduleFrequency.value : 'off',
scheduleTime: elements.backupScheduleTime ? elements.backupScheduleTime.value : '02:00',
scheduleDay: elements.backupScheduleDay ? Number(elements.backupScheduleDay.value) || 0 : 0,
retentionCount: elements.backupRetentionCount ? Number(elements.backupRetentionCount.value) : 14,
backupFormat: elements.backupFormat ? elements.backupFormat.value : 'both',
embedRemoteImages: elements.backupEmbedImages ? elements.backupEmbedImages.checked : true,
s3Enabled: elements.backupS3Enabled ? elements.backupS3Enabled.checked : false,
s3Endpoint: elements.backupS3Endpoint ? elements.backupS3Endpoint.value.trim() : 'https://s3.us-east-1.wasabisys.com',
s3Region: elements.backupS3Region ? elements.backupS3Region.value.trim() : 'us-east-1',
s3Bucket: elements.backupS3Bucket ? elements.backupS3Bucket.value.trim() : '',
s3Prefix: elements.backupS3Prefix ? elements.backupS3Prefix.value.trim() : 'forkfolio-backups/',
s3AccessKeyId: elements.backupS3AccessKey ? elements.backupS3AccessKey.value.trim() : '',
s3SecretAccessKey: elements.backupS3SecretKey ? elements.backupS3SecretKey.value.trim() : '',
gdriveEnabled: elements.backupGdriveEnabled ? elements.backupGdriveEnabled.checked : false,
gdriveFolderId: elements.backupGdriveFolderSelect && !elements.backupGdriveFolderSelect.disabled ? elements.backupGdriveFolderSelect.value : undefined,
gdriveFolderName: selectedOpt && !elements.backupGdriveFolderSelect.disabled ? selectedOpt.dataset.folderName || selectedOpt.textContent : undefined
};
}
let lastGdriveFoldersFetchAt = 0;
function renderGdriveConnectionState(cfg) {
const isConnected = Boolean(cfg.gdriveConnected);
if (elements.gdriveStatusDot) {
elements.gdriveStatusDot.style.background = isConnected ? '#10b981' : 'var(--text-muted)';
elements.gdriveStatusDot.style.boxShadow = isConnected ? '0 0 8px rgba(16, 185, 129, 0.6)' : 'none';
}
if (elements.gdriveStatusTitle) {
elements.gdriveStatusTitle.textContent = isConnected
? `Connected to Google Drive${cfg.gdriveAccountEmail ? ` (${cfg.gdriveAccountEmail})` : ''}`
: 'Not Connected to Google Drive';
elements.gdriveStatusTitle.style.color = isConnected ? '#10b981' : 'var(--text-main)';
}
if (elements.gdriveStatusSubtitle) {
elements.gdriveStatusSubtitle.textContent = isConnected
? `Destination folder: "${cfg.gdriveFolderName || 'ForkFolio Backups'}"`
: 'Click Authorize with Google to link your account in one click.';
}
if (elements.btnAuthorizeGdriveLabel) {
elements.btnAuthorizeGdriveLabel.textContent = isConnected ? 'Switch Account' : 'Authorize with Google';
}
if (elements.btnBackupAuthorizeGdrive) {
if (isConnected) {
elements.btnBackupAuthorizeGdrive.classList.remove('btn-primary');
elements.btnBackupAuthorizeGdrive.classList.add('btn-outline');
} else {
elements.btnBackupAuthorizeGdrive.classList.add('btn-primary');
elements.btnBackupAuthorizeGdrive.classList.remove('btn-outline');
}
}
if (elements.btnBackupDisconnectGdrive) {
elements.btnBackupDisconnectGdrive.classList.toggle('hidden', !isConnected);
}
if (elements.backupGdriveFolderSelect) {
elements.backupGdriveFolderSelect.disabled = !isConnected;
if (!isConnected) {
lastGdriveFoldersFetchAt = 0;
elements.backupGdriveFolderSelect.innerHTML = `<option value="">Authorize with Google first to select a folder...</option>`;
} else if (cfg.gdriveFolderId && (!elements.backupGdriveFolderSelect.value || elements.backupGdriveFolderSelect.options.length <= 1)) {
elements.backupGdriveFolderSelect.innerHTML = '';
const opt = document.createElement('option');
opt.value = cfg.gdriveFolderId;
opt.dataset.folderName = cfg.gdriveFolderName || 'ForkFolio Backups';
opt.textContent = `📁 ${cfg.gdriveFolderName || 'ForkFolio Backups'}`;
opt.selected = true;
elements.backupGdriveFolderSelect.appendChild(opt);
}
}
if (elements.btnGdriveNewFolder) {
elements.btnGdriveNewFolder.disabled = !isConnected;
}
if (elements.btnGdriveRefreshFolders) {
elements.btnGdriveRefreshFolders.disabled = !isConnected;
}
if (isConnected && Date.now() - lastGdriveFoldersFetchAt > 60 * 1000) {
loadGoogleDriveFolders(false, cfg.gdriveFolderId, cfg.gdriveFolderName);
}
}
async function loadGoogleDriveFolders(showFeedback = false, fallbackId = '', fallbackName = 'ForkFolio Backups') {
if (!elements.backupGdriveFolderSelect) return;
try {
lastGdriveFoldersFetchAt = Date.now();
if (showFeedback) {
showGdriveInlineStatus('Loading folders from your Google Drive...', 'info');
}
const res = await fetch('/api/admin/backups/gdrive/folders', {
headers: getAuthHeaders()
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Failed to load Google Drive folders.');
const folders = Array.isArray(data.folders) ? data.folders : [];
const activeId = data.currentFolderId || fallbackId;
const activeName = data.currentFolderName || fallbackName || 'ForkFolio Backups';
if (data.accountEmail && elements.gdriveStatusTitle) {
elements.gdriveStatusTitle.textContent = `Connected to Google Drive (${data.accountEmail})`;
}
elements.backupGdriveFolderSelect.innerHTML = '';
// Ensure the active folder is present in the list
const hasActive = folders.some(f => f.id === activeId);
if (activeId && !hasActive) {
folders.unshift({ id: activeId, name: activeName });
}
folders.forEach(folder => {
const opt = document.createElement('option');
opt.value = folder.id;
opt.dataset.folderName = folder.name;
opt.textContent = `📁 ${folder.name}`;
if (folder.id === activeId) {
opt.selected = true;
}
elements.backupGdriveFolderSelect.appendChild(opt);
});
if (elements.gdriveStatusSubtitle) {
const selOpt = elements.backupGdriveFolderSelect.selectedOptions[0];
const dispName = selOpt ? (selOpt.dataset.folderName || activeName) : activeName;
elements.gdriveStatusSubtitle.textContent = `Destination folder: "${dispName}"`;
}
if (showFeedback) {
showGdriveInlineStatus(`Loaded ${folders.length} folder(s) from Google Drive.`, 'success');
}
} catch (err) {
console.warn('Error loading Google Drive folders:', err);
if (showFeedback) {
showGdriveInlineStatus(err.message, 'error');
}
}
}
async function handleSelectGdriveFolder() {
if (!elements.backupGdriveFolderSelect) return;
const folderId = elements.backupGdriveFolderSelect.value;
const selectedOpt = elements.backupGdriveFolderSelect.selectedOptions[0];
const folderName = selectedOpt ? (selectedOpt.dataset.folderName || selectedOpt.textContent.replace(/^📁\s*/, '')) : 'ForkFolio Backups';
if (!folderId) return;
try {
const res = await fetch('/api/admin/backups/gdrive/select-folder', {
method: 'PUT',
headers: getAuthHeaders(),
body: JSON.stringify({ folderId, folderName })
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Failed to save selected folder.');
if (elements.backupGdriveEnabled) elements.backupGdriveEnabled.checked = true;
if (elements.gdriveStatusSubtitle) {
elements.gdriveStatusSubtitle.textContent = `Destination folder: "${folderName}"`;
}
showGdriveInlineStatus(`✓ Backups will now be saved to "${folderName}" in Google Drive.`, 'success');
} catch (err) {
showGdriveInlineStatus(err.message, 'error');
}
}
async function handleCreateGdriveFolder() {
const folderName = window.prompt('Enter a name for the new Google Drive backup folder:', 'ForkFolio Backups');
if (!folderName || !folderName.trim()) return;
const btn = elements.btnGdriveNewFolder;
const origHtml = btn.innerHTML;
btn.disabled = true;
try {
showGdriveInlineStatus(`Creating folder "${folderName.trim()}" in Google Drive...`, 'info');
const res = await fetch('/api/admin/backups/gdrive/folders', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ name: folderName.trim() })
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Failed to create folder.');
await loadGoogleDriveFolders(false, data.folder.id, data.folder.name);
if (elements.backupGdriveEnabled) elements.backupGdriveEnabled.checked = true;
showGdriveInlineStatus(`✓ Created and selected folder "${data.folder.name}"!`, 'success');
} catch (err) {
showGdriveInlineStatus(err.message, 'error');
} finally {
btn.disabled = false;
btn.innerHTML = origHtml;
lucide.createIcons();
}
}
async function handleDisconnectGoogleDrive() {
const confirmed = window.confirm('Disconnect your Google Drive account from ForkFolio?');
if (!confirmed) return;
try {
const res = await fetch('/api/admin/backups/gdrive/disconnect', {
method: 'POST',
headers: getAuthHeaders()
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Failed to disconnect Google Drive.');
showGdriveInlineStatus('Google Drive account disconnected.', 'info');
fetchAdminBackupConfig();
} catch (err) {
showGdriveInlineStatus(err.message, 'error');
}
}
async function fetchAdminBackupConfig() {
try {
const res = await fetch('/api/admin/backups/config', {
headers: getAuthHeaders()
});
if (res.status === 401) {
logout();
return;
}
if (!res.ok) throw new Error('Failed to load backup configuration.');
const data = await res.json();
const cfg = data.config || {};
if (elements.backupScheduleFrequency) elements.backupScheduleFrequency.value = cfg.scheduleFrequency || cfg.schedule || 'off';
if (elements.backupScheduleTime) elements.backupScheduleTime.value = cfg.scheduleTime || '02:00';
if (elements.backupScheduleDay) elements.backupScheduleDay.value = String(cfg.scheduleDay !== undefined ? cfg.scheduleDay : 0);
if (elements.backupRetentionCount) elements.backupRetentionCount.value = String(cfg.retentionCount !== undefined ? cfg.retentionCount : 14);
if (elements.backupFormat) elements.backupFormat.value = cfg.backupFormat || 'both';
if (elements.backupEmbedImages) elements.backupEmbedImages.checked = Boolean(cfg.embedRemoteImages);
if (elements.backupS3Enabled) elements.backupS3Enabled.checked = Boolean(cfg.s3Enabled);
if (elements.backupS3Endpoint) elements.backupS3Endpoint.value = cfg.s3Endpoint || 'https://s3.us-east-1.wasabisys.com';
if (elements.backupS3Region) elements.backupS3Region.value = cfg.s3Region || 'us-east-1';
if (elements.backupS3Bucket) elements.backupS3Bucket.value = cfg.s3Bucket || '';
if (elements.backupS3Prefix) elements.backupS3Prefix.value = cfg.s3Prefix || 'forkfolio-backups/';
if (elements.backupS3AccessKey) elements.backupS3AccessKey.value = cfg.s3AccessKeyId || '';
if (elements.backupS3SecretKey) elements.backupS3SecretKey.value = cfg.s3SecretAccessKey || '';
if (elements.backupGdriveEnabled) elements.backupGdriveEnabled.checked = Boolean(cfg.gdriveEnabled);
renderGdriveConnectionState(cfg);
if (elements.backupLastRunBadge) {
elements.backupLastRunBadge.style.display = 'inline-flex';
if (cfg.lastRunAt) {
const dt = new Date(cfg.lastRunAt).toLocaleString();
const st = cfg.lastRunStatus === 'success' ? '✓ Success' : cfg.lastRunStatus === 'error' ? '⚠ Failed' : cfg.lastRunStatus || '';
elements.backupLastRunBadge.textContent = `Last Cloud Run: ${dt} (${st})`;
} else {
elements.backupLastRunBadge.textContent = 'Never run';
}
}
renderAdminBackupHistory(data.history || []);
// Automatically scan cloud storage if connected and local history is empty
if ((!data.history || data.history.length === 0) && (cfg.gdriveConnected || cfg.s3Enabled)) {
handleSyncCloudBackups(true);
}
} catch (err) {
console.error('Error fetching backup config:', err);
showBackupStatus(err.message, 'error');
}
}
function formatBytesHuman(bytes) {
const n = Number(bytes) || 0;
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / (1024 * 1024)).toFixed(2)} MB`;
}
function renderAdminBackupHistory(history) {
if (!elements.adminBackupsHistoryList) return;
elements.adminBackupsHistoryList.innerHTML = '';
if (!history || history.length === 0) {
elements.adminBackupsHistoryList.innerHTML = `
<tr>
<td colspan="7" style="text-align: center; color: var(--text-muted); padding: 24px; font-style: italic;">
No cloud backups found yet. Click "Backup to Cloud Now" to create one, or "Sync from Cloud" / "Restore from Cloud" to scan Google Drive & Wasabi S3.
</td>
</tr>
`;
return;
}
history.forEach(item => {
const tr = document.createElement('tr');
const dateStr = new Date(item.createdAt).toLocaleString();
const triggerBadge = item.triggerType === 'scheduled'
? `<span class="admin-role-badge" style="background:rgba(99,102,241,0.12);color:#6366f1;padding:3px 8px;border-radius:6px;font-size:0.78rem;">Scheduled</span>`
: item.triggerType === 'cloud'
? `<span class="admin-role-badge" style="background:rgba(16,185,129,0.12);color:#10b981;padding:3px 8px;border-radius:6px;font-size:0.78rem;">Cloud Sync</span>`
: `<span class="admin-role-badge" style="background:rgba(249,115,22,0.12);color:var(--accent);padding:3px 8px;border-radius:6px;font-size:0.78rem;">Manual</span>`;
const destBadges = [];
if (item.s3KeyDb || item.s3KeyJson) {
destBadges.push(`<span class="admin-role-badge" style="background:rgba(16,185,129,0.12);color:#10b981;padding:3px 8px;border-radius:6px;font-size:0.76rem;margin-right:4px;">Wasabi S3</span>`);
}
if (item.gdriveFileIdDb || item.gdriveFileIdJson) {
destBadges.push(`<span class="admin-role-badge" style="background:rgba(59,130,246,0.12);color:#3b82f6;padding:3px 8px;border-radius:6px;font-size:0.76rem;">Google Drive</span>`);
}
const statusBadge = item.status === 'success'
? `<span style="color:#10b981;font-weight:600;font-size:0.84rem;">✓ Available</span>`
: `<span style="color:#ef4444;font-weight:600;font-size:0.84rem;" title="${(item.errorMessage || '').replace(/"/g, '&quot;')}">⚠ Error</span>`;
const hasDbFile = Boolean(item.s3KeyDb || item.gdriveFileIdDb);
const hasJsonFile = Boolean(item.s3KeyJson || item.gdriveFileIdJson);
const hasCloudFiles = hasDbFile || hasJsonFile;
const countsText = (item.userCount || item.recipeCount || item.shoppingListCount)
? `${item.userCount} users · ${item.recipeCount} recipes · ${item.shoppingListCount} lists`
: `<span style="font-style:italic;">${item.label || 'Cloud Snapshot'}</span>`;
tr.innerHTML = `
<td style="font-size:0.88rem;color:var(--text-main);white-space:nowrap;">${dateStr}</td>
<td>${triggerBadge}</td>
<td style="font-size:0.85rem;color:var(--text-muted);">${countsText}</td>
<td style="font-size:0.85rem;color:var(--text-muted);white-space:nowrap;">
${hasDbFile ? `DB: ${formatBytesHuman(item.dbSizeBytes)}` : ''}${hasDbFile && hasJsonFile ? '<br>' : ''}${hasJsonFile ? `JSON: ${formatBytesHuman(item.jsonSizeBytes)}` : ''}
</td>
<td>${destBadges.join('') || '<span style="color:var(--text-muted);font-size:0.8rem;">None</span>'}</td>
<td>${statusBadge}${item.errorMessage ? `<div style="font-size:0.75rem;color:#ef4444;max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${item.errorMessage.replace(/"/g, '&quot;')}">${item.errorMessage}</div>` : ''}</td>
<td style="text-align:right;white-space:nowrap;">
${hasDbFile ? `
<button type="button" class="btn btn-outline btn-dl-db" style="padding:5px 9px;font-size:0.76rem;border-radius:6px;margin-right:4px;" title="Download .db Snapshot from Cloud to Computer">
<i data-lucide="download" style="width:12px;height:12px;"></i> .DB
</button>
` : ''}
${hasJsonFile ? `
<button type="button" class="btn btn-outline btn-dl-json" style="padding:5px 9px;font-size:0.76rem;border-radius:6px;margin-right:4px;" title="Download .json Archive from Cloud to Computer">
<i data-lucide="file-json" style="width:12px;height:12px;"></i> .JSON
</button>
` : ''}
${hasCloudFiles ? `
<button type="button" class="btn btn-outline btn-restore-cloud" style="padding:5px 10px;font-size:0.76rem;border-radius:6px;margin-right:4px;border-color:rgba(16,185,129,0.45);color:#10b981;font-weight:600;" title="Restore Database from this Cloud Backup">
<i data-lucide="rotate-ccw" style="width:12px;height:12px;"></i> Restore
</button>
` : ''}
<button type="button" class="btn btn-danger btn-delete-cloud" style="padding:5px 9px;font-size:0.76rem;border-radius:6px;" title="Delete Snapshot from Cloud & History">
<i data-lucide="trash-2" style="width:12px;height:12px;"></i>
</button>
</td>
`;
const btnDlDb = tr.querySelector('.btn-dl-db');
if (btnDlDb) btnDlDb.addEventListener('click', () => downloadCloudBackupItem(item.id, 'db', item.filenameDb));
const btnDlJson = tr.querySelector('.btn-dl-json');
if (btnDlJson) btnDlJson.addEventListener('click', () => downloadCloudBackupItem(item.id, 'json', item.filenameJson));
const btnRestore = tr.querySelector('.btn-restore-cloud');
if (btnRestore) btnRestore.addEventListener('click', () => restoreCloudBackupItem(item.id, dateStr));
const btnDelete = tr.querySelector('.btn-delete-cloud');
if (btnDelete) btnDelete.addEventListener('click', () => deleteCloudBackupItem(item.id, dateStr));
elements.adminBackupsHistoryList.appendChild(tr);
});
lucide.createIcons();
}
async function handleSaveBackupConfig(e) {
e.preventDefault();
try {
const payload = getBackupFormPayload();
const res = await fetch('/api/admin/backups/config', {
method: 'PUT',
headers: getAuthHeaders(),
body: JSON.stringify(payload)
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Failed to save backup configuration.');
showBackupStatus('Backup & schedule settings saved!', 'success');
fetchAdminBackupConfig();
} catch (err) {
showBackupStatus(err.message, 'error');
}
}
async function handleTestS3Connection() {
const btn = elements.btnBackupTestS3;
const origHtml = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = `<i data-lucide="loader" style="width:13px;height:13px;"></i> Testing...`;
lucide.createIcons();
try {
const payload = getBackupFormPayload();
const res = await fetch('/api/admin/backups/test-s3', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify(payload)
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Wasabi S3 connection test failed.');
showBackupStatus(data.message || 'Wasabi S3 connection verified!', 'success');
} catch (err) {
showBackupStatus(`Wasabi S3 Error: ${err.message}`, 'error');
} finally {
btn.disabled = false;
btn.innerHTML = origHtml;
lucide.createIcons();
}
}
async function handleTestGdriveConnection() {
const btn = elements.btnBackupTestGdrive;
const origHtml = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = `<i data-lucide="loader" style="width:13px;height:13px;"></i> Testing...`;
lucide.createIcons();
try {
const payload = getBackupFormPayload();
const res = await fetch('/api/admin/backups/test-gdrive', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify(payload)
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Google Drive connection test failed.');
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('<body style="font-family:sans-serif;background:#0f172a;color:#f8fafc;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;"><h3>Connecting to Google Drive...</h3></body>');
}
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 = `<i data-lucide="loader" style="width:15px;height:15px;"></i> Backing up to Cloud...`;
lucide.createIcons();
try {
// First save current form config so any newly toggled provider is persisted
const payload = getBackupFormPayload();
await fetch('/api/admin/backups/config', {
method: 'PUT',
headers: getAuthHeaders(),
body: JSON.stringify(payload)
});
const res = await fetch('/api/admin/backups/run', {
method: 'POST',
headers: getAuthHeaders()
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Cloud backup failed.');
showBackupStatus('Cloud backup completed! Temporary local snapshot was wiped after upload.', 'success');
fetchAdminBackupConfig();
} catch (err) {
showBackupStatus(err.message, 'error');
fetchAdminBackupConfig();
} finally {
btn.disabled = false;
btn.innerHTML = origHtml;
lucide.createIcons();
}
}
async function handleDownloadLocalBackup(format) {
const btn = format === 'json' ? elements.btnBackupDownloadJson : elements.btnBackupDownloadDb;
const origHtml = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = `<i data-lucide="loader" style="width:15px;height:15px;"></i> Preparing...`;
lucide.createIcons();
try {
const res = await fetch(`/api/admin/backups/download-local?format=${encodeURIComponent(format)}`, {
headers: getAuthHeaders()
});
if (!res.ok) {
const errData = await res.json().catch(() => ({}));
throw new Error(errData.error || 'Failed to generate local backup download.');
}
const blob = await res.blob();
const disposition = res.headers.get('Content-Disposition') || '';
const match = disposition.match(/filename="?([^"]+)"?/i);
const filename = match ? match[1] : `forkfolio-backup-${new Date().toISOString().replace(/[:.]/g, '-')}.${format === 'json' ? 'json' : 'db'}`;
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
showBackupStatus(`Downloaded ${filename} to your computer (no copy kept on server).`, 'success');
} catch (err) {
showBackupStatus(err.message, 'error');
} finally {
btn.disabled = false;
btn.innerHTML = origHtml;
lucide.createIcons();
}
}
async function downloadCloudBackupItem(id, format, fallbackName) {
try {
showBackupStatus(`Streaming ${format.toUpperCase()} snapshot from cloud storage...`, 'info');
const res = await fetch(`/api/admin/backups/${id}/download-cloud?format=${encodeURIComponent(format)}`, {
headers: getAuthHeaders()
});
if (!res.ok) {
const errData = await res.json().catch(() => ({}));
throw new Error(errData.error || 'Failed to download backup from cloud.');
}
const blob = await res.blob();
const filename = fallbackName || `forkfolio-cloud-backup-${id}.${format}`;
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
showBackupStatus(`Downloaded ${filename} from cloud storage.`, 'success');
} catch (err) {
showBackupStatus(err.message, 'error');
}
}
async function restoreCloudBackupItem(id, dateLabel) {
openScopedRestoreModal({
mode: 'history',
historyId: id,
format: 'db',
titleLabel: `Cloud Backup (${dateLabel})`
});
}
async function deleteCloudBackupItem(id, dateLabel) {
const confirmed = window.confirm(`Delete the cloud backup from ${dateLabel} from remote storage and history?`);
if (!confirmed) return;
try {
const res = await fetch(`/api/admin/backups/${id}`, {
method: 'DELETE',
headers: getAuthHeaders()
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Failed to delete backup.');
showBackupStatus('Cloud backup deleted.', 'success');
fetchAdminBackupConfig();
} catch (err) {
showBackupStatus(err.message, 'error');
}
}
async function handleRestoreLocalFile(e) {
const file = e.target.files && e.target.files[0];
e.target.value = '';
if (!file) return;
try {
showBackupStatus(`Reading "${file.name}"...`, 'info');
const base64Data = await new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const result = String(reader.result || '');
const commaIdx = result.indexOf(',');
resolve(commaIdx >= 0 ? result.slice(commaIdx + 1) : result);
};
reader.onerror = () => reject(new Error('Failed to read selected file.'));
reader.readAsDataURL(file);
});
openScopedRestoreModal({
mode: 'upload',
filename: file.name,
base64Data,
titleLabel: `Local File: "${file.name}"`
});
} catch (err) {
showBackupStatus(err.message, 'error');
}
}
// ==========================================
// LIVE CLOUD BACKUP BROWSER & RESTORE MODAL
// ==========================================
let cachedCloudBackups = [];
let activeRestoreScopeContext = null;
function showCloudRestoreModalStatus(message, type = 'info') {
const box = elements.cloudRestoreModalStatus;
if (!box) return;
box.classList.remove('hidden');
if (type === 'success') {
box.style.background = 'rgba(16, 185, 129, 0.12)';
box.style.border = '1px solid rgba(16, 185, 129, 0.35)';
box.style.color = '#10b981';
} else if (type === 'error') {
box.style.background = 'rgba(239, 68, 68, 0.12)';
box.style.border = '1px solid rgba(239, 68, 68, 0.35)';
box.style.color = '#ef4444';
} else {
box.style.background = 'rgba(59, 130, 246, 0.12)';
box.style.border = '1px solid rgba(59, 130, 246, 0.35)';
box.style.color = '#3b82f6';
}
box.textContent = message;
}
function showRestoreScopeStatus(message, type = 'info') {
const box = elements.restoreScopeStatus;
if (!box) return;
box.classList.remove('hidden');
if (type === 'success') {
box.style.background = 'rgba(16, 185, 129, 0.12)';
box.style.border = '1px solid rgba(16, 185, 129, 0.35)';
box.style.color = '#10b981';
} else if (type === 'error') {
box.style.background = 'rgba(239, 68, 68, 0.12)';
box.style.border = '1px solid rgba(239, 68, 68, 0.35)';
box.style.color = '#ef4444';
} else {
box.style.background = 'rgba(59, 130, 246, 0.12)';
box.style.border = '1px solid rgba(59, 130, 246, 0.35)';
box.style.color = '#3b82f6';
}
box.textContent = message;
}
function updateRestoreScopeModeUI() {
const isSingle = elements.restoreScopeSingle && elements.restoreScopeSingle.checked;
if (elements.restoreScopeCardAll) {
elements.restoreScopeCardAll.style.borderColor = isSingle ? 'var(--border)' : 'var(--accent)';
}
if (elements.restoreScopeCardSingle) {
elements.restoreScopeCardSingle.style.borderColor = isSingle ? 'var(--accent)' : 'var(--border)';
}
if (elements.restoreTargetUserSelect) {
elements.restoreTargetUserSelect.disabled = !isSingle;
}
}
async function openScopedRestoreModal(context) {
activeRestoreScopeContext = context;
if (elements.restoreScopeStatus) {
elements.restoreScopeStatus.classList.add('hidden');
}
if (elements.restoreScopeAll) elements.restoreScopeAll.checked = true;
if (elements.restoreScopeSingle) elements.restoreScopeSingle.checked = false;
updateRestoreScopeModeUI();
if (elements.restoreScopeBackupSummary) {
elements.restoreScopeBackupSummary.innerHTML = `<strong>Source:</strong> ${context.titleLabel}<br><span style="font-size:0.82rem;">Inspecting users and recipes inside backup...</span>`;
}
if (elements.restoreTargetUserSelect) {
elements.restoreTargetUserSelect.innerHTML = `<option value="">Loading users from backup...</option>`;
}
openModal('restore-scope-modal');
lucide.createIcons();
try {
const inspectBody = {};
if (context.mode === 'upload') {
inspectBody.filename = context.filename;
inspectBody.base64Data = context.base64Data;
} else if (context.mode === 'direct') {
inspectBody.provider = context.provider;
inspectBody.fileId = context.fileId;
inspectBody.objectKey = context.objectKey;
inspectBody.filename = context.filename;
} else if (context.mode === 'history') {
inspectBody.historyId = context.historyId;
inspectBody.format = context.format || 'db';
}
const res = await fetch('/api/admin/backups/inspect', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify(inspectBody)
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Failed to inspect backup contents.');
if (elements.restoreScopeBackupSummary) {
elements.restoreScopeBackupSummary.innerHTML = `
<div style="color:var(--text-main);font-weight:600;margin-bottom:2px;">${context.titleLabel}</div>
<div>Contains <strong>${data.totalUsers}</strong> users · <strong>${data.totalRecipes}</strong> recipes · <strong>${data.totalShoppingLists}</strong> shopping lists</div>
`;
}
if (elements.restoreScopeAllDesc) {
elements.restoreScopeAllDesc.textContent = `Replaces all users (${data.totalUsers}), recipes (${data.totalRecipes}), images, and shopping lists (${data.totalShoppingLists}) with this backup.`;
}
if (elements.restoreTargetUserSelect) {
elements.restoreTargetUserSelect.innerHTML = '';
const users = Array.isArray(data.users) ? data.users : [];
if (users.length === 0) {
elements.restoreTargetUserSelect.innerHTML = `<option value="">No individual users found in backup</option>`;
} else {
users.forEach(u => {
const opt = document.createElement('option');
opt.value = u.username;
const roleLabel = u.isAdmin ? ' (Admin)' : u.isRecipeManager ? ' (Manager)' : '';
opt.textContent = `@${u.username}${roleLabel} — ${u.recipeCount} ${u.recipeCount === 1 ? 'recipe' : 'recipes'}, ${u.shoppingListCount} ${u.shoppingListCount === 1 ? 'shopping list' : 'shopping lists'}`;
elements.restoreTargetUserSelect.appendChild(opt);
});
}
}
} catch (err) {
if (elements.restoreScopeBackupSummary) {
elements.restoreScopeBackupSummary.innerHTML = `<div style="color:var(--text-main);font-weight:600;">${context.titleLabel}</div>`;
}
showRestoreScopeStatus(`Could not pre-inspect user list: ${err.message}`, 'error');
}
}
async function executeScopedRestore() {
if (!activeRestoreScopeContext) return;
const isSingle = elements.restoreScopeSingle && elements.restoreScopeSingle.checked;
const targetUser = isSingle
? (elements.restoreTargetUserSelect ? elements.restoreTargetUserSelect.value : '')
: 'all';
if (isSingle && !targetUser) {
showRestoreScopeStatus('Please select which user to restore from the dropdown.', 'error');
return;
}
const btn = elements.btnConfirmScopedRestore;
const origHtml = btn ? btn.innerHTML : '';
if (btn) {
btn.disabled = true;
btn.innerHTML = `<i data-lucide="loader" style="width:15px;height:15px;"></i> <span>Restoring...</span>`;
lucide.createIcons();
}
try {
showRestoreScopeStatus(
isSingle
? `Restoring only @${targetUser}'s recipes, images, and shopping lists...`
: 'Restoring entire database (all users, recipes, and shopping lists)...',
'info'
);
const ctx = activeRestoreScopeContext;
let res;
if (ctx.mode === 'upload') {
res = await fetch('/api/admin/backups/restore-upload', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({
filename: ctx.filename,
dataBase64: ctx.base64Data,
targetUser
})
});
} else if (ctx.mode === 'direct') {
res = await fetch('/api/admin/backups/restore-cloud-file', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({
provider: ctx.provider,
fileId: ctx.fileId,
objectKey: ctx.objectKey,
filename: ctx.filename,
targetUser
})
});
} else {
res = await fetch(`/api/admin/backups/${ctx.historyId}/restore-cloud`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({
format: ctx.format || 'db',
targetUser
})
});
}
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Restore failed.');
const r = data.restored || {};
const msg =
r.mode === 'single_user'
? `✓ Restored data for @${r.targetUsername} (${r.recipeCount} recipes, ${r.shoppingListCount} shopping lists) without modifying other users!`
: `✓ Full database restored (${r.userCount} users, ${r.recipeCount} recipes, ${r.shoppingListCount} shopping lists)!`;
showRestoreScopeStatus(msg, 'success');
showBackupStatus(msg, 'success');
await fetchRecipes();
await fetchShoppingLists();
await fetchAdminBackupConfig();
setTimeout(() => {
closeModal('restore-scope-modal');
closeModal('restore-cloud-modal');
}, 1100);
} catch (err) {
showRestoreScopeStatus(`Restore failed: ${err.message}`, 'error');
showBackupStatus(`Restore failed: ${err.message}`, 'error');
} finally {
if (btn) {
btn.disabled = false;
btn.innerHTML = origHtml;
lucide.createIcons();
}
}
}
async function handleSyncCloudBackups(silent = false) {
const btn = elements.btnSyncCloudBackups;
const origHtml = btn ? btn.innerHTML : '';
if (btn) {
btn.disabled = true;
btn.innerHTML = `<i data-lucide="loader" style="width:13px;height:13px;"></i> <span>Scanning Cloud...</span>`;
lucide.createIcons();
}
try {
const res = await fetch('/api/admin/backups/cloud-files', {
headers: getAuthHeaders()
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Failed to scan cloud backups.');
cachedCloudBackups = data.backups || [];
renderAdminBackupHistory(cachedCloudBackups);
if (!silent) {
if (data.providerErrors && data.providerErrors.length > 0) {
showBackupStatus(`Synced ${cachedCloudBackups.length} cloud backup(s) (${data.providerErrors.join('; ')}).`, 'info');
} else {
showBackupStatus(`Synced ${cachedCloudBackups.length} cloud backup(s) from connected storage.`, 'success');
}
}
} catch (err) {
if (!silent) showBackupStatus(err.message, 'error');
} finally {
if (btn) {
btn.disabled = false;
btn.innerHTML = origHtml;
lucide.createIcons();
}
}
}
function openCloudRestoreModal() {
if (elements.cloudRestoreModalStatus) {
elements.cloudRestoreModalStatus.classList.add('hidden');
}
openModal('restore-cloud-modal');
loadLiveCloudBackupsForModal(false);
}
async function loadLiveCloudBackupsForModal(showToast = false) {
if (!elements.cloudRestoreFilesContainer) return;
elements.cloudRestoreFilesContainer.innerHTML = `
<div style="text-align: center; padding: 32px; color: var(--text-muted);">
<p>Scanning your Google Drive backup folder & Wasabi S3 bucket for available backups...</p>
</div>
`;
try {
const res = await fetch('/api/admin/backups/cloud-files', {
headers: getAuthHeaders()
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Failed to scan cloud storage.');
cachedCloudBackups = data.backups || [];
renderAdminBackupHistory(cachedCloudBackups);
renderCloudRestoreModalList();
if (data.providerErrors && data.providerErrors.length > 0) {
showCloudRestoreModalStatus(`Note: ${data.providerErrors.join(' | ')}`, 'error');
} else if (showToast) {
showCloudRestoreModalStatus(`Found ${cachedCloudBackups.length} backup snapshot(s) in cloud storage.`, 'success');
}
} catch (err) {
showCloudRestoreModalStatus(err.message, 'error');
elements.cloudRestoreFilesContainer.innerHTML = `
<div style="text-align: center; padding: 28px; color: var(--text-muted);">
Could not load cloud backups. Make sure Google Drive or Wasabi S3 is connected.
</div>
`;
}
}
function renderCloudRestoreModalList() {
if (!elements.cloudRestoreFilesContainer) return;
const filter = elements.cloudRestoreProviderFilter ? elements.cloudRestoreProviderFilter.value : 'all';
const filtered = cachedCloudBackups.filter(snap => {
const hasGdrive = Boolean(snap.gdriveFileIdDb || snap.gdriveFileIdJson);
const hasS3 = Boolean(snap.s3KeyDb || snap.s3KeyJson);
if (filter === 'gdrive') return hasGdrive;
if (filter === 's3') return hasS3;
return hasGdrive || hasS3;
});
elements.cloudRestoreFilesContainer.innerHTML = '';
if (filtered.length === 0) {
elements.cloudRestoreFilesContainer.innerHTML = `
<div style="text-align: center; padding: 32px; color: var(--text-muted); border: 1px dashed var(--border); border-radius: 10px;">
No cloud backups found for this source. Click "Backup to Cloud Now" to create your first cloud snapshot, or verify your Google Drive destination folder.
</div>
`;
return;
}
filtered.forEach(snap => {
const card = document.createElement('div');
card.style.cssText = 'background: var(--bg-main); border: 1px solid var(--border); border-radius: 10px; padding: 14px 16px; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 12px;';
const dateStr = new Date(snap.createdAt).toLocaleString();
const badges = [];
if (snap.gdriveFileIdDb || snap.gdriveFileIdJson) {
badges.push(`<span class="admin-role-badge" style="background:rgba(59,130,246,0.12);color:#3b82f6;padding:3px 8px;border-radius:6px;font-size:0.75rem;">Google Drive</span>`);
}
if (snap.s3KeyDb || snap.s3KeyJson) {
badges.push(`<span class="admin-role-badge" style="background:rgba(16,185,129,0.12);color:#10b981;padding:3px 8px;border-radius:6px;font-size:0.75rem;">Wasabi S3</span>`);
}
const statsLine = (snap.userCount || snap.recipeCount || snap.shoppingListCount)
? `${snap.userCount} users · ${snap.recipeCount} recipes · ${snap.shoppingListCount} shopping lists`
: snap.label;
const hasDb = Boolean(snap.gdriveFileIdDb || snap.s3KeyDb);
const hasJson = Boolean(snap.gdriveFileIdJson || snap.s3KeyJson);
card.innerHTML = `
<div style="display: flex; flex-direction: column; gap: 4px;">
<div style="display: flex; align-items: center; gap: 8px; flex-wrap: wrap;">
<strong style="color: var(--text-main); font-size: 0.95rem;">${dateStr}</strong>
${badges.join('')}
</div>
<div style="font-size: 0.82rem; color: var(--text-muted);">
${statsLine}
${hasDb ? ` · DB: ${formatBytesHuman(snap.dbSizeBytes)}` : ''}
${hasJson ? ` · JSON: ${formatBytesHuman(snap.jsonSizeBytes)}` : ''}
</div>
</div>
<div style="display: flex; align-items: center; gap: 8px; flex-wrap: wrap;">
${hasDb ? `
<button type="button" class="btn btn-primary btn-sm btn-modal-restore-db" title="Restore full database or a single user from .DB snapshot">
<i data-lucide="rotate-ccw" style="width:13px;height:13px;"></i> <span>Restore (.DB)</span>
</button>
` : ''}
${hasJson ? `
<button type="button" class="btn ${hasDb ? 'btn-outline' : 'btn-primary'} btn-sm btn-modal-restore-json" title="Restore full database or a single user from .JSON archive">
<i data-lucide="file-json" style="width:13px;height:13px;"></i> <span>Restore (.JSON)</span>
</button>
` : ''}
</div>
`;
const btnRestoreDb = card.querySelector('.btn-modal-restore-db');
if (btnRestoreDb) {
btnRestoreDb.addEventListener('click', () => handleRestoreDirectCloudFile(snap, 'db', dateStr));
}
const btnRestoreJson = card.querySelector('.btn-modal-restore-json');
if (btnRestoreJson) {
btnRestoreJson.addEventListener('click', () => handleRestoreDirectCloudFile(snap, 'json', dateStr));
}
elements.cloudRestoreFilesContainer.appendChild(card);
});
lucide.createIcons();
}
function handleRestoreDirectCloudFile(snap, format, dateLabel) {
const gdriveId = format === 'json' ? (snap.gdriveFileIdJson || snap.gdriveFileIdDb) : (snap.gdriveFileIdDb || snap.gdriveFileIdJson);
const s3Key = format === 'json' ? (snap.s3KeyJson || snap.s3KeyDb) : (snap.s3KeyDb || snap.s3KeyJson);
const filename = format === 'json' ? snap.filenameJson : snap.filenameDb;
if (gdriveId) {
openScopedRestoreModal({
mode: 'direct',
provider: 'gdrive',
fileId: gdriveId,
filename,
titleLabel: `Google Drive (${dateLabel} · .${format.toUpperCase()})`
});
} else if (s3Key) {
openScopedRestoreModal({
mode: 'direct',
provider: 's3',
objectKey: s3Key,
filename,
titleLabel: `Wasabi S3 (${dateLabel} · .${format.toUpperCase()})`
});
} else {
openScopedRestoreModal({
mode: 'history',
historyId: snap.id,
format,
titleLabel: `Cloud Backup (${dateLabel} · .${format.toUpperCase()})`
});
}
}