mirror of
https://gitlab.com/Zer0interrupt/recipe-manager.git
synced 2026-11-01 23:24:11 -05:00
4631 lines
172 KiB
JavaScript
4631 lines
172 KiB
JavaScript
// ForkFolio client application state
|
||
let allRecipes = [];
|
||
let activeRecipe = null;
|
||
let currentDraftRawCaption = '';
|
||
let currentMultiplier = 1;
|
||
let selectedCoverImage = '';
|
||
let stopwatchInterval = null;
|
||
let stopwatchSeconds = 0;
|
||
let cookingActiveStep = 0;
|
||
let currentUser = null;
|
||
let activeTab = 'my-cookbook';
|
||
let shoppingLists = [];
|
||
let activeShoppingListId = null;
|
||
let consolidateItems = true;
|
||
let shoppingGroupBy = 'aisle';
|
||
|
||
// Canonical base URL / FQDN tracking (resolved from server or window.location fallback)
|
||
let systemAppUrl = '';
|
||
|
||
function getBaseUrl() {
|
||
return systemAppUrl || window.location.origin;
|
||
}
|
||
|
||
async function fetchAppConfig() {
|
||
try {
|
||
const res = await fetch('/api/app-config');
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
if (data && data.appUrl) {
|
||
systemAppUrl = data.appUrl.replace(/\/+$/, '');
|
||
}
|
||
}
|
||
} catch (err) {
|
||
console.debug('Failed to fetch app configuration, defaulting to window.location.origin:', err);
|
||
}
|
||
}
|
||
|
||
// 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'),
|
||
btnSocialImport: document.getElementById('btn-social-import'),
|
||
btnManualAdd: document.getElementById('btn-manual-add'),
|
||
btnSettings: document.getElementById('btn-settings'),
|
||
settingsModal: document.getElementById('settings-modal'),
|
||
settingsApiForm: document.getElementById('settings-api-form'),
|
||
settingsGeminiKeyInput: document.getElementById('settings-gemini-key'),
|
||
settingsGeminiModelSelect: document.getElementById('settings-gemini-model'),
|
||
btnDiscoverModels: document.getElementById('btn-discover-models'),
|
||
modelsStatusHint: document.getElementById('models-status-hint'),
|
||
btnToggleGeminiKey: document.getElementById('btn-toggle-gemini-key'),
|
||
settingsStatusMsg: document.getElementById('settings-status-msg'),
|
||
settingsStatusText: document.getElementById('settings-status-text'),
|
||
scanImageInput: document.getElementById('scan-image-input'),
|
||
btnScanImage: document.getElementById('btn-scan-image'),
|
||
recipesGrid: document.getElementById('recipes-grid'),
|
||
emptyState: document.getElementById('empty-state'),
|
||
librarySearch: document.getElementById('library-search'),
|
||
|
||
// Auth caching
|
||
authModal: document.getElementById('auth-modal'),
|
||
loginForm: document.getElementById('login-form'),
|
||
authErrorMsg: document.getElementById('auth-error-msg'),
|
||
authErrorText: document.getElementById('auth-error-text'),
|
||
loginUsernameInput: document.getElementById('login-username'),
|
||
loginPasswordInput: document.getElementById('login-password'),
|
||
userBadgeContainer: document.getElementById('user-badge-container'),
|
||
userDisplayName: document.getElementById('user-display-name'),
|
||
btnLogout: document.getElementById('btn-logout'),
|
||
tabMyCookbook: document.getElementById('tab-my-cookbook'),
|
||
tabCommunityVault: document.getElementById('tab-community-vault'),
|
||
tabUserAdmin: document.getElementById('tab-user-admin'),
|
||
userAdminPanel: document.getElementById('user-admin-panel'),
|
||
adminUsersList: document.getElementById('admin-users-list'),
|
||
btnAdminAddUser: document.getElementById('btn-admin-add-user'),
|
||
btnAdminTabUsers: document.getElementById('btn-admin-tab-users'),
|
||
btnAdminTabShares: document.getElementById('btn-admin-tab-shares'),
|
||
btnAdminTabBackups: document.getElementById('btn-admin-tab-backups'),
|
||
adminUsersSection: document.getElementById('admin-users-section'),
|
||
adminSharesSection: document.getElementById('admin-shares-section'),
|
||
adminBackupsSection: document.getElementById('admin-backups-section'),
|
||
adminSharesList: document.getElementById('admin-shares-list'),
|
||
|
||
// Admin Backups Console Elements
|
||
btnBackupRunCloud: document.getElementById('btn-backup-run-cloud'),
|
||
btnBackupDownloadDb: document.getElementById('btn-backup-download-db'),
|
||
btnBackupDownloadJson: document.getElementById('btn-backup-download-json'),
|
||
backupRestoreFileInput: document.getElementById('backup-restore-file-input'),
|
||
btnBackupRestoreLocal: document.getElementById('btn-backup-restore-local'),
|
||
backupStatusBanner: document.getElementById('backup-status-banner'),
|
||
adminBackupConfigForm: document.getElementById('admin-backup-config-form'),
|
||
backupLastRunBadge: document.getElementById('backup-last-run-badge'),
|
||
backupScheduleFrequency: document.getElementById('backup-schedule-frequency'),
|
||
backupScheduleTime: document.getElementById('backup-schedule-time'),
|
||
backupScheduleDay: document.getElementById('backup-schedule-day'),
|
||
backupRetentionCount: document.getElementById('backup-retention-count'),
|
||
backupFormat: document.getElementById('backup-format'),
|
||
backupEmbedImages: document.getElementById('backup-embed-images'),
|
||
backupS3Enabled: document.getElementById('backup-s3-enabled'),
|
||
backupS3Endpoint: document.getElementById('backup-s3-endpoint'),
|
||
backupS3Region: document.getElementById('backup-s3-region'),
|
||
backupS3Bucket: document.getElementById('backup-s3-bucket'),
|
||
backupS3Prefix: document.getElementById('backup-s3-prefix'),
|
||
backupS3AccessKey: document.getElementById('backup-s3-access-key'),
|
||
backupS3SecretKey: document.getElementById('backup-s3-secret-key'),
|
||
btnBackupTestS3: document.getElementById('btn-backup-test-s3'),
|
||
backupGdriveEnabled: document.getElementById('backup-gdrive-enabled'),
|
||
gdriveStatusDot: document.getElementById('gdrive-status-dot'),
|
||
gdriveStatusTitle: document.getElementById('gdrive-status-title'),
|
||
gdriveStatusSubtitle: document.getElementById('gdrive-status-subtitle'),
|
||
btnAuthorizeGdriveLabel: document.getElementById('btn-authorize-gdrive-label'),
|
||
btnBackupAuthorizeGdrive: document.getElementById('btn-backup-authorize-gdrive'),
|
||
btnBackupDisconnectGdrive: document.getElementById('btn-backup-disconnect-gdrive'),
|
||
backupGdriveFolderSelect: document.getElementById('backup-gdrive-folder-select'),
|
||
btnGdriveNewFolder: document.getElementById('btn-gdrive-new-folder'),
|
||
btnGdriveRefreshFolders: document.getElementById('btn-gdrive-refresh-folders'),
|
||
gdriveFolderHint: document.getElementById('gdrive-folder-hint'),
|
||
gdriveInlineStatus: document.getElementById('gdrive-inline-status'),
|
||
btnBackupTestGdrive: document.getElementById('btn-backup-test-gdrive'),
|
||
btnBackupRestoreCloudModal: document.getElementById('btn-backup-restore-cloud-modal'),
|
||
btnSyncCloudBackups: document.getElementById('btn-sync-cloud-backups'),
|
||
restoreCloudModal: document.getElementById('restore-cloud-modal'),
|
||
cloudRestoreProviderFilter: document.getElementById('cloud-restore-provider-filter'),
|
||
btnRefreshCloudModal: document.getElementById('btn-refresh-cloud-modal'),
|
||
cloudRestoreModalStatus: document.getElementById('cloud-restore-modal-status'),
|
||
cloudRestoreFilesContainer: document.getElementById('cloud-restore-files-container'),
|
||
restoreScopeModal: document.getElementById('restore-scope-modal'),
|
||
restoreScopeBackupSummary: document.getElementById('restore-scope-backup-summary'),
|
||
restoreScopeCardAll: document.getElementById('restore-scope-card-all'),
|
||
restoreScopeAll: document.getElementById('restore-scope-all'),
|
||
restoreScopeAllDesc: document.getElementById('restore-scope-all-desc'),
|
||
restoreScopeCardSingle: document.getElementById('restore-scope-card-single'),
|
||
restoreScopeSingle: document.getElementById('restore-scope-single'),
|
||
restoreTargetUserSelect: document.getElementById('restore-target-user-select'),
|
||
restoreScopeStatus: document.getElementById('restore-scope-status'),
|
||
btnConfirmScopedRestore: document.getElementById('btn-confirm-scoped-restore'),
|
||
adminBackupsHistoryList: document.getElementById('admin-backups-history-list'),
|
||
|
||
// Admin Create User Modal
|
||
adminCreateUserModal: document.getElementById('admin-create-user-modal'),
|
||
adminCreateUserForm: document.getElementById('admin-create-user-form'),
|
||
adminNewUsername: document.getElementById('admin-new-username'),
|
||
adminNewPassword: document.getElementById('admin-new-password'),
|
||
adminCreateError: document.getElementById('admin-create-error'),
|
||
adminCreateErrorText: document.getElementById('admin-create-error-text'),
|
||
|
||
// Admin Delete User Modal
|
||
adminDeleteUserModal: document.getElementById('admin-delete-user-modal'),
|
||
deleteUserTargetId: document.getElementById('delete-user-target-id'),
|
||
deleteUserTargetName: document.getElementById('delete-user-target-name'),
|
||
deleteUserPreserveRecipes: document.getElementById('delete-user-preserve-recipes'),
|
||
btnAdminConfirmDelete: document.getElementById('btn-admin-confirm-delete'),
|
||
|
||
// Scrape / Edit Modal
|
||
saveModal: document.getElementById('save-modal'),
|
||
recipeEditForm: document.getElementById('recipe-edit-form'),
|
||
editRecipeId: document.getElementById('edit-recipe-id'),
|
||
editTitle: document.getElementById('edit-title'),
|
||
editSource: document.getElementById('edit-source'),
|
||
editDescription: document.getElementById('edit-description'),
|
||
editServings: document.getElementById('edit-servings'),
|
||
editPrepTime: document.getElementById('edit-prep-time'),
|
||
editCookTime: document.getElementById('edit-cook-time'),
|
||
imageSelectionGrid: document.getElementById('image-selection-grid'),
|
||
editImageUrl: document.getElementById('edit-image-url'),
|
||
editIngredients: document.getElementById('edit-ingredients'),
|
||
editInstructions: document.getElementById('edit-instructions'),
|
||
modalActionTitle: document.getElementById('modal-action-title'),
|
||
btnSaveConfirm: document.getElementById('btn-save-confirm'),
|
||
editIsPublic: document.getElementById('edit-is-public'),
|
||
aiPolishBanner: document.getElementById('ai-polish-banner'),
|
||
btnAiPolish: document.getElementById('btn-ai-polish'),
|
||
aiPolishTitle: document.getElementById('ai-polish-title'),
|
||
aiPolishSubtitle: document.getElementById('ai-polish-subtitle'),
|
||
|
||
// Detail Modal
|
||
detailModal: document.getElementById('detail-modal'),
|
||
recipeDetailBanner: document.getElementById('recipe-detail-banner'),
|
||
btnDetailEdit: document.getElementById('btn-detail-edit'),
|
||
btnDetailDelete: document.getElementById('btn-detail-delete'),
|
||
btnDetailClone: document.getElementById('btn-detail-clone'),
|
||
btnDetailShare: document.getElementById('btn-detail-share'),
|
||
btnDetailRevokeShare: document.getElementById('btn-detail-revoke-share'),
|
||
tagShareStats: document.getElementById('tag-share-stats'),
|
||
recipeDetailCreator: document.getElementById('recipe-detail-creator'),
|
||
recipeDetailCreatorName: document.getElementById('recipe-detail-creator-name'),
|
||
recipeDetailTitle: document.getElementById('recipe-detail-title'),
|
||
tagServings: document.getElementById('tag-servings'),
|
||
tagPrep: document.getElementById('tag-prep'),
|
||
tagCook: document.getElementById('tag-cook'),
|
||
tagSource: document.getElementById('tag-source'),
|
||
recipeDetailDescription: document.getElementById('recipe-detail-description'),
|
||
recipeDetailIngredients: document.getElementById('recipe-detail-ingredients'),
|
||
recipeDetailInstructions: document.getElementById('recipe-detail-instructions'),
|
||
scalerMinus: document.getElementById('scaler-minus'),
|
||
scalerPlus: document.getElementById('scaler-plus'),
|
||
currentServingsVal: document.getElementById('current-servings-val'),
|
||
btnTriggerCookmode: document.getElementById('btn-trigger-cookmode'),
|
||
|
||
// Cooking Mode Modal
|
||
cookingModeModal: document.getElementById('cooking-mode-modal'),
|
||
cookRecipeTitle: document.getElementById('cook-recipe-title'),
|
||
cookTimerVal: document.getElementById('cook-timer-val'),
|
||
cookTimerPlay: document.getElementById('cook-timer-play'),
|
||
cookTimerReset: document.getElementById('cook-timer-reset'),
|
||
cookProgressBar: document.getElementById('cook-progress-bar'),
|
||
cookStepIndex: document.getElementById('cook-step-index'),
|
||
cookStepInstruction: document.getElementById('cook-step-instruction'),
|
||
cookBtnPrev: document.getElementById('cook-btn-prev'),
|
||
cookBtnNext: document.getElementById('cook-btn-next'),
|
||
cookStepDots: document.getElementById('cook-step-dots'),
|
||
|
||
// Loader
|
||
loadingOverlay: document.getElementById('loading-overlay'),
|
||
loaderStepFetch: document.getElementById('loader-step-fetch'),
|
||
loaderStepParse: document.getElementById('loader-step-parse'),
|
||
loaderStepImages: document.getElementById('loader-step-images'),
|
||
|
||
// Mobile Components
|
||
fabAddRecipe: document.getElementById('mobile-header-btn-add'),
|
||
mobileTabCookbook: document.getElementById('mobile-header-tab-cookbook'),
|
||
mobileTabCommunity: document.getElementById('mobile-header-tab-community'),
|
||
mobileTabShopping: document.getElementById('mobile-header-tab-shopping'),
|
||
mobileShoppingTabBadge: document.getElementById('mobile-shopping-tab-badge'),
|
||
mobileTabProfile: document.getElementById('mobile-header-btn-profile'),
|
||
mobileProfileModal: document.getElementById('mobile-profile-modal'),
|
||
mobileProfileUsername: document.getElementById('mobile-profile-username'),
|
||
mobileProfileRole: document.getElementById('mobile-profile-role'),
|
||
mobileBtnThemeToggle: document.getElementById('mobile-btn-theme-toggle'),
|
||
mobileBtnSettings: document.getElementById('mobile-btn-settings'),
|
||
mobileBtnAdmin: document.getElementById('mobile-btn-admin'),
|
||
mobileBtnLogout: document.getElementById('mobile-btn-logout'),
|
||
headerLogo: document.getElementById('header-logo'),
|
||
libraryViewTitle: document.getElementById('library-view-title'),
|
||
|
||
// Shopping Lists Components
|
||
tabShoppingLists: document.getElementById('tab-shopping-lists'),
|
||
shoppingTabBadge: document.getElementById('shopping-tab-badge'),
|
||
shoppingListsPanel: document.getElementById('shopping-lists-panel'),
|
||
shoppingListSelect: document.getElementById('shopping-list-select'),
|
||
btnShoppingNewList: document.getElementById('btn-shopping-new-list'),
|
||
btnShoppingRenameList: document.getElementById('btn-shopping-rename-list'),
|
||
btnShoppingDeleteList: document.getElementById('btn-shopping-delete-list'),
|
||
shoppingShareStatsBadge: document.getElementById('shopping-share-stats-badge'),
|
||
btnShoppingShare: document.getElementById('btn-shopping-share'),
|
||
btnShoppingRevokeShare: document.getElementById('btn-shopping-revoke-share'),
|
||
btnShoppingCopyText: document.getElementById('btn-shopping-copy-text'),
|
||
shoppingAddItemForm: document.getElementById('shopping-add-item-form'),
|
||
shoppingAddItemInput: document.getElementById('shopping-add-item-input'),
|
||
toggleConsolidateItems: document.getElementById('toggle-consolidate-items'),
|
||
shoppingGroupBySelect: document.getElementById('shopping-group-by'),
|
||
btnShoppingClearChecked: document.getElementById('btn-shopping-clear-checked'),
|
||
btnShoppingClearAll: document.getElementById('btn-shopping-clear-all'),
|
||
shoppingListsContent: document.getElementById('shopping-lists-content'),
|
||
shoppingEmptyState: document.getElementById('shopping-empty-state'),
|
||
detailShoppingListSelect: document.getElementById('detail-shopping-list-select'),
|
||
btnAddRecipeToList: document.getElementById('btn-add-recipe-to-list'),
|
||
settingsDefaultConsolidate: document.getElementById('settings-default-consolidate')
|
||
};
|
||
|
||
// -------------------------------------------------------------
|
||
// LIFE CYCLE AND REGISTRATION
|
||
// -------------------------------------------------------------
|
||
|
||
window.addEventListener('DOMContentLoaded', () => {
|
||
initTheme();
|
||
registerEvents();
|
||
fetchAppConfig();
|
||
initAuth();
|
||
});
|
||
|
||
function registerEvents() {
|
||
// Theme Toggle
|
||
elements.themeToggle.addEventListener('click', toggleTheme);
|
||
|
||
// URL Form Submission
|
||
elements.urlScrapeForm.addEventListener('submit', handleScrapeSubmit);
|
||
elements.btnSocialImport?.addEventListener('click', () => openSocialCaptionModal({}));
|
||
document.getElementById('social-caption-form')?.addEventListener('submit', handleSocialCaptionSubmit);
|
||
|
||
// Manual Add Form Opening
|
||
elements.btnManualAdd.addEventListener('click', openManualAdd);
|
||
|
||
// Settings Modal Opening
|
||
elements.btnSettings.addEventListener('click', openSettingsModal);
|
||
elements.settingsApiForm.addEventListener('submit', handleSaveSettings);
|
||
elements.btnToggleGeminiKey.addEventListener('click', toggleSettingsKeyVisibility);
|
||
if (elements.btnDiscoverModels) {
|
||
elements.btnDiscoverModels.addEventListener('click', () => handleDiscoverModels(true));
|
||
}
|
||
|
||
// Image Scanning Actions
|
||
elements.btnScanImage.addEventListener('click', () => elements.scanImageInput.click());
|
||
elements.scanImageInput.addEventListener('change', handleRecipeImageScan);
|
||
document.addEventListener('paste', handleClipboardPaste);
|
||
|
||
// Library Search
|
||
elements.librarySearch.addEventListener('input', filterRecipes);
|
||
|
||
// Recipe Form Save
|
||
elements.recipeEditForm.addEventListener('submit', handleRecipeSave);
|
||
elements.btnAiPolish?.addEventListener('click', handleAiPolish);
|
||
|
||
// Action Buttons Detail Modal
|
||
elements.btnDetailEdit.addEventListener('click', editActiveRecipe);
|
||
elements.btnDetailDelete.addEventListener('click', deleteActiveRecipe);
|
||
elements.btnDetailClone.addEventListener('click', cloneActiveRecipe);
|
||
elements.btnDetailShare.addEventListener('click', shareActiveRecipe);
|
||
elements.btnDetailRevokeShare.addEventListener('click', revokeActiveRecipeShare);
|
||
|
||
// Auth Form Submissions
|
||
elements.loginForm.addEventListener('submit', handleLogin);
|
||
elements.btnLogout.addEventListener('click', logout);
|
||
|
||
// Dashboard Tabs Switchers
|
||
elements.tabMyCookbook.addEventListener('click', () => switchTab('my-cookbook'));
|
||
elements.tabCommunityVault.addEventListener('click', () => switchTab('community-vault'));
|
||
elements.tabShoppingLists.addEventListener('click', () => switchTab('shopping-lists'));
|
||
elements.tabUserAdmin.addEventListener('click', () => switchTab('user-admin'));
|
||
|
||
// Shopping Lists Events
|
||
elements.shoppingListSelect.addEventListener('change', (e) => {
|
||
activeShoppingListId = e.target.value;
|
||
renderShoppingListsWorkspace();
|
||
});
|
||
elements.btnShoppingNewList.addEventListener('click', handleCreateShoppingList);
|
||
elements.btnShoppingRenameList.addEventListener('click', handleRenameShoppingList);
|
||
elements.btnShoppingDeleteList.addEventListener('click', handleDeleteShoppingList);
|
||
elements.btnShoppingShare.addEventListener('click', handleShareShoppingList);
|
||
elements.btnShoppingRevokeShare.addEventListener('click', handleRevokeShoppingListShare);
|
||
elements.btnShoppingCopyText.addEventListener('click', handleCopyShoppingListText);
|
||
elements.shoppingAddItemForm.addEventListener('submit', handleAddManualShoppingItem);
|
||
elements.toggleConsolidateItems.addEventListener('change', (e) => {
|
||
consolidateItems = e.target.checked;
|
||
renderShoppingListsWorkspace();
|
||
});
|
||
elements.shoppingGroupBySelect.addEventListener('change', (e) => {
|
||
shoppingGroupBy = e.target.value;
|
||
renderShoppingListsWorkspace();
|
||
});
|
||
elements.btnShoppingClearChecked.addEventListener('click', () => handleClearShoppingList(true));
|
||
elements.btnShoppingClearAll.addEventListener('click', () => handleClearShoppingList(false));
|
||
elements.detailShoppingListSelect.addEventListener('change', handleDetailListSelectChange);
|
||
elements.btnAddRecipeToList.addEventListener('click', handleAddRecipeToShoppingList);
|
||
elements.settingsDefaultConsolidate.addEventListener('change', handleDefaultConsolidatePreferenceChange);
|
||
|
||
// Admin Action Buttons & Forms
|
||
elements.btnAdminAddUser.addEventListener('click', () => openModal('admin-create-user-modal'));
|
||
elements.adminCreateUserForm.addEventListener('submit', handleAdminCreateUser);
|
||
elements.btnAdminConfirmDelete.addEventListener('click', handleAdminDeleteUser);
|
||
|
||
// Admin Sub-Tab Switchers
|
||
elements.btnAdminTabUsers.addEventListener('click', () => switchAdminSubTab('users'));
|
||
elements.btnAdminTabShares.addEventListener('click', () => switchAdminSubTab('shares'));
|
||
if (elements.btnAdminTabBackups) {
|
||
elements.btnAdminTabBackups.addEventListener('click', () => switchAdminSubTab('backups'));
|
||
}
|
||
|
||
// Admin Backups Console Actions
|
||
if (elements.adminBackupConfigForm) {
|
||
elements.adminBackupConfigForm.addEventListener('submit', handleSaveBackupConfig);
|
||
elements.btnBackupRunCloud.addEventListener('click', handleRunCloudBackupNow);
|
||
elements.btnBackupDownloadDb.addEventListener('click', () => handleDownloadLocalBackup('db'));
|
||
elements.btnBackupDownloadJson.addEventListener('click', () => handleDownloadLocalBackup('json'));
|
||
elements.btnBackupRestoreLocal.addEventListener('click', () => elements.backupRestoreFileInput.click());
|
||
elements.backupRestoreFileInput.addEventListener('change', handleRestoreLocalFile);
|
||
elements.btnBackupTestS3.addEventListener('click', handleTestS3Connection);
|
||
elements.btnBackupTestGdrive.addEventListener('click', handleTestGdriveConnection);
|
||
elements.btnBackupAuthorizeGdrive.addEventListener('click', handleAuthorizeGoogleDrive);
|
||
if (elements.btnBackupDisconnectGdrive) {
|
||
elements.btnBackupDisconnectGdrive.addEventListener('click', handleDisconnectGoogleDrive);
|
||
}
|
||
if (elements.backupGdriveFolderSelect) {
|
||
elements.backupGdriveFolderSelect.addEventListener('change', handleSelectGdriveFolder);
|
||
}
|
||
if (elements.btnGdriveNewFolder) {
|
||
elements.btnGdriveNewFolder.addEventListener('click', handleCreateGdriveFolder);
|
||
}
|
||
if (elements.btnGdriveRefreshFolders) {
|
||
elements.btnGdriveRefreshFolders.addEventListener('click', () => loadGoogleDriveFolders(true));
|
||
}
|
||
if (elements.btnBackupRestoreCloudModal) {
|
||
elements.btnBackupRestoreCloudModal.addEventListener('click', openCloudRestoreModal);
|
||
}
|
||
if (elements.btnSyncCloudBackups) {
|
||
elements.btnSyncCloudBackups.addEventListener('click', handleSyncCloudBackups);
|
||
}
|
||
if (elements.btnRefreshCloudModal) {
|
||
elements.btnRefreshCloudModal.addEventListener('click', () => loadLiveCloudBackupsForModal(true));
|
||
}
|
||
if (elements.cloudRestoreProviderFilter) {
|
||
elements.cloudRestoreProviderFilter.addEventListener('change', () => renderCloudRestoreModalList());
|
||
}
|
||
if (elements.restoreScopeAll) {
|
||
elements.restoreScopeAll.addEventListener('change', updateRestoreScopeModeUI);
|
||
}
|
||
if (elements.restoreScopeSingle) {
|
||
elements.restoreScopeSingle.addEventListener('change', updateRestoreScopeModeUI);
|
||
}
|
||
if (elements.btnConfirmScopedRestore) {
|
||
elements.btnConfirmScopedRestore.addEventListener('click', executeScopedRestore);
|
||
}
|
||
}
|
||
|
||
window.addEventListener('message', (event) => {
|
||
if (event.data && event.data.type === 'GDRIVE_OAUTH_SUCCESS') {
|
||
showGdriveInlineStatus('Google Drive connected! Select your backup destination folder below.', 'success');
|
||
showBackupStatus('Google Drive account connected!', 'success');
|
||
fetchAdminBackupConfig();
|
||
}
|
||
});
|
||
|
||
// Servings Scaler Buttons
|
||
elements.scalerMinus.addEventListener('click', () => adjustMultiplier(-0.5));
|
||
elements.scalerPlus.addEventListener('click', () => adjustMultiplier(0.5));
|
||
|
||
// Cooking Mode Trigger
|
||
elements.btnTriggerCookmode.addEventListener('click', enterCookingMode);
|
||
|
||
// Cooking Controls
|
||
elements.cookBtnPrev.addEventListener('click', prevCookingStep);
|
||
elements.cookBtnNext.addEventListener('click', nextCookingStep);
|
||
elements.cookTimerPlay.addEventListener('click', toggleTimer);
|
||
elements.cookTimerReset.addEventListener('click', resetTimer);
|
||
|
||
// Global Modals click to dismiss on overlay
|
||
document.querySelectorAll('.modal-overlay').forEach(overlay => {
|
||
overlay.addEventListener('click', (e) => {
|
||
if (e.target === overlay && !overlay.classList.contains('loading-overlay')) {
|
||
closeModal(overlay.id);
|
||
}
|
||
});
|
||
});
|
||
|
||
// Global key bindings for cooking mode
|
||
document.addEventListener('keydown', (e) => {
|
||
if (elements.cookingModeModal.classList.contains('active')) {
|
||
if (e.key === 'ArrowRight' || e.key === ' ') {
|
||
e.preventDefault();
|
||
nextCookingStep();
|
||
} else if (e.key === 'ArrowLeft') {
|
||
e.preventDefault();
|
||
prevCookingStep();
|
||
} else if (e.key === 'Escape') {
|
||
exitCookingMode();
|
||
}
|
||
}
|
||
});
|
||
|
||
// Mobile UI Events
|
||
elements.mobileTabCookbook.addEventListener('click', () => switchTab('my-cookbook'));
|
||
elements.mobileTabCommunity.addEventListener('click', () => switchTab('community-vault'));
|
||
if (elements.mobileTabShopping) {
|
||
elements.mobileTabShopping.addEventListener('click', () => switchTab('shopping-lists'));
|
||
}
|
||
elements.mobileTabProfile.addEventListener('click', openMobileProfile);
|
||
elements.headerLogo.addEventListener('click', () => switchTab('my-cookbook'));
|
||
elements.fabAddRecipe.addEventListener('click', handleFabClick);
|
||
elements.mobileBtnThemeToggle.addEventListener('click', toggleTheme);
|
||
elements.mobileBtnSettings.addEventListener('click', () => {
|
||
closeModal('mobile-profile-modal');
|
||
openSettingsModal();
|
||
});
|
||
elements.mobileBtnAdmin.addEventListener('click', () => {
|
||
closeModal('mobile-profile-modal');
|
||
switchTab('user-admin');
|
||
});
|
||
elements.mobileBtnLogout.addEventListener('click', () => {
|
||
closeModal('mobile-profile-modal');
|
||
logout();
|
||
});
|
||
}
|
||
|
||
// -------------------------------------------------------------
|
||
// UTILITIES: SYSTEM THEMES
|
||
// -------------------------------------------------------------
|
||
|
||
function initTheme() {
|
||
const savedTheme = localStorage.getItem('gourmet-theme') || 'dark';
|
||
if (savedTheme === 'dark') {
|
||
document.body.classList.add('dark-theme');
|
||
} else {
|
||
document.body.classList.remove('dark-theme');
|
||
}
|
||
}
|
||
|
||
function toggleTheme() {
|
||
document.body.classList.toggle('dark-theme');
|
||
const isDark = document.body.classList.contains('dark-theme');
|
||
localStorage.setItem('gourmet-theme', isDark ? 'dark' : 'light');
|
||
}
|
||
|
||
// -------------------------------------------------------------
|
||
// LOADING STATUS MACHINE
|
||
// -------------------------------------------------------------
|
||
|
||
function setLoaderStepIcon(stepElement, iconName, spin = false) {
|
||
const wrapper = stepElement.querySelector('.step-icon-wrapper');
|
||
if (wrapper) {
|
||
wrapper.innerHTML = `<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
|
||
? { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }
|
||
: { 'Content-Type': 'application/json' };
|
||
}
|
||
|
||
// -------------------------------------------------------------
|
||
// CRUD & API HANDLERS
|
||
// -------------------------------------------------------------
|
||
|
||
async function fetchRecipes() {
|
||
try {
|
||
let url = '/api/recipes';
|
||
if (activeTab === 'community-vault') {
|
||
url = '/api/recipes/public';
|
||
}
|
||
|
||
const response = await fetch(url, {
|
||
headers: getAuthHeaders()
|
||
});
|
||
|
||
if (response.status === 401) {
|
||
logout();
|
||
return;
|
||
}
|
||
|
||
if (!response.ok) {
|
||
throw new Error('Failed to load recipes');
|
||
}
|
||
|
||
allRecipes = await response.json();
|
||
renderLibrary();
|
||
} catch (error) {
|
||
console.error('Error fetching recipes library:', error);
|
||
}
|
||
}
|
||
|
||
// Scrape link form submission
|
||
async function handleScrapeSubmit(e) {
|
||
e.preventDefault();
|
||
const url = elements.scrapeUrlInput.value.trim();
|
||
if (!url) return;
|
||
|
||
showScrapeLoading();
|
||
|
||
try {
|
||
const response = await fetch('/api/scrape', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
...getAuthHeaders()
|
||
},
|
||
body: JSON.stringify({ url })
|
||
});
|
||
|
||
if (response.status === 401) {
|
||
logout();
|
||
hideScrapeLoading();
|
||
return;
|
||
}
|
||
|
||
if (!response.ok) {
|
||
const errorData = await response.json().catch(() => ({}));
|
||
|
||
// Check if this is an Instagram or social link requiring caption paste
|
||
if (errorData.requiresCaption) {
|
||
hideScrapeLoading();
|
||
openSocialCaptionModal({
|
||
url: errorData.sourceUrl || url,
|
||
isInstagram: errorData.isInstagram,
|
||
isTikTok: errorData.isTikTok,
|
||
author: errorData.author || '',
|
||
coverImage: errorData.coverImage || '',
|
||
errorMessage: errorData.error
|
||
});
|
||
return;
|
||
}
|
||
|
||
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}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Open Social Caption Extractor Modal (Instagram / TikTok)
|
||
function openSocialCaptionModal(options = {}) {
|
||
const { url = '', isInstagram = false, isTikTok = false, author = '', coverImage = '' } = options;
|
||
|
||
const titleEl = document.getElementById('social-modal-title');
|
||
const iconEl = document.getElementById('social-modal-icon');
|
||
const hintEl = document.getElementById('social-modal-hint');
|
||
const urlInput = document.getElementById('social-input-url');
|
||
const authorInput = document.getElementById('social-input-author');
|
||
const coverInput = document.getElementById('social-input-cover');
|
||
const captionText = document.getElementById('social-caption-text');
|
||
const previewBox = document.getElementById('social-post-preview');
|
||
const previewCover = document.getElementById('social-post-cover');
|
||
const previewAuthor = document.getElementById('social-post-author');
|
||
const previewUrl = document.getElementById('social-post-url-display');
|
||
|
||
const isIg = isInstagram || url.toLowerCase().includes('instagram.com') || url.toLowerCase().includes('instagr.am');
|
||
const isTt = isTikTok || url.toLowerCase().includes('tiktok.com');
|
||
|
||
if (isIg) {
|
||
if (titleEl) titleEl.textContent = 'Instagram Recipe Import';
|
||
if (iconEl) {
|
||
iconEl.setAttribute('data-lucide', 'instagram');
|
||
iconEl.style.color = '#e1306c';
|
||
}
|
||
if (hintEl) {
|
||
hintEl.textContent = 'Instagram restricts automated scraping of post captions. Simply copy the caption text from the Reel or post and paste it below—ForkFolio will parse the recipe and format the ingredients automatically!';
|
||
}
|
||
} else if (isTt) {
|
||
if (titleEl) titleEl.textContent = 'TikTok Recipe Import';
|
||
if (iconEl) {
|
||
iconEl.setAttribute('data-lucide', 'video');
|
||
iconEl.style.color = 'var(--primary)';
|
||
}
|
||
if (hintEl) {
|
||
hintEl.textContent = 'TikTok video caption could not be accessed directly. Paste the video description or recipe text below to extract the recipe automatically!';
|
||
}
|
||
} else {
|
||
if (titleEl) titleEl.textContent = 'Social Media Recipe Import';
|
||
if (iconEl) {
|
||
iconEl.setAttribute('data-lucide', 'sparkles');
|
||
iconEl.style.color = 'var(--primary)';
|
||
}
|
||
if (hintEl) {
|
||
hintEl.textContent = 'Paste any recipe caption or culinary text from Instagram, TikTok, or social posts below to extract and format it with AI!';
|
||
}
|
||
}
|
||
|
||
if (urlInput) urlInput.value = url || '';
|
||
if (authorInput) authorInput.value = author || '';
|
||
if (coverInput) coverInput.value = coverImage || '';
|
||
if (captionText) captionText.value = '';
|
||
|
||
if (previewBox) {
|
||
if (url || author || coverImage) {
|
||
previewBox.style.display = 'flex';
|
||
if (previewCover) {
|
||
if (coverImage) {
|
||
previewCover.src = coverImage;
|
||
previewCover.style.display = 'block';
|
||
} else {
|
||
previewCover.style.display = 'none';
|
||
}
|
||
}
|
||
if (previewAuthor) {
|
||
previewAuthor.textContent = author ? `By ${author}` : (isIg ? 'Instagram Post' : 'Social Post');
|
||
}
|
||
if (previewUrl) {
|
||
previewUrl.textContent = url || '';
|
||
}
|
||
} else {
|
||
previewBox.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
openModal('social-caption-modal');
|
||
lucide.createIcons();
|
||
if (captionText) {
|
||
setTimeout(() => captionText.focus(), 150);
|
||
}
|
||
}
|
||
|
||
// Handle Social Caption Form Submission
|
||
async function handleSocialCaptionSubmit(e) {
|
||
e.preventDefault();
|
||
const captionText = document.getElementById('social-caption-text');
|
||
const rawCaption = captionText?.value?.trim();
|
||
const url = document.getElementById('social-input-url')?.value?.trim() || '';
|
||
const author = document.getElementById('social-input-author')?.value?.trim() || '';
|
||
const coverImage = document.getElementById('social-input-cover')?.value?.trim() || '';
|
||
const submitBtn = document.getElementById('btn-social-extract');
|
||
|
||
if (!rawCaption) return;
|
||
|
||
const originalBtnHtml = submitBtn ? submitBtn.innerHTML : '';
|
||
if (submitBtn) {
|
||
submitBtn.disabled = true;
|
||
submitBtn.innerHTML = '<i data-lucide="loader-2" class="spin"></i> <span>Extracting Recipe...</span>';
|
||
lucide.createIcons();
|
||
}
|
||
|
||
try {
|
||
const response = await fetch('/api/scrape', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
...getAuthHeaders()
|
||
},
|
||
body: JSON.stringify({
|
||
url,
|
||
rawCaption,
|
||
author,
|
||
coverImage
|
||
})
|
||
});
|
||
|
||
if (response.status === 401) {
|
||
logout();
|
||
return;
|
||
}
|
||
|
||
const data = await response.json();
|
||
if (!response.ok) {
|
||
throw new Error(data.error || 'Failed to parse recipe from caption.');
|
||
}
|
||
|
||
closeModal('social-caption-modal');
|
||
if (captionText) captionText.value = '';
|
||
elements.scrapeUrlInput.value = '';
|
||
openDraftPreview(data);
|
||
} catch (err) {
|
||
alert(err.message || 'Error parsing caption.');
|
||
} finally {
|
||
if (submitBtn) {
|
||
submitBtn.disabled = false;
|
||
submitBtn.innerHTML = originalBtnHtml;
|
||
lucide.createIcons();
|
||
}
|
||
}
|
||
}
|
||
|
||
// User-initiated AI Polish action for draft recipe
|
||
async function handleAiPolish() {
|
||
if (!elements.btnAiPolish) return;
|
||
const originalBtnHtml = elements.btnAiPolish.innerHTML;
|
||
elements.btnAiPolish.disabled = true;
|
||
elements.btnAiPolish.innerHTML = '<i data-lucide="loader-2" class="spin"></i> <span>Polishing...</span>';
|
||
lucide.createIcons();
|
||
|
||
try {
|
||
const response = await fetch('/api/recipes/ai-polish', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
...getAuthHeaders()
|
||
},
|
||
body: JSON.stringify({
|
||
rawCaption: currentDraftRawCaption,
|
||
title: elements.editTitle.value.trim(),
|
||
ingredients: elements.editIngredients.value.trim().split(/\r?\n/).filter(Boolean),
|
||
instructions: elements.editInstructions.value.trim().split(/\r?\n/).filter(Boolean)
|
||
})
|
||
});
|
||
|
||
if (response.status === 401) {
|
||
logout();
|
||
return;
|
||
}
|
||
|
||
const result = await response.json();
|
||
if (!response.ok) {
|
||
throw new Error(result.error || 'Failed to polish recipe with AI.');
|
||
}
|
||
|
||
// Populate polished fields
|
||
if (result.title) elements.editTitle.value = result.title;
|
||
if (result.description) elements.editDescription.value = result.description;
|
||
if (result.servings) elements.editServings.value = result.servings;
|
||
if (result.prepTime) elements.editPrepTime.value = result.prepTime;
|
||
if (result.cookTime) elements.editCookTime.value = result.cookTime;
|
||
if (Array.isArray(result.ingredients) && result.ingredients.length > 0) {
|
||
elements.editIngredients.value = result.ingredients.join('\n');
|
||
}
|
||
if (Array.isArray(result.instructions) && result.instructions.length > 0) {
|
||
elements.editInstructions.value = result.instructions.join('\n');
|
||
}
|
||
|
||
// Update banner to success state
|
||
elements.aiPolishBanner.className = 'ai-polish-banner success';
|
||
elements.aiPolishTitle.textContent = 'Polished with Gemini AI';
|
||
elements.aiPolishSubtitle.textContent = 'Measurements and instruction steps have been organized.';
|
||
elements.btnAiPolish.style.display = 'none';
|
||
lucide.createIcons();
|
||
} catch (err) {
|
||
elements.btnAiPolish.disabled = false;
|
||
elements.btnAiPolish.innerHTML = originalBtnHtml;
|
||
lucide.createIcons();
|
||
alert(err.message);
|
||
}
|
||
}
|
||
|
||
// Save or Edit submit
|
||
async function handleRecipeSave(e) {
|
||
e.preventDefault();
|
||
|
||
const id = elements.editRecipeId.value;
|
||
const recipePayload = {
|
||
title: elements.editTitle.value.trim(),
|
||
sourceUrl: elements.editSource.value.trim(),
|
||
description: elements.editDescription.value.trim(),
|
||
servings: elements.editServings.value.trim(),
|
||
prepTime: elements.editPrepTime.value.trim(),
|
||
cookTime: elements.editCookTime.value.trim(),
|
||
isPublic: elements.editIsPublic.checked,
|
||
image: elements.editImageUrl.value.trim(),
|
||
images: Array.from(elements.imageSelectionGrid.querySelectorAll('.image-option img')).map(img => img.src),
|
||
ingredients: elements.editIngredients.value.trim().split(/\r?\n/).map(line => line.trim()).filter(Boolean),
|
||
instructions: elements.editInstructions.value.trim().split(/\r?\n/).map(line => line.trim()).filter(Boolean)
|
||
};
|
||
|
||
try {
|
||
let response;
|
||
if (id) {
|
||
// Edit
|
||
response = await fetch(`/api/recipes/${id}`, {
|
||
method: 'PUT',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
...getAuthHeaders()
|
||
},
|
||
body: JSON.stringify(recipePayload)
|
||
});
|
||
} else {
|
||
// Create
|
||
response = await fetch('/api/recipes', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
...getAuthHeaders()
|
||
},
|
||
body: JSON.stringify(recipePayload)
|
||
});
|
||
}
|
||
|
||
if (response.status === 401) {
|
||
logout();
|
||
return;
|
||
}
|
||
|
||
if (!response.ok) throw new Error('Saving failed.');
|
||
|
||
const saved = await response.json();
|
||
closeModal('save-modal');
|
||
fetchRecipes();
|
||
|
||
// If active view was open, update it
|
||
if (activeRecipe && activeRecipe.id === saved.id) {
|
||
showRecipeDetails(saved);
|
||
}
|
||
} catch (error) {
|
||
console.error('Error saving recipe:', error);
|
||
alert('Error saving recipe.');
|
||
}
|
||
}
|
||
|
||
// Delete Active Recipe
|
||
async function deleteActiveRecipe() {
|
||
if (!activeRecipe) return;
|
||
if (!confirm(`Are you sure you want to delete "${activeRecipe.title}"?`)) return;
|
||
|
||
try {
|
||
const response = await fetch(`/api/recipes/${activeRecipe.id}`, {
|
||
method: 'DELETE',
|
||
headers: getAuthHeaders()
|
||
});
|
||
|
||
if (response.status === 401) {
|
||
logout();
|
||
return;
|
||
}
|
||
|
||
if (!response.ok) throw new Error('Deletion failed.');
|
||
|
||
closeModal('detail-modal');
|
||
activeRecipe = null;
|
||
fetchRecipes();
|
||
} catch (error) {
|
||
console.error('Error deleting recipe:', error);
|
||
alert('Deletion failed.');
|
||
}
|
||
}
|
||
|
||
// Clone a Shared Cookbook recipe to current user's collection
|
||
async function cloneActiveRecipe() {
|
||
if (!activeRecipe) return;
|
||
|
||
try {
|
||
const response = await fetch(`/api/recipes/clone/${activeRecipe.id}`, {
|
||
method: 'POST',
|
||
headers: getAuthHeaders()
|
||
});
|
||
|
||
if (response.status === 401) {
|
||
logout();
|
||
return;
|
||
}
|
||
|
||
if (!response.ok) {
|
||
const err = await response.json().catch(() => ({}));
|
||
throw new Error(err.error || 'Cloning failed.');
|
||
}
|
||
|
||
const cloned = await response.json();
|
||
alert(`Successfully cloned "${activeRecipe.title}" into your cookbook!`);
|
||
closeModal('detail-modal');
|
||
|
||
// Switch back to "My Cookbook" tab to let them see it immediately
|
||
switchTab('my-cookbook');
|
||
} catch (error) {
|
||
console.error('Error cloning recipe:', error);
|
||
alert(`Error cloning recipe: ${error.message}`);
|
||
}
|
||
}
|
||
|
||
async function copyTextToClipboard(text) {
|
||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||
await navigator.clipboard.writeText(text);
|
||
return;
|
||
}
|
||
const textArea = document.createElement('textarea');
|
||
textArea.value = text;
|
||
textArea.style.position = 'fixed';
|
||
textArea.style.opacity = '0';
|
||
document.body.appendChild(textArea);
|
||
textArea.focus();
|
||
textArea.select();
|
||
document.execCommand('copy');
|
||
document.body.removeChild(textArea);
|
||
}
|
||
|
||
let shareBtnResetTimeout = null;
|
||
|
||
async function shareActiveRecipe() {
|
||
if (!activeRecipe) return;
|
||
|
||
if (activeRecipe.isPublic === false) {
|
||
if (confirm(`"${activeRecipe.title}" is currently private.\n\nTo share it with people outside ForkFolio, you must make it public first. Would you like to make this recipe public now?`)) {
|
||
try {
|
||
const response = await fetch(`/api/recipes/${activeRecipe.id}`, {
|
||
method: 'PUT',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
...getAuthHeaders()
|
||
},
|
||
body: JSON.stringify({ isPublic: true })
|
||
});
|
||
|
||
if (response.status === 401) {
|
||
logout();
|
||
return;
|
||
}
|
||
|
||
if (!response.ok) throw new Error('Failed to update privacy.');
|
||
|
||
const updated = await response.json();
|
||
activeRecipe = updated;
|
||
fetchRecipes();
|
||
|
||
// Re-render detail badge visual state
|
||
showRecipeDetails(updated);
|
||
} catch (err) {
|
||
console.error('Error toggling privacy for share link:', err);
|
||
alert('Failed to make the recipe public.');
|
||
return;
|
||
}
|
||
} else {
|
||
return;
|
||
}
|
||
}
|
||
|
||
// If public and has a share token
|
||
if (activeRecipe.shareToken) {
|
||
const shareLink = `${getBaseUrl()}/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() {
|
||
currentDraftRawCaption = '';
|
||
if (elements.aiPolishBanner) elements.aiPolishBanner.style.display = 'none';
|
||
|
||
elements.modalActionTitle.textContent = 'Create New Recipe';
|
||
elements.editRecipeId.value = '';
|
||
elements.recipeEditForm.reset();
|
||
elements.imageSelectionGrid.innerHTML = '';
|
||
selectedCoverImage = '';
|
||
elements.editIsPublic.checked = true;
|
||
|
||
openModal('save-modal');
|
||
}
|
||
|
||
function openDraftPreview(data) {
|
||
elements.modalActionTitle.textContent = 'Save Extracted Recipe';
|
||
elements.editRecipeId.value = '';
|
||
|
||
elements.editTitle.value = data.title || '';
|
||
elements.editSource.value = data.sourceUrl || '';
|
||
elements.editDescription.value = data.description || '';
|
||
elements.editServings.value = data.servings || '';
|
||
elements.editPrepTime.value = data.prepTime || '';
|
||
elements.editCookTime.value = data.cookTime || '';
|
||
elements.editIsPublic.checked = true;
|
||
|
||
// Ingredients text area
|
||
elements.editIngredients.value = data.ingredients ? data.ingredients.join('\n') : '';
|
||
|
||
// Instructions text area
|
||
elements.editInstructions.value = data.instructions ? data.instructions.join('\n') : '';
|
||
|
||
// Render Image Selector
|
||
renderImageSelector(data.images || []);
|
||
|
||
// Handle AI Polish Suggestion Banner
|
||
if (data.suggestAiPolish && data.rawCaption) {
|
||
currentDraftRawCaption = data.rawCaption;
|
||
if (elements.aiPolishBanner) {
|
||
elements.aiPolishBanner.style.display = 'flex';
|
||
elements.aiPolishBanner.className = 'ai-polish-banner';
|
||
elements.aiPolishTitle.textContent = 'Extracted with local engine';
|
||
elements.aiPolishSubtitle.textContent = 'Want AI to polish measurements, separate run-on steps, and format times?';
|
||
elements.btnAiPolish.style.display = 'inline-flex';
|
||
elements.btnAiPolish.disabled = false;
|
||
elements.btnAiPolish.innerHTML = '<i data-lucide="wand-2"></i> <span>Polish with AI</span>';
|
||
lucide.createIcons();
|
||
}
|
||
} else {
|
||
currentDraftRawCaption = '';
|
||
if (elements.aiPolishBanner) elements.aiPolishBanner.style.display = 'none';
|
||
}
|
||
|
||
openModal('save-modal');
|
||
}
|
||
|
||
function editActiveRecipe() {
|
||
if (!activeRecipe) return;
|
||
|
||
currentDraftRawCaption = '';
|
||
if (elements.aiPolishBanner) elements.aiPolishBanner.style.display = 'none';
|
||
|
||
elements.modalActionTitle.textContent = 'Edit Recipe Details';
|
||
elements.editRecipeId.value = activeRecipe.id;
|
||
|
||
elements.editTitle.value = activeRecipe.title || '';
|
||
elements.editSource.value = activeRecipe.sourceUrl || '';
|
||
elements.editDescription.value = activeRecipe.description || '';
|
||
elements.editServings.value = activeRecipe.servings || '';
|
||
elements.editPrepTime.value = activeRecipe.prepTime || '';
|
||
elements.editCookTime.value = activeRecipe.cookTime || '';
|
||
elements.editIsPublic.checked = activeRecipe.isPublic !== false;
|
||
|
||
elements.editIngredients.value = activeRecipe.ingredients ? activeRecipe.ingredients.join('\n') : '';
|
||
elements.editInstructions.value = activeRecipe.instructions ? activeRecipe.instructions.join('\n') : '';
|
||
|
||
renderImageSelector(activeRecipe.images || []);
|
||
|
||
// Set active selection
|
||
if (activeRecipe.image) {
|
||
elements.editImageUrl.value = activeRecipe.image;
|
||
selectedCoverImage = activeRecipe.image;
|
||
selectImageThumbnail(activeRecipe.image);
|
||
}
|
||
|
||
openModal('save-modal');
|
||
}
|
||
|
||
// -------------------------------------------------------------
|
||
// IMAGE EXTRACTION CAROUSEL & SELECTOR
|
||
// -------------------------------------------------------------
|
||
|
||
function renderImageSelector(images) {
|
||
elements.imageSelectionGrid.innerHTML = '';
|
||
selectedCoverImage = '';
|
||
|
||
if (!images || images.length === 0) {
|
||
elements.editImageUrl.value = '';
|
||
return;
|
||
}
|
||
|
||
// Pre-select first image
|
||
selectedCoverImage = images[0];
|
||
elements.editImageUrl.value = images[0];
|
||
|
||
images.forEach((imgSrc, index) => {
|
||
const option = document.createElement('div');
|
||
option.className = `image-option ${index === 0 ? 'selected' : ''}`;
|
||
option.innerHTML = `<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 = `${getBaseUrl()}/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
|
||
// -------------------------------------------------------------
|
||
|
||
function populateModelDropdown(models, selectedModel) {
|
||
if (!elements.settingsGeminiModelSelect) return;
|
||
const select = elements.settingsGeminiModelSelect;
|
||
select.innerHTML = '';
|
||
|
||
const targetModel = selectedModel || 'gemini-2.5-flash';
|
||
let foundTarget = false;
|
||
|
||
models.forEach(m => {
|
||
const opt = document.createElement('option');
|
||
opt.value = m.id;
|
||
opt.textContent = m.displayName || m.id;
|
||
if (m.id === 'gemini-2.5-flash') {
|
||
opt.textContent = `${m.displayName || 'Gemini 2.5 Flash'} (Stable Recommended)`;
|
||
} else if (m.id === 'gemini-3.8-flash') {
|
||
opt.textContent = `${m.displayName || 'Gemini 3.8 Flash'} (Newest Flash)`;
|
||
}
|
||
if (m.id === targetModel) {
|
||
opt.selected = true;
|
||
foundTarget = true;
|
||
}
|
||
select.appendChild(opt);
|
||
});
|
||
|
||
if (!foundTarget && targetModel) {
|
||
const customOpt = document.createElement('option');
|
||
customOpt.value = targetModel;
|
||
customOpt.textContent = targetModel;
|
||
customOpt.selected = true;
|
||
select.appendChild(customOpt);
|
||
}
|
||
}
|
||
|
||
async function fetchModelsDirectFromGoogle(key) {
|
||
if (!key || key === 'PASTE_YOUR_GEMINI_API_KEY_HERE') {
|
||
throw new Error('Please enter a valid Gemini API key first.');
|
||
}
|
||
const googleRes = await fetch(`https://generativelanguage.googleapis.com/v1beta/models?key=${key}`);
|
||
const googleData = await googleRes.json().catch(() => ({}));
|
||
if (!googleRes.ok) {
|
||
throw new Error(googleData.error?.message || `Google API returned status ${googleRes.status}`);
|
||
}
|
||
if (!Array.isArray(googleData.models)) {
|
||
throw new Error('Unexpected response format from Google API.');
|
||
}
|
||
const excludedPatterns = /embedding|aqa|imagen|tts|lyria|banana|-image|robotics|computer-use|deep-research|antigravity|customtools/i;
|
||
const models = googleData.models
|
||
.filter(m => Array.isArray(m.supportedGenerationMethods) && m.supportedGenerationMethods.includes('generateContent'))
|
||
.filter(m => !excludedPatterns.test(m.name) && !excludedPatterns.test(m.displayName || ''))
|
||
.map(m => {
|
||
const modelId = m.name.replace(/^models\//, '');
|
||
return {
|
||
id: modelId,
|
||
displayName: m.displayName || modelId,
|
||
description: m.description || ''
|
||
};
|
||
});
|
||
|
||
models.sort((a, b) => {
|
||
const aIsFlash = a.id.toLowerCase().includes('flash');
|
||
const bIsFlash = b.id.toLowerCase().includes('flash');
|
||
if (aIsFlash && !bIsFlash) return -1;
|
||
if (!aIsFlash && bIsFlash) return 1;
|
||
return b.id.localeCompare(a.id);
|
||
});
|
||
return models;
|
||
}
|
||
|
||
async function handleDiscoverModels(showNotice = true) {
|
||
const key = elements.settingsGeminiKeyInput ? elements.settingsGeminiKeyInput.value.trim() : '';
|
||
if (!key || key === 'PASTE_YOUR_GEMINI_API_KEY_HERE') {
|
||
if (showNotice) {
|
||
alert('Please enter your Google Gemini API key first.');
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (elements.modelsStatusHint) {
|
||
elements.modelsStatusHint.textContent = 'Discovering available models from Google API...';
|
||
elements.modelsStatusHint.style.color = 'var(--primary)';
|
||
}
|
||
if (elements.btnDiscoverModels) {
|
||
elements.btnDiscoverModels.disabled = true;
|
||
}
|
||
|
||
try {
|
||
let models = [];
|
||
|
||
// 1. Attempt backend endpoint first
|
||
try {
|
||
const res = await fetch('/api/settings/discover-models', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
...getAuthHeaders()
|
||
},
|
||
body: JSON.stringify({ apiKey: key })
|
||
});
|
||
|
||
if (res.ok) {
|
||
const text = await res.text();
|
||
try {
|
||
const data = JSON.parse(text);
|
||
if (Array.isArray(data.models)) {
|
||
models = data.models;
|
||
}
|
||
} catch (parseErr) {}
|
||
}
|
||
} catch (serverErr) {
|
||
// Backend may not have route if server hasn't restarted yet
|
||
}
|
||
|
||
// 2. Fallback to direct client-side call to Google API if backend returned HTML / 404
|
||
if (models.length === 0) {
|
||
models = await fetchModelsDirectFromGoogle(key);
|
||
}
|
||
|
||
if (models.length > 0) {
|
||
const currentSelected = elements.settingsGeminiModelSelect ? elements.settingsGeminiModelSelect.value : 'gemini-2.5-flash';
|
||
populateModelDropdown(models, currentSelected);
|
||
if (elements.modelsStatusHint) {
|
||
elements.modelsStatusHint.textContent = `Discovered ${models.length} compatible models.`;
|
||
elements.modelsStatusHint.style.color = 'var(--text-muted)';
|
||
}
|
||
} else {
|
||
if (elements.modelsStatusHint) {
|
||
elements.modelsStatusHint.textContent = 'No compatible models found for this key.';
|
||
elements.modelsStatusHint.style.color = '#ef4444';
|
||
}
|
||
}
|
||
} catch (err) {
|
||
console.error('Discover models error:', err);
|
||
if (elements.modelsStatusHint) {
|
||
elements.modelsStatusHint.textContent = `Discovery error: ${err.message}`;
|
||
elements.modelsStatusHint.style.color = '#ef4444';
|
||
}
|
||
if (showNotice) {
|
||
alert(`Model Discovery Failed: ${err.message}\n\nPlease check that your Gemini API Key is valid.`);
|
||
}
|
||
} finally {
|
||
if (elements.btnDiscoverModels) {
|
||
elements.btnDiscoverModels.disabled = false;
|
||
}
|
||
}
|
||
}
|
||
|
||
async function openSettingsModal() {
|
||
// Reset visibility states and statuses
|
||
elements.settingsStatusMsg.style.display = 'none';
|
||
elements.settingsGeminiKeyInput.type = 'password';
|
||
const toggleIcon = elements.btnToggleGeminiKey.querySelector('i, svg');
|
||
if (toggleIcon) toggleIcon.setAttribute('data-lucide', 'eye');
|
||
lucide.createIcons();
|
||
|
||
elements.settingsGeminiKeyInput.value = '';
|
||
elements.settingsGeminiKeyInput.placeholder = 'Loading credentials...';
|
||
if (elements.modelsStatusHint) {
|
||
elements.modelsStatusHint.textContent = 'Used for both recipe photo scanning and AI text polishing.';
|
||
elements.modelsStatusHint.style.color = 'var(--text-muted)';
|
||
}
|
||
|
||
openModal('settings-modal');
|
||
|
||
try {
|
||
const response = await fetch('/api/settings', {
|
||
headers: getAuthHeaders()
|
||
});
|
||
if (response.status === 401) {
|
||
logout();
|
||
closeModal('settings-modal');
|
||
return;
|
||
}
|
||
if (!response.ok) throw new Error('Settings could not be loaded.');
|
||
const settings = await response.json();
|
||
|
||
const key = settings.geminiApiKey ? settings.geminiApiKey.trim() : '';
|
||
const isPlaceholder = key === 'PASTE_YOUR_GEMINI_API_KEY_HERE';
|
||
|
||
elements.settingsGeminiKeyInput.placeholder = 'Enter your Gemini API key...';
|
||
if (key && !isPlaceholder) {
|
||
elements.settingsGeminiKeyInput.value = key;
|
||
}
|
||
|
||
if (elements.settingsGeminiModelSelect) {
|
||
const savedModel = (!settings.geminiModel || settings.geminiModel === 'gemini-2.0-flash') ? 'gemini-2.5-flash' : settings.geminiModel;
|
||
let exists = false;
|
||
for (let i = 0; i < elements.settingsGeminiModelSelect.options.length; i++) {
|
||
if (elements.settingsGeminiModelSelect.options[i].value === savedModel) {
|
||
exists = true;
|
||
break;
|
||
}
|
||
}
|
||
if (!exists && savedModel) {
|
||
const opt = document.createElement('option');
|
||
opt.value = savedModel;
|
||
opt.textContent = savedModel;
|
||
elements.settingsGeminiModelSelect.appendChild(opt);
|
||
}
|
||
elements.settingsGeminiModelSelect.value = savedModel;
|
||
}
|
||
|
||
if (elements.settingsDefaultConsolidate && typeof settings.defaultConsolidateLists === 'boolean') {
|
||
elements.settingsDefaultConsolidate.checked = settings.defaultConsolidateLists;
|
||
applyUserDefaultConsolidationPreference(settings.defaultConsolidateLists);
|
||
}
|
||
} catch (err) {
|
||
console.error('Error loading settings:', err);
|
||
elements.settingsGeminiKeyInput.placeholder = 'Failed to load settings';
|
||
}
|
||
}
|
||
|
||
async function handleSaveSettings(e) {
|
||
e.preventDefault();
|
||
elements.settingsStatusMsg.style.display = 'none';
|
||
|
||
const key = elements.settingsGeminiKeyInput.value.trim();
|
||
const model = elements.settingsGeminiModelSelect ? elements.settingsGeminiModelSelect.value : 'gemini-2.5-flash';
|
||
const defaultConsolidate = elements.settingsDefaultConsolidate ? elements.settingsDefaultConsolidate.checked : true;
|
||
|
||
try {
|
||
const response = await fetch('/api/settings', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
...getAuthHeaders()
|
||
},
|
||
body: JSON.stringify({
|
||
geminiApiKey: key,
|
||
geminiModel: model,
|
||
defaultConsolidateLists: defaultConsolidate
|
||
})
|
||
});
|
||
|
||
if (response.status === 401) {
|
||
logout();
|
||
closeModal('settings-modal');
|
||
return;
|
||
}
|
||
|
||
if (!response.ok) throw new Error('Failed to save settings file.');
|
||
|
||
applyUserDefaultConsolidationPreference(defaultConsolidate);
|
||
if (elements.settingsStatusText) {
|
||
elements.settingsStatusText.textContent = 'Settings saved successfully.';
|
||
}
|
||
|
||
// Show success status banner
|
||
elements.settingsStatusMsg.style.display = 'flex';
|
||
setTimeout(() => {
|
||
elements.settingsStatusMsg.style.display = 'none';
|
||
}, 4000);
|
||
} catch (err) {
|
||
console.error('Error saving settings:', err);
|
||
alert(`Failed to save settings: ${err.message}`);
|
||
}
|
||
}
|
||
|
||
function toggleSettingsKeyVisibility() {
|
||
const input = elements.settingsGeminiKeyInput;
|
||
const toggleIcon = elements.btnToggleGeminiKey.querySelector('i, svg');
|
||
|
||
if (input.type === 'password') {
|
||
input.type = 'text';
|
||
if (toggleIcon) toggleIcon.setAttribute('data-lucide', 'eye-off');
|
||
} else {
|
||
input.type = 'password';
|
||
if (toggleIcon) toggleIcon.setAttribute('data-lucide', 'eye');
|
||
}
|
||
lucide.createIcons();
|
||
}
|
||
|
||
function showScanLoading() {
|
||
elements.loadingOverlay.classList.add('active');
|
||
|
||
const title = elements.loadingOverlay.querySelector('h3');
|
||
const subtitle = elements.loadingOverlay.querySelector('.loader-subtitle');
|
||
if (title) title.textContent = 'Scanning Recipe Photo';
|
||
if (subtitle) subtitle.textContent = 'Loading parameters and initializing scanner...';
|
||
|
||
elements.loaderStepFetch.className = 'loader-step active';
|
||
elements.loaderStepParse.className = 'loader-step';
|
||
elements.loaderStepImages.className = 'loader-step';
|
||
|
||
elements.loaderStepFetch.querySelector('span:last-child').textContent = 'Loading system configuration...';
|
||
elements.loaderStepParse.querySelector('span:last-child').textContent = 'Compressing and optimizing image...';
|
||
elements.loaderStepImages.querySelector('span:last-child').textContent = 'Digitizing with Gemini Vision AI...';
|
||
|
||
setLoaderStepIcon(elements.loaderStepFetch, 'circle-dashed', true);
|
||
setLoaderStepIcon(elements.loaderStepParse, 'circle', false);
|
||
setLoaderStepIcon(elements.loaderStepImages, 'circle', false);
|
||
lucide.createIcons();
|
||
}
|
||
|
||
function hideScanLoading() {
|
||
elements.loadingOverlay.classList.remove('active');
|
||
|
||
// Restore original loading screen texts
|
||
const title = elements.loadingOverlay.querySelector('h3');
|
||
const subtitle = elements.loadingOverlay.querySelector('.loader-subtitle');
|
||
if (title) title.textContent = 'Extracting Recipe Data';
|
||
if (subtitle) subtitle.textContent = 'Retrieving site contents and parsing culinary structures...';
|
||
|
||
elements.loaderStepFetch.querySelector('span:last-child').textContent = 'Connecting to source URL...';
|
||
elements.loaderStepParse.querySelector('span:last-child').textContent = 'Parsing schema structures...';
|
||
elements.loaderStepImages.querySelector('span:last-child').textContent = 'Extracting high-res dish photos...';
|
||
}
|
||
|
||
function compressImage(file, maxDimension = 1600) {
|
||
return new Promise((resolve, reject) => {
|
||
const reader = new FileReader();
|
||
reader.readAsDataURL(file);
|
||
reader.onload = (event) => {
|
||
const img = new Image();
|
||
img.src = event.target.result;
|
||
img.onload = () => {
|
||
let width = img.width;
|
||
let height = img.height;
|
||
|
||
// Downscale maintaining aspect ratio
|
||
if (width > maxDimension || height > maxDimension) {
|
||
if (width > height) {
|
||
height = Math.round((height * maxDimension) / width);
|
||
width = maxDimension;
|
||
} else {
|
||
width = Math.round((width * maxDimension) / height);
|
||
height = maxDimension;
|
||
}
|
||
}
|
||
|
||
const canvas = document.createElement('canvas');
|
||
canvas.width = width;
|
||
canvas.height = height;
|
||
const ctx = canvas.getContext('2d');
|
||
ctx.drawImage(img, 0, 0, width, height);
|
||
|
||
const dataUrl = canvas.toDataURL('image/jpeg', 0.85);
|
||
const base64 = dataUrl.split(',')[1];
|
||
resolve({
|
||
dataUrl,
|
||
base64,
|
||
mimeType: 'image/jpeg'
|
||
});
|
||
};
|
||
img.onerror = (err) => reject(err);
|
||
};
|
||
reader.onerror = (err) => reject(err);
|
||
});
|
||
}
|
||
|
||
async function handleRecipeImageScan(e) {
|
||
const file = e.target.files[0];
|
||
if (!file) return;
|
||
scanRecipeFile(file);
|
||
}
|
||
|
||
async function handleClipboardPaste(e) {
|
||
// Only trigger scan if logged in to prevent unauthenticated access attempts
|
||
if (!currentUser) return;
|
||
|
||
const items = (e.clipboardData || e.originalEvent?.clipboardData)?.items;
|
||
if (!items) return;
|
||
|
||
for (const item of items) {
|
||
if (item.kind === 'file' && item.type.startsWith('image/')) {
|
||
const file = item.getAsFile();
|
||
e.preventDefault(); // Intercept paste action
|
||
scanRecipeFile(file);
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
async function scanRecipeFile(file) {
|
||
showScanLoading();
|
||
|
||
try {
|
||
// 1. Fetch system-wide configuration
|
||
const settingsResponse = await fetch('/api/settings', {
|
||
headers: getAuthHeaders()
|
||
});
|
||
if (settingsResponse.status === 401) {
|
||
logout();
|
||
hideScanLoading();
|
||
return;
|
||
}
|
||
if (!settingsResponse.ok) {
|
||
throw new Error('Could not fetch settings. Please ensure system settings are configured.');
|
||
}
|
||
const settings = await settingsResponse.json();
|
||
const apiKey = settings.geminiApiKey ? settings.geminiApiKey.trim() : '';
|
||
const rawModel = settings.geminiModel ? settings.geminiModel.trim() : '';
|
||
const geminiModel = (!rawModel || rawModel === 'gemini-2.0-flash') ? 'gemini-2.5-flash' : rawModel;
|
||
const isPlaceholder = apiKey === 'PASTE_YOUR_GEMINI_API_KEY_HERE' || apiKey === '';
|
||
|
||
if (!apiKey || isPlaceholder) {
|
||
hideScanLoading();
|
||
elements.scanImageInput.value = '';
|
||
alert('Google Gemini API Key is not configured.\n\nPlease click the Settings button at the top right, configure your Google Gemini API Key, and try again.');
|
||
return;
|
||
}
|
||
|
||
// Step 1 Completed -> Activate Step 2 (Compression)
|
||
elements.loaderStepFetch.className = 'loader-step completed';
|
||
setLoaderStepIcon(elements.loaderStepFetch, 'check-circle-2', false);
|
||
elements.loaderStepParse.className = 'loader-step active';
|
||
setLoaderStepIcon(elements.loaderStepParse, 'circle-dashed', true);
|
||
lucide.createIcons();
|
||
|
||
// 2. Compress image using Canvas
|
||
const compressed = await compressImage(file, 1600);
|
||
|
||
// Step 2 Completed -> Activate Step 3 (Gemini transcription)
|
||
elements.loaderStepParse.className = 'loader-step completed';
|
||
setLoaderStepIcon(elements.loaderStepParse, 'check-circle-2', false);
|
||
elements.loaderStepImages.className = 'loader-step active';
|
||
setLoaderStepIcon(elements.loaderStepImages, 'circle-dashed', true);
|
||
lucide.createIcons();
|
||
|
||
// 3. Make direct call to Google Gemini Vision API
|
||
const promptText = "You are a professional culinary transcriber. Analyze the image of a recipe (which could be a cookbook page, handwritten note, screenshot, or packaging) and extract it accurately. Fill in all fields as completely as possible. The response must be a JSON object matching this schema:\n{\n \"title\": \"Recipe Title\",\n \"description\": \"Brief description or review\",\n \"servings\": \"e.g., 4\",\n \"prepTime\": \"e.g., 15 mins\",\n \"cookTime\": \"e.g., 30 mins\",\n \"totalTime\": \"e.g., 45 mins\",\n \"ingredients\": [\"ingredient 1\", \"ingredient 2\"],\n \"instructions\": [\"step 1\", \"step 2\"]\n}\nDo not include any markdown styling (like ```json) or explanation. Return only the raw JSON string.";
|
||
|
||
const geminiUrl = `https://generativelanguage.googleapis.com/v1beta/models/${geminiModel}:generateContent?key=${apiKey}`;
|
||
|
||
const geminiResponse = await fetch(geminiUrl, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify({
|
||
contents: [
|
||
{
|
||
parts: [
|
||
{ text: promptText },
|
||
{
|
||
inlineData: {
|
||
mimeType: compressed.mimeType,
|
||
data: compressed.base64
|
||
}
|
||
}
|
||
]
|
||
}
|
||
],
|
||
generationConfig: {
|
||
responseMimeType: "application/json"
|
||
}
|
||
})
|
||
});
|
||
|
||
if (!geminiResponse.ok) {
|
||
const errorObj = await geminiResponse.json().catch(() => ({}));
|
||
const status = geminiResponse.status;
|
||
let errMsg = (errorObj.error && errorObj.error.message)
|
||
? errorObj.error.message
|
||
: 'Google Gemini API call failed.';
|
||
|
||
if (status === 400 && (!errorObj.error || !errorObj.error.message)) {
|
||
errMsg = `Invalid parameters or model version (${geminiModel}). Please check your settings.`;
|
||
} else if (status === 403 && (!errorObj.error || !errorObj.error.message)) {
|
||
errMsg = 'Access denied. The Google Gemini API key is invalid or inactive.';
|
||
} else if (status === 429 && (!errorObj.error || !errorObj.error.message)) {
|
||
errMsg = 'Rate limit exceeded. You have made too many requests or hit your Gemini quota.';
|
||
}
|
||
throw new Error(errMsg);
|
||
}
|
||
|
||
const result = await geminiResponse.json();
|
||
if (!result.candidates || result.candidates.length === 0 || !result.candidates[0].content) {
|
||
throw new Error('Gemini API returned an empty response. The text in the image may be unreadable.');
|
||
}
|
||
|
||
const rawText = result.candidates[0].content.parts[0].text;
|
||
const recipeData = JSON.parse(rawText);
|
||
|
||
// Auto-inject the scanned local picture as a custom visual preview option
|
||
recipeData.images = [compressed.dataUrl];
|
||
recipeData.image = compressed.dataUrl;
|
||
|
||
// 4. Finalize and trigger draft preview
|
||
hideScanLoading();
|
||
elements.scanImageInput.value = '';
|
||
openDraftPreview(recipeData);
|
||
|
||
} catch (error) {
|
||
console.error('Photo scanning error occurred:', error);
|
||
hideScanLoading();
|
||
elements.scanImageInput.value = '';
|
||
alert(`Scanning Failed: ${error.message}\n\nPlease verify your internet connection and check that your Gemini API Key is correct.`);
|
||
}
|
||
}
|
||
|
||
// -------------------------------------------------------------
|
||
// MOBILE PORTAL CONTROLS & GESTURES
|
||
// -------------------------------------------------------------
|
||
|
||
function openMobileProfile() {
|
||
if (!currentUser) return;
|
||
|
||
elements.mobileProfileUsername.textContent = `@${currentUser.username}`;
|
||
|
||
// Set role text
|
||
let roleText = 'Chef Account';
|
||
if (currentUser.isAdmin) {
|
||
roleText = 'Administrator';
|
||
} else if (currentUser.isRecipeManager) {
|
||
roleText = 'Recipe Manager';
|
||
}
|
||
elements.mobileProfileRole.textContent = roleText;
|
||
|
||
// Handle theme icon inside profile drawer
|
||
const isDark = document.body.classList.contains('dark-theme');
|
||
const sunIcon = elements.mobileBtnThemeToggle.querySelector('.sun-icon');
|
||
const moonIcon = elements.mobileBtnThemeToggle.querySelector('.moon-icon');
|
||
if (sunIcon && moonIcon) {
|
||
if (isDark) {
|
||
sunIcon.style.display = 'block';
|
||
moonIcon.style.display = 'none';
|
||
} else {
|
||
sunIcon.style.display = 'none';
|
||
moonIcon.style.display = 'block';
|
||
}
|
||
}
|
||
|
||
openModal('mobile-profile-modal');
|
||
}
|
||
|
||
function handleFabClick() {
|
||
// If not on cookbook tab, switch back to list first
|
||
if (activeTab !== 'my-cookbook') {
|
||
switchTab('my-cookbook');
|
||
}
|
||
// Open the manual recipe creator
|
||
openManualAdd();
|
||
}
|
||
|
||
// -------------------------------------------------------------
|
||
// SHOPPING LISTS MANAGEMENT & CONSOLIDATION ENGINE
|
||
// -------------------------------------------------------------
|
||
|
||
const AISLE_ORDER = [
|
||
'Produce',
|
||
'Meat & Seafood',
|
||
'Dairy & Eggs',
|
||
'Canned & Jarred',
|
||
'Bakery & Grains',
|
||
'Pantry & Spices',
|
||
'Frozen',
|
||
'Other'
|
||
];
|
||
|
||
const AISLE_ICONS = {
|
||
'Produce': 'apple',
|
||
'Meat & Seafood': 'beef',
|
||
'Dairy & Eggs': 'milk',
|
||
'Canned & Jarred': 'Archive',
|
||
'Bakery & Grains': 'wheat',
|
||
'Pantry & Spices': 'flame',
|
||
'Frozen': 'snowflake',
|
||
'Other': 'shopping-bag'
|
||
};
|
||
|
||
function applyUserDefaultConsolidationPreference(prefValue) {
|
||
const shouldConsolidate = prefValue !== false;
|
||
consolidateItems = shouldConsolidate;
|
||
if (elements.toggleConsolidateItems) {
|
||
elements.toggleConsolidateItems.checked = shouldConsolidate;
|
||
}
|
||
if (elements.settingsDefaultConsolidate) {
|
||
elements.settingsDefaultConsolidate.checked = shouldConsolidate;
|
||
}
|
||
if (currentUser) {
|
||
currentUser.defaultConsolidateLists = shouldConsolidate;
|
||
localStorage.setItem('gourmet_user', JSON.stringify(currentUser));
|
||
}
|
||
}
|
||
|
||
async function syncUserPreferencesFromServer() {
|
||
try {
|
||
const response = await fetch('/api/settings', { headers: getAuthHeaders() });
|
||
if (response.ok) {
|
||
const data = await response.json();
|
||
if (data && data.appUrl) {
|
||
systemAppUrl = data.appUrl.replace(/\/+$/, '');
|
||
}
|
||
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 = `${getBaseUrl()}/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 && !cfg.gdriveFolderId && Date.now() - lastGdriveFoldersFetchAt > 60 * 1000) {
|
||
loadGoogleDriveFolders(false, cfg.gdriveFolderId, cfg.gdriveFolderName);
|
||
}
|
||
}
|
||
|
||
async function loadGoogleDriveFolders(showFeedback = false, fallbackId = '', fallbackName = 'ForkFolio Backups') {
|
||
if (!elements.backupGdriveFolderSelect) return;
|
||
try {
|
||
lastGdriveFoldersFetchAt = Date.now();
|
||
if (showFeedback) {
|
||
showGdriveInlineStatus('Loading folders from your Google Drive...', 'info');
|
||
}
|
||
const res = await fetch('/api/admin/backups/gdrive/folders', {
|
||
headers: getAuthHeaders()
|
||
});
|
||
const data = await res.json();
|
||
if (!res.ok) throw new Error(data.error || 'Failed to load Google Drive folders.');
|
||
|
||
const folders = Array.isArray(data.folders) ? data.folders : [];
|
||
const activeId = data.currentFolderId || fallbackId;
|
||
const activeName = data.currentFolderName || fallbackName || 'ForkFolio Backups';
|
||
|
||
if (data.accountEmail && elements.gdriveStatusTitle) {
|
||
elements.gdriveStatusTitle.textContent = `Connected to Google Drive (${data.accountEmail})`;
|
||
}
|
||
|
||
elements.backupGdriveFolderSelect.innerHTML = '';
|
||
|
||
// Ensure the active folder is present in the list
|
||
const hasActive = folders.some(f => f.id === activeId);
|
||
if (activeId && !hasActive) {
|
||
folders.unshift({ id: activeId, name: activeName });
|
||
}
|
||
|
||
folders.forEach(folder => {
|
||
const opt = document.createElement('option');
|
||
opt.value = folder.id;
|
||
opt.dataset.folderName = folder.name;
|
||
opt.textContent = `📁 ${folder.name}`;
|
||
if (folder.id === activeId) {
|
||
opt.selected = true;
|
||
}
|
||
elements.backupGdriveFolderSelect.appendChild(opt);
|
||
});
|
||
|
||
if (elements.gdriveStatusSubtitle) {
|
||
const selOpt = elements.backupGdriveFolderSelect.selectedOptions[0];
|
||
const dispName = selOpt ? (selOpt.dataset.folderName || activeName) : activeName;
|
||
elements.gdriveStatusSubtitle.textContent = `Destination folder: "${dispName}"`;
|
||
}
|
||
|
||
if (showFeedback) {
|
||
showGdriveInlineStatus(`Loaded ${folders.length} folder(s) from Google Drive.`, 'success');
|
||
}
|
||
} catch (err) {
|
||
console.warn('Error loading Google Drive folders:', err);
|
||
if (showFeedback) {
|
||
showGdriveInlineStatus(err.message, 'error');
|
||
}
|
||
}
|
||
}
|
||
|
||
async function handleSelectGdriveFolder() {
|
||
if (!elements.backupGdriveFolderSelect) return;
|
||
const folderId = elements.backupGdriveFolderSelect.value;
|
||
const selectedOpt = elements.backupGdriveFolderSelect.selectedOptions[0];
|
||
const folderName = selectedOpt ? (selectedOpt.dataset.folderName || selectedOpt.textContent.replace(/^📁\s*/, '')) : 'ForkFolio Backups';
|
||
if (!folderId) return;
|
||
|
||
try {
|
||
const res = await fetch('/api/admin/backups/gdrive/select-folder', {
|
||
method: 'PUT',
|
||
headers: getAuthHeaders(),
|
||
body: JSON.stringify({ folderId, folderName })
|
||
});
|
||
const data = await res.json();
|
||
if (!res.ok) throw new Error(data.error || 'Failed to save selected folder.');
|
||
if (elements.backupGdriveEnabled) elements.backupGdriveEnabled.checked = true;
|
||
if (elements.gdriveStatusSubtitle) {
|
||
elements.gdriveStatusSubtitle.textContent = `Destination folder: "${folderName}"`;
|
||
}
|
||
showGdriveInlineStatus(`✓ Backups will now be saved to "${folderName}" in Google Drive.`, 'success');
|
||
} catch (err) {
|
||
showGdriveInlineStatus(err.message, 'error');
|
||
}
|
||
}
|
||
|
||
async function handleCreateGdriveFolder() {
|
||
const folderName = window.prompt('Enter a name for the new Google Drive backup folder:', 'ForkFolio Backups');
|
||
if (!folderName || !folderName.trim()) return;
|
||
|
||
const btn = elements.btnGdriveNewFolder;
|
||
const origHtml = btn.innerHTML;
|
||
btn.disabled = true;
|
||
|
||
try {
|
||
showGdriveInlineStatus(`Creating folder "${folderName.trim()}" in Google Drive...`, 'info');
|
||
const res = await fetch('/api/admin/backups/gdrive/folders', {
|
||
method: 'POST',
|
||
headers: getAuthHeaders(),
|
||
body: JSON.stringify({ name: folderName.trim() })
|
||
});
|
||
const data = await res.json();
|
||
if (!res.ok) throw new Error(data.error || 'Failed to create folder.');
|
||
|
||
await loadGoogleDriveFolders(false, data.folder.id, data.folder.name);
|
||
if (elements.backupGdriveEnabled) elements.backupGdriveEnabled.checked = true;
|
||
showGdriveInlineStatus(`✓ Created and selected folder "${data.folder.name}"!`, 'success');
|
||
} catch (err) {
|
||
showGdriveInlineStatus(err.message, 'error');
|
||
} finally {
|
||
btn.disabled = false;
|
||
btn.innerHTML = origHtml;
|
||
lucide.createIcons();
|
||
}
|
||
}
|
||
|
||
async function handleDisconnectGoogleDrive() {
|
||
const confirmed = window.confirm('Disconnect your Google Drive account from ForkFolio?');
|
||
if (!confirmed) return;
|
||
|
||
try {
|
||
const res = await fetch('/api/admin/backups/gdrive/disconnect', {
|
||
method: 'POST',
|
||
headers: getAuthHeaders()
|
||
});
|
||
const data = await res.json();
|
||
if (!res.ok) throw new Error(data.error || 'Failed to disconnect Google Drive.');
|
||
showGdriveInlineStatus('Google Drive account disconnected.', 'info');
|
||
fetchAdminBackupConfig();
|
||
} catch (err) {
|
||
showGdriveInlineStatus(err.message, 'error');
|
||
}
|
||
}
|
||
|
||
async function fetchAdminBackupConfig() {
|
||
try {
|
||
const res = await fetch('/api/admin/backups/config', {
|
||
headers: getAuthHeaders()
|
||
});
|
||
if (res.status === 401) {
|
||
logout();
|
||
return;
|
||
}
|
||
if (!res.ok) throw new Error('Failed to load backup configuration.');
|
||
const data = await res.json();
|
||
const cfg = data.config || {};
|
||
|
||
if (elements.backupScheduleFrequency) elements.backupScheduleFrequency.value = cfg.scheduleFrequency || cfg.schedule || 'off';
|
||
if (elements.backupScheduleTime) elements.backupScheduleTime.value = cfg.scheduleTime || '02:00';
|
||
if (elements.backupScheduleDay) elements.backupScheduleDay.value = String(cfg.scheduleDay !== undefined ? cfg.scheduleDay : 0);
|
||
if (elements.backupRetentionCount) elements.backupRetentionCount.value = String(cfg.retentionCount !== undefined ? cfg.retentionCount : 14);
|
||
if (elements.backupFormat) elements.backupFormat.value = cfg.backupFormat || 'both';
|
||
if (elements.backupEmbedImages) elements.backupEmbedImages.checked = Boolean(cfg.embedRemoteImages);
|
||
|
||
if (elements.backupS3Enabled) elements.backupS3Enabled.checked = Boolean(cfg.s3Enabled);
|
||
if (elements.backupS3Endpoint) elements.backupS3Endpoint.value = cfg.s3Endpoint || 'https://s3.us-east-1.wasabisys.com';
|
||
if (elements.backupS3Region) elements.backupS3Region.value = cfg.s3Region || 'us-east-1';
|
||
if (elements.backupS3Bucket) elements.backupS3Bucket.value = cfg.s3Bucket || '';
|
||
if (elements.backupS3Prefix) elements.backupS3Prefix.value = cfg.s3Prefix || 'forkfolio-backups/';
|
||
if (elements.backupS3AccessKey) elements.backupS3AccessKey.value = cfg.s3AccessKeyId || '';
|
||
if (elements.backupS3SecretKey) elements.backupS3SecretKey.value = cfg.s3SecretAccessKey || '';
|
||
|
||
if (elements.backupGdriveEnabled) elements.backupGdriveEnabled.checked = Boolean(cfg.gdriveEnabled);
|
||
|
||
renderGdriveConnectionState(cfg);
|
||
|
||
if (elements.backupLastRunBadge) {
|
||
elements.backupLastRunBadge.style.display = 'inline-flex';
|
||
if (cfg.lastRunAt) {
|
||
const dt = new Date(cfg.lastRunAt).toLocaleString();
|
||
const st = cfg.lastRunStatus === 'success' ? '✓ Success' : cfg.lastRunStatus === 'error' ? '⚠ Failed' : cfg.lastRunStatus || '';
|
||
elements.backupLastRunBadge.textContent = `Last Cloud Run: ${dt} (${st})`;
|
||
} else {
|
||
elements.backupLastRunBadge.textContent = 'Never run';
|
||
}
|
||
}
|
||
|
||
renderAdminBackupHistory(data.history || []);
|
||
// Automatically scan cloud storage if connected and local history is empty
|
||
if ((!data.history || data.history.length === 0) && (cfg.gdriveConnected || cfg.s3Enabled)) {
|
||
handleSyncCloudBackups(true);
|
||
}
|
||
} catch (err) {
|
||
console.error('Error fetching backup config:', err);
|
||
showBackupStatus(err.message, 'error');
|
||
}
|
||
}
|
||
|
||
function formatBytesHuman(bytes) {
|
||
const n = Number(bytes) || 0;
|
||
if (n < 1024) return `${n} B`;
|
||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
||
return `${(n / (1024 * 1024)).toFixed(2)} MB`;
|
||
}
|
||
|
||
function renderAdminBackupHistory(history) {
|
||
if (!elements.adminBackupsHistoryList) return;
|
||
elements.adminBackupsHistoryList.innerHTML = '';
|
||
|
||
if (!history || history.length === 0) {
|
||
elements.adminBackupsHistoryList.innerHTML = `
|
||
<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, '"')}">⚠ 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, '"')}">${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: getBaseUrl()
|
||
})
|
||
});
|
||
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 query = silent ? '' : '?scan=1';
|
||
const res = await fetch(`/api/admin/backups/cloud-files${query}`, {
|
||
headers: getAuthHeaders()
|
||
});
|
||
const data = await res.json();
|
||
if (!res.ok) throw new Error(data.error || 'Failed to scan cloud backups.');
|
||
|
||
cachedCloudBackups = data.backups || [];
|
||
renderAdminBackupHistory(cachedCloudBackups);
|
||
if (!silent) {
|
||
if (data.providerErrors && data.providerErrors.length > 0) {
|
||
showBackupStatus(`Synced ${cachedCloudBackups.length} cloud backup(s) (${data.providerErrors.join('; ')}).`, 'info');
|
||
} else {
|
||
showBackupStatus(`Synced ${cachedCloudBackups.length} cloud backup(s) from connected storage.`, 'success');
|
||
}
|
||
}
|
||
} catch (err) {
|
||
if (!silent) showBackupStatus(err.message, 'error');
|
||
} finally {
|
||
if (btn) {
|
||
btn.disabled = false;
|
||
btn.innerHTML = origHtml;
|
||
lucide.createIcons();
|
||
}
|
||
}
|
||
}
|
||
|
||
function openCloudRestoreModal() {
|
||
if (elements.cloudRestoreModalStatus) {
|
||
elements.cloudRestoreModalStatus.classList.add('hidden');
|
||
}
|
||
openModal('restore-cloud-modal');
|
||
loadLiveCloudBackupsForModal(false);
|
||
}
|
||
|
||
async function loadLiveCloudBackupsForModal(showToast = false) {
|
||
if (!elements.cloudRestoreFilesContainer) return;
|
||
elements.cloudRestoreFilesContainer.innerHTML = `
|
||
<div style="text-align: center; padding: 32px; color: var(--text-muted);">
|
||
<p>Loading available cloud backups...</p>
|
||
</div>
|
||
`;
|
||
|
||
try {
|
||
const query = showToast ? '?scan=1' : '';
|
||
const res = await fetch(`/api/admin/backups/cloud-files${query}`, {
|
||
headers: getAuthHeaders()
|
||
});
|
||
const data = await res.json();
|
||
if (!res.ok) throw new Error(data.error || 'Failed to scan cloud storage.');
|
||
|
||
cachedCloudBackups = data.backups || [];
|
||
renderAdminBackupHistory(cachedCloudBackups);
|
||
renderCloudRestoreModalList();
|
||
|
||
if (data.providerErrors && data.providerErrors.length > 0) {
|
||
showCloudRestoreModalStatus(`Note: ${data.providerErrors.join(' | ')}`, 'error');
|
||
} else if (showToast) {
|
||
showCloudRestoreModalStatus(`Found ${cachedCloudBackups.length} backup snapshot(s) in cloud storage.`, 'success');
|
||
}
|
||
} catch (err) {
|
||
showCloudRestoreModalStatus(err.message, 'error');
|
||
elements.cloudRestoreFilesContainer.innerHTML = `
|
||
<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()})`
|
||
});
|
||
}
|
||
}
|
||
|
||
|
||
|
||
|