mirror of
https://gitlab.com/Zer0interrupt/recipe-manager.git
synced 2026-11-01 23:24:11 -05:00
2075 lines
73 KiB
JavaScript
2075 lines
73 KiB
JavaScript
// ForkFolio client application state
|
|
let allRecipes = [];
|
|
let activeRecipe = null;
|
|
let currentMultiplier = 1;
|
|
let selectedCoverImage = '';
|
|
let stopwatchInterval = null;
|
|
let stopwatchSeconds = 0;
|
|
let cookingActiveStep = 0;
|
|
let currentUser = null;
|
|
let activeTab = 'my-cookbook';
|
|
|
|
// Page elements caching
|
|
const elements = {
|
|
themeToggle: document.getElementById('theme-toggle'),
|
|
urlScrapeForm: document.getElementById('url-scrape-form'),
|
|
scrapeUrlInput: document.getElementById('scrape-url-input'),
|
|
btnScrapeSubmit: document.getElementById('btn-scrape-submit'),
|
|
btnManualAdd: document.getElementById('btn-manual-add'),
|
|
btnSettings: document.getElementById('btn-settings'),
|
|
settingsModal: document.getElementById('settings-modal'),
|
|
settingsApiForm: document.getElementById('settings-api-form'),
|
|
settingsGeminiKeyInput: document.getElementById('settings-gemini-key'),
|
|
btnToggleGeminiKey: document.getElementById('btn-toggle-gemini-key'),
|
|
settingsStatusMsg: document.getElementById('settings-status-msg'),
|
|
settingsStatusText: document.getElementById('settings-status-text'),
|
|
scanImageInput: document.getElementById('scan-image-input'),
|
|
btnScanImage: document.getElementById('btn-scan-image'),
|
|
recipesGrid: document.getElementById('recipes-grid'),
|
|
emptyState: document.getElementById('empty-state'),
|
|
librarySearch: document.getElementById('library-search'),
|
|
|
|
// Auth caching
|
|
authModal: document.getElementById('auth-modal'),
|
|
loginForm: document.getElementById('login-form'),
|
|
authErrorMsg: document.getElementById('auth-error-msg'),
|
|
authErrorText: document.getElementById('auth-error-text'),
|
|
loginUsernameInput: document.getElementById('login-username'),
|
|
loginPasswordInput: document.getElementById('login-password'),
|
|
userBadgeContainer: document.getElementById('user-badge-container'),
|
|
userDisplayName: document.getElementById('user-display-name'),
|
|
btnLogout: document.getElementById('btn-logout'),
|
|
tabMyCookbook: document.getElementById('tab-my-cookbook'),
|
|
tabCommunityVault: document.getElementById('tab-community-vault'),
|
|
tabUserAdmin: document.getElementById('tab-user-admin'),
|
|
userAdminPanel: document.getElementById('user-admin-panel'),
|
|
adminUsersList: document.getElementById('admin-users-list'),
|
|
btnAdminAddUser: document.getElementById('btn-admin-add-user'),
|
|
btnAdminTabUsers: document.getElementById('btn-admin-tab-users'),
|
|
btnAdminTabShares: document.getElementById('btn-admin-tab-shares'),
|
|
adminUsersSection: document.getElementById('admin-users-section'),
|
|
adminSharesSection: document.getElementById('admin-shares-section'),
|
|
adminSharesList: document.getElementById('admin-shares-list'),
|
|
|
|
// Admin Create User Modal
|
|
adminCreateUserModal: document.getElementById('admin-create-user-modal'),
|
|
adminCreateUserForm: document.getElementById('admin-create-user-form'),
|
|
adminNewUsername: document.getElementById('admin-new-username'),
|
|
adminNewPassword: document.getElementById('admin-new-password'),
|
|
adminCreateError: document.getElementById('admin-create-error'),
|
|
adminCreateErrorText: document.getElementById('admin-create-error-text'),
|
|
|
|
// Admin Delete User Modal
|
|
adminDeleteUserModal: document.getElementById('admin-delete-user-modal'),
|
|
deleteUserTargetId: document.getElementById('delete-user-target-id'),
|
|
deleteUserTargetName: document.getElementById('delete-user-target-name'),
|
|
deleteUserPreserveRecipes: document.getElementById('delete-user-preserve-recipes'),
|
|
btnAdminConfirmDelete: document.getElementById('btn-admin-confirm-delete'),
|
|
|
|
// Scrape / Edit Modal
|
|
saveModal: document.getElementById('save-modal'),
|
|
recipeEditForm: document.getElementById('recipe-edit-form'),
|
|
editRecipeId: document.getElementById('edit-recipe-id'),
|
|
editTitle: document.getElementById('edit-title'),
|
|
editSource: document.getElementById('edit-source'),
|
|
editDescription: document.getElementById('edit-description'),
|
|
editServings: document.getElementById('edit-servings'),
|
|
editPrepTime: document.getElementById('edit-prep-time'),
|
|
editCookTime: document.getElementById('edit-cook-time'),
|
|
imageSelectionGrid: document.getElementById('image-selection-grid'),
|
|
editImageUrl: document.getElementById('edit-image-url'),
|
|
editIngredients: document.getElementById('edit-ingredients'),
|
|
editInstructions: document.getElementById('edit-instructions'),
|
|
modalActionTitle: document.getElementById('modal-action-title'),
|
|
btnSaveConfirm: document.getElementById('btn-save-confirm'),
|
|
editIsPublic: document.getElementById('edit-is-public'),
|
|
|
|
// Detail Modal
|
|
detailModal: document.getElementById('detail-modal'),
|
|
recipeDetailBanner: document.getElementById('recipe-detail-banner'),
|
|
btnDetailEdit: document.getElementById('btn-detail-edit'),
|
|
btnDetailDelete: document.getElementById('btn-detail-delete'),
|
|
btnDetailClone: document.getElementById('btn-detail-clone'),
|
|
btnDetailShare: document.getElementById('btn-detail-share'),
|
|
btnDetailRevokeShare: document.getElementById('btn-detail-revoke-share'),
|
|
tagShareStats: document.getElementById('tag-share-stats'),
|
|
recipeDetailCreator: document.getElementById('recipe-detail-creator'),
|
|
recipeDetailCreatorName: document.getElementById('recipe-detail-creator-name'),
|
|
recipeDetailTitle: document.getElementById('recipe-detail-title'),
|
|
tagServings: document.getElementById('tag-servings'),
|
|
tagPrep: document.getElementById('tag-prep'),
|
|
tagCook: document.getElementById('tag-cook'),
|
|
tagSource: document.getElementById('tag-source'),
|
|
recipeDetailDescription: document.getElementById('recipe-detail-description'),
|
|
recipeDetailIngredients: document.getElementById('recipe-detail-ingredients'),
|
|
recipeDetailInstructions: document.getElementById('recipe-detail-instructions'),
|
|
scalerMinus: document.getElementById('scaler-minus'),
|
|
scalerPlus: document.getElementById('scaler-plus'),
|
|
currentServingsVal: document.getElementById('current-servings-val'),
|
|
btnTriggerCookmode: document.getElementById('btn-trigger-cookmode'),
|
|
|
|
// Cooking Mode Modal
|
|
cookingModeModal: document.getElementById('cooking-mode-modal'),
|
|
cookRecipeTitle: document.getElementById('cook-recipe-title'),
|
|
cookTimerVal: document.getElementById('cook-timer-val'),
|
|
cookTimerPlay: document.getElementById('cook-timer-play'),
|
|
cookTimerReset: document.getElementById('cook-timer-reset'),
|
|
cookProgressBar: document.getElementById('cook-progress-bar'),
|
|
cookStepIndex: document.getElementById('cook-step-index'),
|
|
cookStepInstruction: document.getElementById('cook-step-instruction'),
|
|
cookBtnPrev: document.getElementById('cook-btn-prev'),
|
|
cookBtnNext: document.getElementById('cook-btn-next'),
|
|
cookStepDots: document.getElementById('cook-step-dots'),
|
|
|
|
// Loader
|
|
loadingOverlay: document.getElementById('loading-overlay'),
|
|
loaderStepFetch: document.getElementById('loader-step-fetch'),
|
|
loaderStepParse: document.getElementById('loader-step-parse'),
|
|
loaderStepImages: document.getElementById('loader-step-images'),
|
|
|
|
// Mobile Components
|
|
fabAddRecipe: document.getElementById('mobile-header-btn-add'),
|
|
mobileTabCookbook: document.getElementById('mobile-header-tab-cookbook'),
|
|
mobileTabCommunity: document.getElementById('mobile-header-tab-community'),
|
|
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')
|
|
};
|
|
|
|
// -------------------------------------------------------------
|
|
// LIFE CYCLE AND REGISTRATION
|
|
// -------------------------------------------------------------
|
|
|
|
window.addEventListener('DOMContentLoaded', () => {
|
|
initTheme();
|
|
registerEvents();
|
|
initAuth();
|
|
});
|
|
|
|
function registerEvents() {
|
|
// Theme Toggle
|
|
elements.themeToggle.addEventListener('click', toggleTheme);
|
|
|
|
// URL Form Submission
|
|
elements.urlScrapeForm.addEventListener('submit', handleScrapeSubmit);
|
|
|
|
// Manual Add Form Opening
|
|
elements.btnManualAdd.addEventListener('click', openManualAdd);
|
|
|
|
// Settings Modal Opening
|
|
elements.btnSettings.addEventListener('click', openSettingsModal);
|
|
elements.settingsApiForm.addEventListener('submit', handleSaveSettings);
|
|
elements.btnToggleGeminiKey.addEventListener('click', toggleSettingsKeyVisibility);
|
|
|
|
// Image Scanning Actions
|
|
elements.btnScanImage.addEventListener('click', () => elements.scanImageInput.click());
|
|
elements.scanImageInput.addEventListener('change', handleRecipeImageScan);
|
|
document.addEventListener('paste', handleClipboardPaste);
|
|
|
|
// Library Search
|
|
elements.librarySearch.addEventListener('input', filterRecipes);
|
|
|
|
// Recipe Form Save
|
|
elements.recipeEditForm.addEventListener('submit', handleRecipeSave);
|
|
|
|
// Action Buttons Detail Modal
|
|
elements.btnDetailEdit.addEventListener('click', editActiveRecipe);
|
|
elements.btnDetailDelete.addEventListener('click', deleteActiveRecipe);
|
|
elements.btnDetailClone.addEventListener('click', cloneActiveRecipe);
|
|
elements.btnDetailShare.addEventListener('click', shareActiveRecipe);
|
|
elements.btnDetailRevokeShare.addEventListener('click', revokeActiveRecipeShare);
|
|
|
|
// Auth Form Submissions
|
|
elements.loginForm.addEventListener('submit', handleLogin);
|
|
elements.btnLogout.addEventListener('click', logout);
|
|
|
|
// Dashboard Tabs Switchers
|
|
elements.tabMyCookbook.addEventListener('click', () => switchTab('my-cookbook'));
|
|
elements.tabCommunityVault.addEventListener('click', () => switchTab('community-vault'));
|
|
elements.tabUserAdmin.addEventListener('click', () => switchTab('user-admin'));
|
|
|
|
// 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'));
|
|
|
|
// 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'));
|
|
elements.mobileTabProfile.addEventListener('click', openMobileProfile);
|
|
elements.headerLogo.addEventListener('click', () => switchTab('my-cookbook'));
|
|
elements.fabAddRecipe.addEventListener('click', handleFabClick);
|
|
elements.mobileBtnThemeToggle.addEventListener('click', toggleTheme);
|
|
elements.mobileBtnSettings.addEventListener('click', () => {
|
|
closeModal('mobile-profile-modal');
|
|
openSettingsModal();
|
|
});
|
|
elements.mobileBtnAdmin.addEventListener('click', () => {
|
|
closeModal('mobile-profile-modal');
|
|
switchTab('user-admin');
|
|
});
|
|
elements.mobileBtnLogout.addEventListener('click', () => {
|
|
closeModal('mobile-profile-modal');
|
|
logout();
|
|
});
|
|
}
|
|
|
|
// -------------------------------------------------------------
|
|
// UTILITIES: SYSTEM THEMES
|
|
// -------------------------------------------------------------
|
|
|
|
function initTheme() {
|
|
const savedTheme = localStorage.getItem('gourmet-theme') || 'dark';
|
|
if (savedTheme === 'dark') {
|
|
document.body.classList.add('dark-theme');
|
|
} else {
|
|
document.body.classList.remove('dark-theme');
|
|
}
|
|
}
|
|
|
|
function toggleTheme() {
|
|
document.body.classList.toggle('dark-theme');
|
|
const isDark = document.body.classList.contains('dark-theme');
|
|
localStorage.setItem('gourmet-theme', isDark ? 'dark' : 'light');
|
|
}
|
|
|
|
// -------------------------------------------------------------
|
|
// LOADING STATUS MACHINE
|
|
// -------------------------------------------------------------
|
|
|
|
function setLoaderStepIcon(stepElement, iconName, spin = false) {
|
|
const wrapper = stepElement.querySelector('.step-icon-wrapper');
|
|
if (wrapper) {
|
|
wrapper.innerHTML = `<i data-lucide="${iconName}" class="${spin ? 'spinning-icon' : ''}"></i>`;
|
|
}
|
|
}
|
|
|
|
function showScrapeLoading() {
|
|
elements.loadingOverlay.classList.add('active');
|
|
|
|
// Reset Steps to default
|
|
elements.loaderStepFetch.className = 'loader-step active';
|
|
elements.loaderStepParse.className = 'loader-step';
|
|
elements.loaderStepImages.className = 'loader-step';
|
|
|
|
setLoaderStepIcon(elements.loaderStepFetch, 'circle-dashed', true);
|
|
setLoaderStepIcon(elements.loaderStepParse, 'circle', false);
|
|
setLoaderStepIcon(elements.loaderStepImages, 'circle', false);
|
|
|
|
lucide.createIcons();
|
|
|
|
// Simulate process transitions
|
|
setTimeout(() => {
|
|
if (!elements.loadingOverlay.classList.contains('active')) return;
|
|
elements.loaderStepFetch.className = 'loader-step completed';
|
|
setLoaderStepIcon(elements.loaderStepFetch, 'check-circle-2', false);
|
|
|
|
elements.loaderStepParse.className = 'loader-step active';
|
|
setLoaderStepIcon(elements.loaderStepParse, 'circle-dashed', true);
|
|
lucide.createIcons();
|
|
}, 1800);
|
|
|
|
setTimeout(() => {
|
|
if (!elements.loadingOverlay.classList.contains('active')) return;
|
|
elements.loaderStepParse.className = 'loader-step completed';
|
|
setLoaderStepIcon(elements.loaderStepParse, 'check-circle-2', false);
|
|
|
|
elements.loaderStepImages.className = 'loader-step active';
|
|
setLoaderStepIcon(elements.loaderStepImages, 'circle-dashed', true);
|
|
lucide.createIcons();
|
|
}, 3500);
|
|
}
|
|
|
|
function hideScrapeLoading() {
|
|
elements.loadingOverlay.classList.remove('active');
|
|
}
|
|
|
|
// -------------------------------------------------------------
|
|
// CORE MODAL ROUTER
|
|
// -------------------------------------------------------------
|
|
|
|
function openModal(id) {
|
|
document.getElementById(id).classList.add('active');
|
|
}
|
|
|
|
function closeModal(id) {
|
|
document.getElementById(id).classList.remove('active');
|
|
if (id === 'cooking-mode-modal') {
|
|
exitCookingMode();
|
|
}
|
|
}
|
|
|
|
// Helper: get authentication headers for fetches
|
|
function getAuthHeaders() {
|
|
const token = localStorage.getItem('gourmet_token');
|
|
return token ? { 'Authorization': `Bearer ${token}` } : {};
|
|
}
|
|
|
|
// -------------------------------------------------------------
|
|
// CRUD & API HANDLERS
|
|
// -------------------------------------------------------------
|
|
|
|
async function fetchRecipes() {
|
|
try {
|
|
let url = '/api/recipes';
|
|
if (activeTab === 'community-vault') {
|
|
url = '/api/recipes/public';
|
|
}
|
|
|
|
const response = await fetch(url, {
|
|
headers: getAuthHeaders()
|
|
});
|
|
|
|
if (response.status === 401) {
|
|
logout();
|
|
return;
|
|
}
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Failed to load recipes');
|
|
}
|
|
|
|
allRecipes = await response.json();
|
|
renderLibrary();
|
|
} catch (error) {
|
|
console.error('Error fetching recipes library:', error);
|
|
}
|
|
}
|
|
|
|
// Scrape link form submission
|
|
async function handleScrapeSubmit(e) {
|
|
e.preventDefault();
|
|
const url = elements.scrapeUrlInput.value.trim();
|
|
if (!url) return;
|
|
|
|
showScrapeLoading();
|
|
|
|
try {
|
|
const response = await fetch('/api/scrape', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
...getAuthHeaders()
|
|
},
|
|
body: JSON.stringify({ url })
|
|
});
|
|
|
|
if (response.status === 401) {
|
|
logout();
|
|
hideScrapeLoading();
|
|
return;
|
|
}
|
|
|
|
if (!response.ok) {
|
|
const errorData = await response.json().catch(() => ({}));
|
|
if (response.status === 403 || errorData.isBlocked) {
|
|
throw new Error('BLOCKED');
|
|
}
|
|
throw new Error(errorData.error || 'Extraction error.');
|
|
}
|
|
|
|
const data = await response.json();
|
|
hideScrapeLoading();
|
|
openDraftPreview(data);
|
|
elements.scrapeUrlInput.value = '';
|
|
} catch (error) {
|
|
hideScrapeLoading();
|
|
if (error.message === 'BLOCKED') {
|
|
alert(`Access Blocked by Site Protection\n\nThis website uses Cloudflare or bot-defense screens to prevent automated scraping.\n\nDon't worry! You can still add this recipe by clicking the "Manual Recipe" button at the top to quickly paste your ingredients and directions.`);
|
|
} else {
|
|
alert(`Scraping failed: ${error.message}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Save or Edit submit
|
|
async function handleRecipeSave(e) {
|
|
e.preventDefault();
|
|
|
|
const id = elements.editRecipeId.value;
|
|
const recipePayload = {
|
|
title: elements.editTitle.value.trim(),
|
|
sourceUrl: elements.editSource.value.trim(),
|
|
description: elements.editDescription.value.trim(),
|
|
servings: elements.editServings.value.trim(),
|
|
prepTime: elements.editPrepTime.value.trim(),
|
|
cookTime: elements.editCookTime.value.trim(),
|
|
isPublic: elements.editIsPublic.checked,
|
|
image: elements.editImageUrl.value.trim(),
|
|
images: Array.from(elements.imageSelectionGrid.querySelectorAll('.image-option img')).map(img => img.src),
|
|
ingredients: elements.editIngredients.value.trim().split(/\r?\n/).map(line => line.trim()).filter(Boolean),
|
|
instructions: elements.editInstructions.value.trim().split(/\r?\n/).map(line => line.trim()).filter(Boolean)
|
|
};
|
|
|
|
try {
|
|
let response;
|
|
if (id) {
|
|
// Edit
|
|
response = await fetch(`/api/recipes/${id}`, {
|
|
method: 'PUT',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
...getAuthHeaders()
|
|
},
|
|
body: JSON.stringify(recipePayload)
|
|
});
|
|
} else {
|
|
// Create
|
|
response = await fetch('/api/recipes', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
...getAuthHeaders()
|
|
},
|
|
body: JSON.stringify(recipePayload)
|
|
});
|
|
}
|
|
|
|
if (response.status === 401) {
|
|
logout();
|
|
return;
|
|
}
|
|
|
|
if (!response.ok) throw new Error('Saving failed.');
|
|
|
|
const saved = await response.json();
|
|
closeModal('save-modal');
|
|
fetchRecipes();
|
|
|
|
// If active view was open, update it
|
|
if (activeRecipe && activeRecipe.id === saved.id) {
|
|
showRecipeDetails(saved);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error saving recipe:', error);
|
|
alert('Error saving recipe.');
|
|
}
|
|
}
|
|
|
|
// Delete Active Recipe
|
|
async function deleteActiveRecipe() {
|
|
if (!activeRecipe) return;
|
|
if (!confirm(`Are you sure you want to delete "${activeRecipe.title}"?`)) return;
|
|
|
|
try {
|
|
const response = await fetch(`/api/recipes/${activeRecipe.id}`, {
|
|
method: 'DELETE',
|
|
headers: getAuthHeaders()
|
|
});
|
|
|
|
if (response.status === 401) {
|
|
logout();
|
|
return;
|
|
}
|
|
|
|
if (!response.ok) throw new Error('Deletion failed.');
|
|
|
|
closeModal('detail-modal');
|
|
activeRecipe = null;
|
|
fetchRecipes();
|
|
} catch (error) {
|
|
console.error('Error deleting recipe:', error);
|
|
alert('Deletion failed.');
|
|
}
|
|
}
|
|
|
|
// Clone a Shared Cookbook recipe to current user's collection
|
|
async function cloneActiveRecipe() {
|
|
if (!activeRecipe) return;
|
|
|
|
try {
|
|
const response = await fetch(`/api/recipes/clone/${activeRecipe.id}`, {
|
|
method: 'POST',
|
|
headers: getAuthHeaders()
|
|
});
|
|
|
|
if (response.status === 401) {
|
|
logout();
|
|
return;
|
|
}
|
|
|
|
if (!response.ok) {
|
|
const err = await response.json().catch(() => ({}));
|
|
throw new Error(err.error || 'Cloning failed.');
|
|
}
|
|
|
|
const cloned = await response.json();
|
|
alert(`Successfully cloned "${activeRecipe.title}" into your cookbook!`);
|
|
closeModal('detail-modal');
|
|
|
|
// Switch back to "My Cookbook" tab to let them see it immediately
|
|
switchTab('my-cookbook');
|
|
} catch (error) {
|
|
console.error('Error cloning recipe:', error);
|
|
alert(`Error cloning recipe: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
async function copyTextToClipboard(text) {
|
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
await navigator.clipboard.writeText(text);
|
|
return;
|
|
}
|
|
const textArea = document.createElement('textarea');
|
|
textArea.value = text;
|
|
textArea.style.position = 'fixed';
|
|
textArea.style.opacity = '0';
|
|
document.body.appendChild(textArea);
|
|
textArea.focus();
|
|
textArea.select();
|
|
document.execCommand('copy');
|
|
document.body.removeChild(textArea);
|
|
}
|
|
|
|
let shareBtnResetTimeout = null;
|
|
|
|
async function shareActiveRecipe() {
|
|
if (!activeRecipe) return;
|
|
|
|
if (activeRecipe.isPublic === false) {
|
|
if (confirm(`"${activeRecipe.title}" is currently private.\n\nTo share it with people outside ForkFolio, you must make it public first. Would you like to make this recipe public now?`)) {
|
|
try {
|
|
const response = await fetch(`/api/recipes/${activeRecipe.id}`, {
|
|
method: 'PUT',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
...getAuthHeaders()
|
|
},
|
|
body: JSON.stringify({ isPublic: true })
|
|
});
|
|
|
|
if (response.status === 401) {
|
|
logout();
|
|
return;
|
|
}
|
|
|
|
if (!response.ok) throw new Error('Failed to update privacy.');
|
|
|
|
const updated = await response.json();
|
|
activeRecipe = updated;
|
|
fetchRecipes();
|
|
|
|
// Re-render detail badge visual state
|
|
showRecipeDetails(updated);
|
|
} catch (err) {
|
|
console.error('Error toggling privacy for share link:', err);
|
|
alert('Failed to make the recipe public.');
|
|
return;
|
|
}
|
|
} else {
|
|
return;
|
|
}
|
|
}
|
|
|
|
// If public and has a share token
|
|
if (activeRecipe.shareToken) {
|
|
const shareLink = `${window.location.origin}/share.html?token=${activeRecipe.shareToken}`;
|
|
try {
|
|
await copyTextToClipboard(shareLink);
|
|
if (elements.btnDetailShare) {
|
|
if (shareBtnResetTimeout) clearTimeout(shareBtnResetTimeout);
|
|
elements.btnDetailShare.classList.add('copied-feedback');
|
|
elements.btnDetailShare.innerHTML = `<i data-lucide="check"></i> <span>Copied!</span>`;
|
|
lucide.createIcons();
|
|
shareBtnResetTimeout = setTimeout(() => {
|
|
elements.btnDetailShare.classList.remove('copied-feedback');
|
|
elements.btnDetailShare.innerHTML = `<i data-lucide="share-2"></i>`;
|
|
lucide.createIcons();
|
|
}, 2000);
|
|
}
|
|
} catch (clipErr) {
|
|
console.warn('Clipboard write failed. Displaying fallback prompt:', clipErr);
|
|
prompt('Public Share Link:', shareLink);
|
|
}
|
|
} else {
|
|
alert('Sharing token was not generated. Please try saving this recipe as public again.');
|
|
}
|
|
}
|
|
|
|
async function revokeActiveRecipeShare() {
|
|
if (!activeRecipe) return;
|
|
if (!confirm(`Are you sure you want to revoke the shared link for "${activeRecipe.title}"?\n\nThis will make the recipe private, disable the public link immediately, and clear all visitor history.`)) return;
|
|
|
|
try {
|
|
const response = await fetch(`/api/recipes/${activeRecipe.id}/revoke-share`, {
|
|
method: 'POST',
|
|
headers: getAuthHeaders()
|
|
});
|
|
|
|
if (response.status === 401) {
|
|
logout();
|
|
return;
|
|
}
|
|
|
|
if (!response.ok) {
|
|
const err = await response.json().catch(() => ({}));
|
|
throw new Error(err.error || 'Failed to revoke shared link.');
|
|
}
|
|
|
|
alert(`Successfully revoked the public share link for "${activeRecipe.title}".`);
|
|
|
|
// Refresh list and details view
|
|
const listUrl = activeTab === 'community-vault' ? '/api/recipes/public' : '/api/recipes';
|
|
const listRes = await fetch(listUrl, { headers: getAuthHeaders() });
|
|
if (listRes.ok) {
|
|
allRecipes = await listRes.json();
|
|
renderLibrary();
|
|
const updated = allRecipes.find(r => r.id === activeRecipe.id);
|
|
if (updated) {
|
|
showRecipeDetails(updated);
|
|
} else {
|
|
closeModal('detail-modal');
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Error revoking recipe share:', error);
|
|
alert(`Error: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------
|
|
// DRAFT / MANUAL CREATOR MODAL POPULATOR
|
|
// -------------------------------------------------------------
|
|
|
|
function openManualAdd() {
|
|
elements.modalActionTitle.textContent = 'Create New Recipe';
|
|
elements.editRecipeId.value = '';
|
|
elements.recipeEditForm.reset();
|
|
elements.imageSelectionGrid.innerHTML = '';
|
|
selectedCoverImage = '';
|
|
elements.editIsPublic.checked = true;
|
|
|
|
openModal('save-modal');
|
|
}
|
|
|
|
function openDraftPreview(data) {
|
|
elements.modalActionTitle.textContent = 'Save Extracted Recipe';
|
|
elements.editRecipeId.value = '';
|
|
|
|
elements.editTitle.value = data.title || '';
|
|
elements.editSource.value = data.sourceUrl || '';
|
|
elements.editDescription.value = data.description || '';
|
|
elements.editServings.value = data.servings || '';
|
|
elements.editPrepTime.value = data.prepTime || '';
|
|
elements.editCookTime.value = data.cookTime || '';
|
|
elements.editIsPublic.checked = true;
|
|
|
|
// Ingredients text area
|
|
elements.editIngredients.value = data.ingredients ? data.ingredients.join('\n') : '';
|
|
|
|
// Instructions text area
|
|
elements.editInstructions.value = data.instructions ? data.instructions.join('\n') : '';
|
|
|
|
// Render Image Selector
|
|
renderImageSelector(data.images || []);
|
|
|
|
openModal('save-modal');
|
|
}
|
|
|
|
function editActiveRecipe() {
|
|
if (!activeRecipe) return;
|
|
|
|
elements.modalActionTitle.textContent = 'Edit Recipe Details';
|
|
elements.editRecipeId.value = activeRecipe.id;
|
|
|
|
elements.editTitle.value = activeRecipe.title || '';
|
|
elements.editSource.value = activeRecipe.sourceUrl || '';
|
|
elements.editDescription.value = activeRecipe.description || '';
|
|
elements.editServings.value = activeRecipe.servings || '';
|
|
elements.editPrepTime.value = activeRecipe.prepTime || '';
|
|
elements.editCookTime.value = activeRecipe.cookTime || '';
|
|
elements.editIsPublic.checked = activeRecipe.isPublic !== false;
|
|
|
|
elements.editIngredients.value = activeRecipe.ingredients ? activeRecipe.ingredients.join('\n') : '';
|
|
elements.editInstructions.value = activeRecipe.instructions ? activeRecipe.instructions.join('\n') : '';
|
|
|
|
renderImageSelector(activeRecipe.images || []);
|
|
|
|
// Set active selection
|
|
if (activeRecipe.image) {
|
|
elements.editImageUrl.value = activeRecipe.image;
|
|
selectedCoverImage = activeRecipe.image;
|
|
selectImageThumbnail(activeRecipe.image);
|
|
}
|
|
|
|
openModal('save-modal');
|
|
}
|
|
|
|
// -------------------------------------------------------------
|
|
// IMAGE EXTRACTION CAROUSEL & SELECTOR
|
|
// -------------------------------------------------------------
|
|
|
|
function renderImageSelector(images) {
|
|
elements.imageSelectionGrid.innerHTML = '';
|
|
selectedCoverImage = '';
|
|
|
|
if (!images || images.length === 0) {
|
|
elements.editImageUrl.value = '';
|
|
return;
|
|
}
|
|
|
|
// Pre-select first image
|
|
selectedCoverImage = images[0];
|
|
elements.editImageUrl.value = images[0];
|
|
|
|
images.forEach((imgSrc, index) => {
|
|
const option = document.createElement('div');
|
|
option.className = `image-option ${index === 0 ? 'selected' : ''}`;
|
|
option.innerHTML = `<img src="${imgSrc}" alt="extracted thumbnail">`;
|
|
|
|
option.addEventListener('click', () => {
|
|
elements.imageSelectionGrid.querySelectorAll('.image-option').forEach(el => el.classList.remove('selected'));
|
|
option.classList.add('selected');
|
|
selectedCoverImage = imgSrc;
|
|
elements.editImageUrl.value = imgSrc;
|
|
});
|
|
|
|
elements.imageSelectionGrid.appendChild(option);
|
|
});
|
|
}
|
|
|
|
function selectImageThumbnail(url) {
|
|
elements.imageSelectionGrid.querySelectorAll('.image-option').forEach(el => {
|
|
const img = el.querySelector('img');
|
|
if (img && img.src === url) {
|
|
el.classList.add('selected');
|
|
} else {
|
|
el.classList.remove('selected');
|
|
}
|
|
});
|
|
}
|
|
|
|
// -------------------------------------------------------------
|
|
// DASHBOARD RENDERING
|
|
// -------------------------------------------------------------
|
|
|
|
function renderLibrary() {
|
|
elements.recipesGrid.innerHTML = '';
|
|
|
|
const searchVal = elements.librarySearch.value.toLowerCase().trim();
|
|
const filtered = allRecipes.filter(recipe => {
|
|
return recipe.title.toLowerCase().includes(searchVal) ||
|
|
(recipe.description && recipe.description.toLowerCase().includes(searchVal)) ||
|
|
(recipe.ingredients && recipe.ingredients.some(i => i.toLowerCase().includes(searchVal)));
|
|
});
|
|
|
|
if (filtered.length === 0) {
|
|
elements.emptyState.style.display = 'flex';
|
|
return;
|
|
}
|
|
|
|
elements.emptyState.style.display = 'none';
|
|
|
|
filtered.forEach(recipe => {
|
|
const domain = recipe.sourceUrl ? new URL(recipe.sourceUrl).hostname.replace('www.', '') : 'Manual Recipe';
|
|
|
|
const card = document.createElement('div');
|
|
card.className = 'recipe-card';
|
|
card.addEventListener('click', () => showRecipeDetails(recipe));
|
|
|
|
let imgBlock = '';
|
|
if (recipe.image) {
|
|
imgBlock = `
|
|
<img src="${recipe.image}" class="recipe-card-img" alt="${recipe.title}" loading="lazy" onerror="this.style.display='none'; this.nextElementSibling.style.display='flex';">
|
|
<div class="recipe-card-img-placeholder" style="display: none;">
|
|
<i data-lucide="cooking-pot"></i>
|
|
<span>No dish photo</span>
|
|
</div>
|
|
`;
|
|
} else {
|
|
imgBlock = `
|
|
<div class="recipe-card-img-placeholder">
|
|
<i data-lucide="cooking-pot"></i>
|
|
<span>No dish photo</span>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
let creatorBadge = '';
|
|
if (activeTab === 'community-vault') {
|
|
const creator = recipe.createdBy || 'gourmet_system';
|
|
creatorBadge = `
|
|
<div class="recipe-card-creator" style="margin-top: 4px; margin-bottom: 8px;">
|
|
<i data-lucide="user"></i>
|
|
<span>@${creator}</span>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
card.innerHTML = `
|
|
<div class="recipe-card-img-wrapper">
|
|
${imgBlock}
|
|
</div>
|
|
<div class="recipe-card-content">
|
|
<span class="recipe-card-origin">${domain}</span>
|
|
<h3 class="recipe-card-title">${recipe.title}</h3>
|
|
${creatorBadge}
|
|
<p class="recipe-card-desc">${recipe.description || 'No description available for this recipe.'}</p>
|
|
|
|
<div class="recipe-card-meta">
|
|
${recipe.servings ? `<span><i data-lucide="users"></i> ${recipe.servings} Servings</span>` : ''}
|
|
${recipe.totalTime ? `<span><i data-lucide="clock"></i> ${recipe.totalTime}</span>` : ''}
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
elements.recipesGrid.appendChild(card);
|
|
});
|
|
|
|
lucide.createIcons();
|
|
}
|
|
|
|
function filterRecipes() {
|
|
renderLibrary();
|
|
}
|
|
|
|
// -------------------------------------------------------------
|
|
// DYNAMIC SERVINGS QUANTITY SCALER
|
|
// -------------------------------------------------------------
|
|
|
|
function scaleIngredientText(text, multiplier) {
|
|
if (multiplier === 1) return text;
|
|
|
|
// Mixed number regex: e.g. "1 1/2" or "2 1/4"
|
|
const mixedRegex = /^(\d+)\s+(\d+\/\d+)\s*(.*)/;
|
|
const mixedMatch = text.match(mixedRegex);
|
|
if (mixedMatch) {
|
|
const whole = parseInt(mixedMatch[1], 10);
|
|
const fracParts = mixedMatch[2].split('/');
|
|
const fracVal = parseInt(fracParts[0], 10) / parseInt(fracParts[1], 10);
|
|
const total = (whole + fracVal) * multiplier;
|
|
return `${formatScaledNumber(total)} ${mixedMatch[3]}`;
|
|
}
|
|
|
|
// Pure Fraction regex: e.g. "1/2" or "3/4"
|
|
const fracRegex = /^(\d+\/\d+)\s*(.*)/;
|
|
const fracMatch = text.match(fracRegex);
|
|
if (fracMatch) {
|
|
const parts = fracMatch[1].split('/');
|
|
const val = (parseInt(parts[0], 10) / parseInt(parts[1], 10)) * multiplier;
|
|
return `${formatScaledNumber(val)} ${fracMatch[2]}`;
|
|
}
|
|
|
|
// Decimals & Integers regex: e.g. "1.5" or "4"
|
|
const numRegex = /^(\d+(?:\.\d+)?)\s*(.*)/;
|
|
const numMatch = text.match(numRegex);
|
|
if (numMatch) {
|
|
const val = parseFloat(numMatch[1]) * multiplier;
|
|
return `${formatScaledNumber(val)} ${numMatch[2]}`;
|
|
}
|
|
|
|
return text;
|
|
}
|
|
|
|
function formatScaledNumber(num) {
|
|
if (num % 1 === 0) return num.toString();
|
|
|
|
const rounded = Math.round(num * 100) / 100;
|
|
const decimal = rounded % 1;
|
|
const integer = Math.floor(rounded);
|
|
|
|
// Common fraction mappings for nice reading
|
|
if (Math.abs(decimal - 0.25) < 0.05) return (integer > 0 ? integer + ' ' : '') + '1/4';
|
|
if (Math.abs(decimal - 0.5) < 0.05) return (integer > 0 ? integer + ' ' : '') + '1/2';
|
|
if (Math.abs(decimal - 0.75) < 0.05) return (integer > 0 ? integer + ' ' : '') + '3/4';
|
|
if (Math.abs(decimal - 0.33) < 0.05) return (integer > 0 ? integer + ' ' : '') + '1/3';
|
|
if (Math.abs(decimal - 0.66) < 0.05) return (integer > 0 ? integer + ' ' : '') + '2/3';
|
|
|
|
return rounded.toString();
|
|
}
|
|
|
|
function adjustMultiplier(change) {
|
|
const newMulti = currentMultiplier + change;
|
|
if (newMulti < 0.5 || newMulti > 10) return;
|
|
|
|
currentMultiplier = newMulti;
|
|
elements.currentServingsVal.textContent = `${currentMultiplier}x`;
|
|
renderScaledIngredients();
|
|
}
|
|
|
|
function renderScaledIngredients() {
|
|
elements.recipeDetailIngredients.innerHTML = '';
|
|
|
|
if (!activeRecipe || !activeRecipe.ingredients) return;
|
|
|
|
activeRecipe.ingredients.forEach((ing, index) => {
|
|
const scaledText = scaleIngredientText(ing, currentMultiplier);
|
|
|
|
const li = document.createElement('li');
|
|
li.className = 'ingredient-checkbox-item';
|
|
li.innerHTML = `
|
|
<div class="checkbox-custom"><i data-lucide="check" style="width:12px;height:12px;"></i></div>
|
|
<span class="ingredient-text">${scaledText}</span>
|
|
`;
|
|
|
|
li.addEventListener('click', () => {
|
|
li.classList.toggle('checked');
|
|
});
|
|
|
|
elements.recipeDetailIngredients.appendChild(li);
|
|
});
|
|
|
|
lucide.createIcons();
|
|
}
|
|
|
|
// -------------------------------------------------------------
|
|
// RECIPE DETAIL VIEWER COMPONENT
|
|
// -------------------------------------------------------------
|
|
|
|
function showRecipeDetails(recipe) {
|
|
activeRecipe = recipe;
|
|
currentMultiplier = 1;
|
|
elements.currentServingsVal.textContent = '1x';
|
|
if (shareBtnResetTimeout) {
|
|
clearTimeout(shareBtnResetTimeout);
|
|
shareBtnResetTimeout = null;
|
|
}
|
|
if (elements.btnDetailShare) {
|
|
elements.btnDetailShare.classList.remove('copied-feedback');
|
|
elements.btnDetailShare.innerHTML = `<i data-lucide="share-2"></i>`;
|
|
}
|
|
|
|
// Set hero cover banner image
|
|
if (recipe.image) {
|
|
elements.recipeDetailBanner.style.backgroundImage = `url('${recipe.image}')`;
|
|
} else {
|
|
elements.recipeDetailBanner.style.backgroundImage = 'none';
|
|
elements.recipeDetailBanner.style.backgroundColor = 'var(--bg-app)';
|
|
}
|
|
|
|
elements.recipeDetailTitle.textContent = recipe.title;
|
|
elements.recipeDetailDescription.textContent = recipe.description || 'No description available for this recipe.';
|
|
|
|
// Ownership and Permission controls
|
|
const isOwner = recipe.userId === currentUser.id;
|
|
const canEdit = isOwner || currentUser.isRecipeManager || currentUser.isAdmin;
|
|
if (canEdit) {
|
|
elements.btnDetailEdit.style.display = 'flex';
|
|
elements.btnDetailDelete.style.display = 'flex';
|
|
|
|
// Revoke share button toggle
|
|
if (recipe.isPublic && recipe.shareToken) {
|
|
elements.btnDetailRevokeShare.style.display = 'flex';
|
|
} else {
|
|
elements.btnDetailRevokeShare.style.display = 'none';
|
|
}
|
|
} else {
|
|
elements.btnDetailEdit.style.display = 'none';
|
|
elements.btnDetailDelete.style.display = 'none';
|
|
elements.btnDetailRevokeShare.style.display = 'none';
|
|
}
|
|
|
|
if (isOwner) {
|
|
elements.btnDetailClone.style.display = 'none';
|
|
elements.recipeDetailCreator.style.display = 'none';
|
|
} else {
|
|
elements.btnDetailClone.style.display = 'flex';
|
|
elements.recipeDetailCreator.style.display = 'inline-flex';
|
|
elements.recipeDetailCreatorName.textContent = `@${recipe.createdBy || 'gourmet_system'}`;
|
|
}
|
|
|
|
// Setup meta tags
|
|
if (recipe.servings) {
|
|
elements.tagServings.style.display = 'flex';
|
|
elements.tagServings.querySelector('.val').textContent = `${recipe.servings} Servings`;
|
|
} else {
|
|
elements.tagServings.style.display = 'none';
|
|
}
|
|
|
|
if (recipe.prepTime) {
|
|
elements.tagPrep.style.display = 'flex';
|
|
elements.tagPrep.querySelector('.val').textContent = recipe.prepTime;
|
|
} else {
|
|
elements.tagPrep.style.display = 'none';
|
|
}
|
|
|
|
if (recipe.cookTime) {
|
|
elements.tagCook.style.display = 'flex';
|
|
elements.tagCook.querySelector('.val').textContent = recipe.cookTime;
|
|
} else {
|
|
elements.tagCook.style.display = 'none';
|
|
}
|
|
|
|
if (recipe.sourceUrl) {
|
|
elements.tagSource.style.display = 'flex';
|
|
elements.tagSource.href = recipe.sourceUrl;
|
|
} else {
|
|
elements.tagSource.style.display = 'none';
|
|
}
|
|
|
|
// Public unique visitors count stats badge
|
|
if (recipe.isPublic && recipe.shareToken && recipe.visitorCount !== undefined) {
|
|
elements.tagShareStats.style.display = 'flex';
|
|
elements.tagShareStats.querySelector('.val').textContent = `${recipe.visitorCount} Unique ${recipe.visitorCount === 1 ? 'Visitor' : 'Visitors'}`;
|
|
} else {
|
|
elements.tagShareStats.style.display = 'none';
|
|
}
|
|
|
|
// Populate Ingredients with scaler
|
|
renderScaledIngredients();
|
|
|
|
// 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);
|
|
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';
|
|
}
|
|
|
|
// 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;
|
|
|
|
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();
|
|
|
|
// 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 = [];
|
|
|
|
// Reset tabs visual state
|
|
elements.tabMyCookbook.classList.add('active');
|
|
elements.tabCommunityVault.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';
|
|
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');
|
|
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');
|
|
elements.mobileTabProfile.classList.remove('active');
|
|
|
|
elements.recipesGrid.style.display = 'none';
|
|
elements.emptyState.style.display = 'none';
|
|
elements.userAdminPanel.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 === '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 === '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) {
|
|
if (subTabName === 'users') {
|
|
elements.btnAdminTabUsers.classList.add('btn-primary');
|
|
elements.btnAdminTabUsers.classList.remove('btn-outline');
|
|
elements.btnAdminTabShares.classList.add('btn-outline');
|
|
elements.btnAdminTabShares.classList.remove('btn-primary');
|
|
|
|
elements.adminUsersSection.style.display = 'block';
|
|
elements.adminSharesSection.style.display = 'none';
|
|
fetchAdminUsers();
|
|
} else if (subTabName === 'shares') {
|
|
elements.btnAdminTabShares.classList.add('btn-primary');
|
|
elements.btnAdminTabShares.classList.remove('btn-outline');
|
|
elements.btnAdminTabUsers.classList.add('btn-outline');
|
|
elements.btnAdminTabUsers.classList.remove('btn-primary');
|
|
|
|
elements.adminUsersSection.style.display = 'none';
|
|
elements.adminSharesSection.style.display = 'block';
|
|
fetchAdminSharedRecipes();
|
|
}
|
|
lucide.createIcons();
|
|
}
|
|
|
|
async function fetchAdminSharedRecipes() {
|
|
try {
|
|
const response = await fetch('/api/admin/shared-recipes', {
|
|
headers: getAuthHeaders()
|
|
});
|
|
|
|
if (response.status === 401) {
|
|
logout();
|
|
return;
|
|
}
|
|
|
|
if (!response.ok) throw new Error('Failed to load shared recipe records.');
|
|
|
|
const recipes = await response.json();
|
|
renderAdminSharedRecipes(recipes);
|
|
} catch (error) {
|
|
console.error('Error fetching admin shared recipes:', error);
|
|
}
|
|
}
|
|
|
|
function renderAdminSharedRecipes(recipes) {
|
|
elements.adminSharesList.innerHTML = '';
|
|
|
|
if (recipes.length === 0) {
|
|
const tr = document.createElement('tr');
|
|
tr.innerHTML = `
|
|
<td colspan="5" style="text-align: center; color: var(--text-muted); padding: 24px; font-style: italic;">
|
|
No recipes are currently shared publicly.
|
|
</td>
|
|
`;
|
|
elements.adminSharesList.appendChild(tr);
|
|
return;
|
|
}
|
|
|
|
recipes.forEach(recipe => {
|
|
const tr = document.createElement('tr');
|
|
|
|
const dateStr = new Date(recipe.createdAt).toLocaleDateString(undefined, {
|
|
year: 'numeric',
|
|
month: 'long',
|
|
day: 'numeric'
|
|
});
|
|
|
|
const shareLink = `${window.location.origin}/share.html?token=${recipe.shareToken}`;
|
|
|
|
const copyActionId = `btn-copy-share-${recipe.id}`;
|
|
const revokeActionId = `btn-revoke-share-${recipe.id}`;
|
|
|
|
tr.innerHTML = `
|
|
<td style="font-weight: 600; color: var(--text-main);">${recipe.title}</td>
|
|
<td style="font-weight: 500; color: var(--text-main);">@${recipe.createdBy}</td>
|
|
<td style="color: var(--text-muted); font-size: 0.9rem;">${dateStr}</td>
|
|
<td>
|
|
<span class="admin-role-badge" style="background: rgba(99, 102, 241, 0.12); border: 1px dashed rgba(99, 102, 241, 0.3); color: #6366f1; padding: 4px 8px; border-radius: 6px; display: inline-flex; align-items: center; gap: 4px;">
|
|
<i data-lucide="eye" style="width: 12px; height: 12px;"></i>
|
|
<span>${recipe.visitorCount} Unique ${recipe.visitorCount === 1 ? 'Visitor' : 'Visitors'}</span>
|
|
</span>
|
|
</td>
|
|
<td style="text-align: right;">
|
|
<button class="btn btn-outline" id="${copyActionId}" style="padding: 6px 12px; font-size: 0.8rem; border-radius: 6px; margin-right: 8px;">
|
|
<i data-lucide="copy" style="width: 12px; height: 12px;"></i> Copy Link
|
|
</button>
|
|
<button class="btn btn-danger" id="${revokeActionId}" style="padding: 6px 12px; font-size: 0.8rem; border-radius: 6px;">
|
|
<i data-lucide="link-2-off" style="width: 12px; height: 12px;"></i> Revoke
|
|
</button>
|
|
</td>
|
|
`;
|
|
|
|
elements.adminSharesList.appendChild(tr);
|
|
|
|
// Bind click events inside the row
|
|
const copyBtn = document.getElementById(copyActionId);
|
|
let rowCopyTimeout = null;
|
|
copyBtn.addEventListener('click', async () => {
|
|
try {
|
|
await copyTextToClipboard(shareLink);
|
|
if (rowCopyTimeout) clearTimeout(rowCopyTimeout);
|
|
copyBtn.innerHTML = `<i data-lucide="check" style="width: 12px; height: 12px;"></i> Copied!`;
|
|
copyBtn.style.borderColor = 'var(--accent)';
|
|
copyBtn.style.color = 'var(--accent)';
|
|
lucide.createIcons();
|
|
rowCopyTimeout = setTimeout(() => {
|
|
copyBtn.innerHTML = `<i data-lucide="copy" style="width: 12px; height: 12px;"></i> Copy Link`;
|
|
copyBtn.style.borderColor = '';
|
|
copyBtn.style.color = '';
|
|
lucide.createIcons();
|
|
}, 2000);
|
|
} catch (clipErr) {
|
|
prompt('Public Share Link:', shareLink);
|
|
}
|
|
});
|
|
|
|
document.getElementById(revokeActionId).addEventListener('click', () => {
|
|
revokeSharedRecipeFromList(recipe.id, recipe.title);
|
|
});
|
|
});
|
|
|
|
lucide.createIcons();
|
|
}
|
|
|
|
async function revokeSharedRecipeFromList(recipeId, recipeTitle) {
|
|
if (!confirm(`Are you sure you want to revoke the public share link for "${recipeTitle}" as an Administrator?\n\nThis will make the recipe private, disable the public link immediately, and clear all visitor history.`)) return;
|
|
|
|
try {
|
|
const response = await fetch(`/api/recipes/${recipeId}/revoke-share`, {
|
|
method: 'POST',
|
|
headers: getAuthHeaders()
|
|
});
|
|
|
|
if (response.status === 401) {
|
|
logout();
|
|
return;
|
|
}
|
|
|
|
if (!response.ok) {
|
|
const err = await response.json().catch(() => ({}));
|
|
throw new Error(err.error || 'Failed to revoke shared link.');
|
|
}
|
|
|
|
alert(`Successfully revoked the public share link for "${recipeTitle}".`);
|
|
fetchAdminSharedRecipes();
|
|
} catch (error) {
|
|
console.error('Error revoking recipe share from admin console:', error);
|
|
alert(`Error: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
async function handleAdminCreateUser(e) {
|
|
e.preventDefault();
|
|
elements.adminCreateError.style.display = 'none';
|
|
|
|
const username = elements.adminNewUsername.value.trim();
|
|
const password = elements.adminNewPassword.value;
|
|
|
|
try {
|
|
const response = await fetch('/api/auth/register', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
...getAuthHeaders()
|
|
},
|
|
body: JSON.stringify({ username, password })
|
|
});
|
|
|
|
if (response.status === 401) {
|
|
logout();
|
|
return;
|
|
}
|
|
|
|
if (!response.ok) {
|
|
const err = await response.json().catch(() => ({}));
|
|
throw new Error(err.error || 'Failed to create chef account.');
|
|
}
|
|
|
|
closeModal('admin-create-user-modal');
|
|
elements.adminCreateUserForm.reset();
|
|
fetchAdminUsers();
|
|
} catch (error) {
|
|
elements.adminCreateErrorText.textContent = error.message;
|
|
elements.adminCreateError.style.display = 'flex';
|
|
}
|
|
}
|
|
|
|
window.confirmDeleteUser = function(id, username) {
|
|
elements.deleteUserTargetId.value = id;
|
|
elements.deleteUserTargetName.textContent = `@${username}`;
|
|
elements.deleteUserPreserveRecipes.checked = true;
|
|
openModal('admin-delete-user-modal');
|
|
};
|
|
|
|
async function handleAdminDeleteUser() {
|
|
const id = elements.deleteUserTargetId.value;
|
|
const preserveRecipes = elements.deleteUserPreserveRecipes.checked;
|
|
|
|
try {
|
|
const response = await fetch(`/api/admin/users/${id}`, {
|
|
method: 'DELETE',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
...getAuthHeaders()
|
|
},
|
|
body: JSON.stringify({ preserveRecipes })
|
|
});
|
|
|
|
if (response.status === 401) {
|
|
logout();
|
|
return;
|
|
}
|
|
|
|
if (!response.ok) {
|
|
const err = await response.json().catch(() => ({}));
|
|
throw new Error(err.error || 'Failed to delete user.');
|
|
}
|
|
|
|
closeModal('admin-delete-user-modal');
|
|
fetchAdminUsers();
|
|
} catch (error) {
|
|
alert(`Error deleting user: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
window.toggleUserRole = async function(id, makeAdmin) {
|
|
try {
|
|
const response = await fetch(`/api/admin/users/${id}/role`, {
|
|
method: 'PUT',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
...getAuthHeaders()
|
|
},
|
|
body: JSON.stringify({ isAdmin: makeAdmin })
|
|
});
|
|
|
|
if (response.status === 401) {
|
|
logout();
|
|
return;
|
|
}
|
|
|
|
if (!response.ok) {
|
|
const err = await response.json().catch(() => ({}));
|
|
throw new Error(err.error || 'Failed to update user role.');
|
|
}
|
|
|
|
fetchAdminUsers();
|
|
} catch (error) {
|
|
alert(`Error updating role: ${error.message}`);
|
|
}
|
|
};
|
|
|
|
window.toggleRecipeManagerRole = async function(id, makeManager) {
|
|
try {
|
|
const response = await fetch(`/api/admin/users/${id}/recipe-manager`, {
|
|
method: 'PUT',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
...getAuthHeaders()
|
|
},
|
|
body: JSON.stringify({ isRecipeManager: makeManager })
|
|
});
|
|
|
|
if (response.status === 401) {
|
|
logout();
|
|
return;
|
|
}
|
|
|
|
if (!response.ok) {
|
|
const err = await response.json().catch(() => ({}));
|
|
throw new Error(err.error || 'Failed to update recipe manager permission.');
|
|
}
|
|
|
|
fetchAdminUsers();
|
|
} catch (error) {
|
|
alert(`Error updating permission: ${error.message}`);
|
|
}
|
|
};
|
|
|
|
// -------------------------------------------------------------
|
|
// SYSTEM SETTINGS & GEMINI AI PHOTO SCANNER
|
|
// -------------------------------------------------------------
|
|
|
|
async function openSettingsModal() {
|
|
// Reset visibility states and statuses
|
|
elements.settingsStatusMsg.style.display = 'none';
|
|
elements.settingsGeminiKeyInput.type = 'password';
|
|
const toggleIcon = elements.btnToggleGeminiKey.querySelector('i, svg');
|
|
if (toggleIcon) toggleIcon.setAttribute('data-lucide', 'eye');
|
|
lucide.createIcons();
|
|
|
|
elements.settingsGeminiKeyInput.value = '';
|
|
elements.settingsGeminiKeyInput.placeholder = 'Loading credentials...';
|
|
|
|
openModal('settings-modal');
|
|
|
|
try {
|
|
const response = await fetch('/api/settings', {
|
|
headers: getAuthHeaders()
|
|
});
|
|
if (response.status === 401) {
|
|
logout();
|
|
closeModal('settings-modal');
|
|
return;
|
|
}
|
|
if (!response.ok) throw new Error('Settings could not be loaded.');
|
|
const settings = await response.json();
|
|
|
|
const key = settings.geminiApiKey ? settings.geminiApiKey.trim() : '';
|
|
const isPlaceholder = key === 'PASTE_YOUR_GEMINI_API_KEY_HERE';
|
|
|
|
elements.settingsGeminiKeyInput.placeholder = 'Enter your Gemini API key...';
|
|
if (key && !isPlaceholder) {
|
|
elements.settingsGeminiKeyInput.value = key;
|
|
}
|
|
} 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();
|
|
|
|
try {
|
|
const response = await fetch('/api/settings', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
...getAuthHeaders()
|
|
},
|
|
body: JSON.stringify({ geminiApiKey: key })
|
|
});
|
|
|
|
if (response.status === 401) {
|
|
logout();
|
|
closeModal('settings-modal');
|
|
return;
|
|
}
|
|
|
|
if (!response.ok) throw new Error('Failed to save settings file.');
|
|
|
|
// Show success status banner
|
|
elements.settingsStatusMsg.style.display = 'flex';
|
|
setTimeout(() => {
|
|
elements.settingsStatusMsg.style.display = 'none';
|
|
}, 4000);
|
|
} catch (err) {
|
|
console.error('Error saving settings:', err);
|
|
alert(`Failed to save settings: ${err.message}`);
|
|
}
|
|
}
|
|
|
|
function toggleSettingsKeyVisibility() {
|
|
const input = elements.settingsGeminiKeyInput;
|
|
const toggleIcon = elements.btnToggleGeminiKey.querySelector('i, svg');
|
|
|
|
if (input.type === 'password') {
|
|
input.type = 'text';
|
|
if (toggleIcon) toggleIcon.setAttribute('data-lucide', 'eye-off');
|
|
} else {
|
|
input.type = 'password';
|
|
if (toggleIcon) toggleIcon.setAttribute('data-lucide', 'eye');
|
|
}
|
|
lucide.createIcons();
|
|
}
|
|
|
|
function showScanLoading() {
|
|
elements.loadingOverlay.classList.add('active');
|
|
|
|
const title = elements.loadingOverlay.querySelector('h3');
|
|
const subtitle = elements.loadingOverlay.querySelector('.loader-subtitle');
|
|
if (title) title.textContent = 'Scanning Recipe Photo';
|
|
if (subtitle) subtitle.textContent = 'Loading parameters and initializing scanner...';
|
|
|
|
elements.loaderStepFetch.className = 'loader-step active';
|
|
elements.loaderStepParse.className = 'loader-step';
|
|
elements.loaderStepImages.className = 'loader-step';
|
|
|
|
elements.loaderStepFetch.querySelector('span:last-child').textContent = 'Loading system configuration...';
|
|
elements.loaderStepParse.querySelector('span:last-child').textContent = 'Compressing and optimizing image...';
|
|
elements.loaderStepImages.querySelector('span:last-child').textContent = 'Digitizing with Gemini Vision AI...';
|
|
|
|
setLoaderStepIcon(elements.loaderStepFetch, 'circle-dashed', true);
|
|
setLoaderStepIcon(elements.loaderStepParse, 'circle', false);
|
|
setLoaderStepIcon(elements.loaderStepImages, 'circle', false);
|
|
lucide.createIcons();
|
|
}
|
|
|
|
function hideScanLoading() {
|
|
elements.loadingOverlay.classList.remove('active');
|
|
|
|
// Restore original loading screen texts
|
|
const title = elements.loadingOverlay.querySelector('h3');
|
|
const subtitle = elements.loadingOverlay.querySelector('.loader-subtitle');
|
|
if (title) title.textContent = 'Extracting Recipe Data';
|
|
if (subtitle) subtitle.textContent = 'Retrieving site contents and parsing culinary structures...';
|
|
|
|
elements.loaderStepFetch.querySelector('span:last-child').textContent = 'Connecting to source URL...';
|
|
elements.loaderStepParse.querySelector('span:last-child').textContent = 'Parsing schema structures...';
|
|
elements.loaderStepImages.querySelector('span:last-child').textContent = 'Extracting high-res dish photos...';
|
|
}
|
|
|
|
function compressImage(file, maxDimension = 1600) {
|
|
return new Promise((resolve, reject) => {
|
|
const reader = new FileReader();
|
|
reader.readAsDataURL(file);
|
|
reader.onload = (event) => {
|
|
const img = new Image();
|
|
img.src = event.target.result;
|
|
img.onload = () => {
|
|
let width = img.width;
|
|
let height = img.height;
|
|
|
|
// Downscale maintaining aspect ratio
|
|
if (width > maxDimension || height > maxDimension) {
|
|
if (width > height) {
|
|
height = Math.round((height * maxDimension) / width);
|
|
width = maxDimension;
|
|
} else {
|
|
width = Math.round((width * maxDimension) / height);
|
|
height = maxDimension;
|
|
}
|
|
}
|
|
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = width;
|
|
canvas.height = height;
|
|
const ctx = canvas.getContext('2d');
|
|
ctx.drawImage(img, 0, 0, width, height);
|
|
|
|
const dataUrl = canvas.toDataURL('image/jpeg', 0.85);
|
|
const base64 = dataUrl.split(',')[1];
|
|
resolve({
|
|
dataUrl,
|
|
base64,
|
|
mimeType: 'image/jpeg'
|
|
});
|
|
};
|
|
img.onerror = (err) => reject(err);
|
|
};
|
|
reader.onerror = (err) => reject(err);
|
|
});
|
|
}
|
|
|
|
async function handleRecipeImageScan(e) {
|
|
const file = e.target.files[0];
|
|
if (!file) return;
|
|
scanRecipeFile(file);
|
|
}
|
|
|
|
async function handleClipboardPaste(e) {
|
|
// Only trigger scan if logged in to prevent unauthenticated access attempts
|
|
if (!currentUser) return;
|
|
|
|
const items = (e.clipboardData || e.originalEvent?.clipboardData)?.items;
|
|
if (!items) return;
|
|
|
|
for (const item of items) {
|
|
if (item.kind === 'file' && item.type.startsWith('image/')) {
|
|
const file = item.getAsFile();
|
|
e.preventDefault(); // Intercept paste action
|
|
scanRecipeFile(file);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
async function scanRecipeFile(file) {
|
|
showScanLoading();
|
|
|
|
try {
|
|
// 1. Fetch system-wide configuration
|
|
const settingsResponse = await fetch('/api/settings', {
|
|
headers: getAuthHeaders()
|
|
});
|
|
if (settingsResponse.status === 401) {
|
|
logout();
|
|
hideScanLoading();
|
|
return;
|
|
}
|
|
if (!settingsResponse.ok) {
|
|
throw new Error('Could not fetch settings. Please ensure system settings are configured.');
|
|
}
|
|
const settings = await settingsResponse.json();
|
|
const apiKey = settings.geminiApiKey ? settings.geminiApiKey.trim() : '';
|
|
const isPlaceholder = apiKey === 'PASTE_YOUR_GEMINI_API_KEY_HERE' || apiKey === '';
|
|
|
|
if (!apiKey || isPlaceholder) {
|
|
hideScanLoading();
|
|
elements.scanImageInput.value = '';
|
|
alert('Google Gemini API Key is not configured.\n\nPlease click the Settings button at the top right, configure your Google Gemini API Key, and try again.');
|
|
return;
|
|
}
|
|
|
|
// Step 1 Completed -> Activate Step 2 (Compression)
|
|
elements.loaderStepFetch.className = 'loader-step completed';
|
|
setLoaderStepIcon(elements.loaderStepFetch, 'check-circle-2', false);
|
|
elements.loaderStepParse.className = 'loader-step active';
|
|
setLoaderStepIcon(elements.loaderStepParse, 'circle-dashed', true);
|
|
lucide.createIcons();
|
|
|
|
// 2. Compress image using Canvas
|
|
const compressed = await compressImage(file, 1600);
|
|
|
|
// Step 2 Completed -> Activate Step 3 (Gemini transcription)
|
|
elements.loaderStepParse.className = 'loader-step completed';
|
|
setLoaderStepIcon(elements.loaderStepParse, 'check-circle-2', false);
|
|
elements.loaderStepImages.className = 'loader-step active';
|
|
setLoaderStepIcon(elements.loaderStepImages, 'circle-dashed', true);
|
|
lucide.createIcons();
|
|
|
|
// 3. Make direct call to Google Gemini Vision API
|
|
const promptText = "You are a professional culinary transcriber. Analyze the image of a recipe (which could be a cookbook page, handwritten note, screenshot, or packaging) and extract it accurately. Fill in all fields as completely as possible. The response must be a JSON object matching this schema:\n{\n \"title\": \"Recipe Title\",\n \"description\": \"Brief description or review\",\n \"servings\": \"e.g., 4\",\n \"prepTime\": \"e.g., 15 mins\",\n \"cookTime\": \"e.g., 30 mins\",\n \"totalTime\": \"e.g., 45 mins\",\n \"ingredients\": [\"ingredient 1\", \"ingredient 2\"],\n \"instructions\": [\"step 1\", \"step 2\"]\n}\nDo not include any markdown styling (like ```json) or explanation. Return only the raw JSON string.";
|
|
|
|
const geminiUrl = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${apiKey}`;
|
|
|
|
const geminiResponse = await fetch(geminiUrl, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({
|
|
contents: [
|
|
{
|
|
parts: [
|
|
{ text: promptText },
|
|
{
|
|
inlineData: {
|
|
mimeType: compressed.mimeType,
|
|
data: compressed.base64
|
|
}
|
|
}
|
|
]
|
|
}
|
|
],
|
|
generationConfig: {
|
|
responseMimeType: "application/json"
|
|
}
|
|
})
|
|
});
|
|
|
|
if (!geminiResponse.ok) {
|
|
const errorObj = await geminiResponse.json().catch(() => ({}));
|
|
const status = geminiResponse.status;
|
|
let errMsg = 'Google Gemini API call failed.';
|
|
if (status === 400) {
|
|
errMsg = 'Invalid parameters or model version. Please verify your settings.';
|
|
} else if (status === 403) {
|
|
errMsg = 'Access denied. The Google Gemini API key is invalid or inactive.';
|
|
} else if (status === 429) {
|
|
errMsg = 'Rate limit exceeded. You have made too many requests or hit your Gemini quota.';
|
|
} else if (errorObj.error && errorObj.error.message) {
|
|
errMsg = `Gemini API Error: ${errorObj.error.message}`;
|
|
}
|
|
throw new Error(errMsg);
|
|
}
|
|
|
|
const result = await geminiResponse.json();
|
|
if (!result.candidates || result.candidates.length === 0 || !result.candidates[0].content) {
|
|
throw new Error('Gemini API returned an empty response. The text in the image may be unreadable.');
|
|
}
|
|
|
|
const rawText = result.candidates[0].content.parts[0].text;
|
|
const recipeData = JSON.parse(rawText);
|
|
|
|
// Auto-inject the scanned local picture as a custom visual preview option
|
|
recipeData.images = [compressed.dataUrl];
|
|
recipeData.image = compressed.dataUrl;
|
|
|
|
// 4. Finalize and trigger draft preview
|
|
hideScanLoading();
|
|
elements.scanImageInput.value = '';
|
|
openDraftPreview(recipeData);
|
|
|
|
} catch (error) {
|
|
console.error('Photo scanning error occurred:', error);
|
|
hideScanLoading();
|
|
elements.scanImageInput.value = '';
|
|
alert(`Scanning Failed: ${error.message}\n\nPlease verify your internet connection and check that your Gemini API Key is correct.`);
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------
|
|
// MOBILE PORTAL CONTROLS & GESTURES
|
|
// -------------------------------------------------------------
|
|
|
|
function openMobileProfile() {
|
|
if (!currentUser) return;
|
|
|
|
elements.mobileProfileUsername.textContent = `@${currentUser.username}`;
|
|
|
|
// Set role text
|
|
let roleText = 'Chef Account';
|
|
if (currentUser.isAdmin) {
|
|
roleText = 'Administrator';
|
|
} else if (currentUser.isRecipeManager) {
|
|
roleText = 'Recipe Manager';
|
|
}
|
|
elements.mobileProfileRole.textContent = roleText;
|
|
|
|
// Handle theme icon inside profile drawer
|
|
const isDark = document.body.classList.contains('dark-theme');
|
|
const sunIcon = elements.mobileBtnThemeToggle.querySelector('.sun-icon');
|
|
const moonIcon = elements.mobileBtnThemeToggle.querySelector('.moon-icon');
|
|
if (sunIcon && moonIcon) {
|
|
if (isDark) {
|
|
sunIcon.style.display = 'block';
|
|
moonIcon.style.display = 'none';
|
|
} else {
|
|
sunIcon.style.display = 'none';
|
|
moonIcon.style.display = 'block';
|
|
}
|
|
}
|
|
|
|
openModal('mobile-profile-modal');
|
|
}
|
|
|
|
function handleFabClick() {
|
|
// If not on cookbook tab, switch back to list first
|
|
if (activeTab !== 'my-cookbook') {
|
|
switchTab('my-cookbook');
|
|
}
|
|
// Open the manual recipe creator
|
|
openManualAdd();
|
|
}
|
|
|