// State management const state = { uploadedImage: null, detectedIngredients: [], isProcessing: false, geminiApiKey: "", skipApiKeyWarning: false, currentPhase: null }; // DOM elements const elements = { apiKeyInput: document.getElementById('apiKey'), uploadArea: document.getElementById('uploadArea'), fileInput: document.getElementById('fileInput'), previewSection: document.getElementById('previewSection'), previewImage: document.getElementById('previewImage'), resetButton: document.getElementById('resetButton'), scanButton: document.getElementById('scanButton'), scanButtonText: document.getElementById('scanButtonText'), heroSection: document.getElementById('heroSection'), resultsSection: document.getElementById('resultsSection'), ingredientsList: document.getElementById('ingredientsList'), recipesSection: document.getElementById('recipesSection'), recipesList: document.getElementById('recipesList'), darkModeToggle: document.getElementById('darkModeToggle') }; // Global abort controller let abortController = null; // Initialize app function init() { setupEventListeners(); loadStoredPreferences(); loadDarkModePreference(); } // Load stored API key and preferences function loadStoredPreferences() { const savedKey = localStorage.getItem('geminiApiKey'); const skipWarning = localStorage.getItem('skipApiKeyWarning') === 'true'; if (savedKey) { state.geminiApiKey = savedKey; elements.apiKeyInput.value = savedKey; } state.skipApiKeyWarning = skipWarning; } // load dark mode preference function loadDarkModePreference() { if (localStorage.getItem('darkMode') === 'true') { document.documentElement.classList.add('dark'); } } // setup event listeners function setupEventListeners() { // save API key elements.apiKeyInput.addEventListener('input', (e) => { state.geminiApiKey = e.target.value.trim(); localStorage.setItem('geminiApiKey', state.geminiApiKey); }); // Upload area, click triggers file input elements.uploadArea.addEventListener('click', () => elements.fileInput.click()); elements.uploadArea.addEventListener('dragover', e => { e.preventDefault(); elements.uploadArea.style.borderColor = 'var(--primary)'; }); elements.uploadArea.addEventListener('dragleave', () => { elements.uploadArea.style.borderColor = 'var(--border)'; }); elements.uploadArea.addEventListener('drop', e => { e.preventDefault(); elements.uploadArea.style.borderColor = 'var(--border)'; if (e.dataTransfer.files[0]) handleFileUpload(e.dataTransfer.files[0]); }); elements.fileInput.addEventListener('change', e => { if (e.target.files && e.target.files[0]) { handleFileUpload(e.target.files[0]); } }); // Reset button elements.resetButton.addEventListener('click', resetUpload); // Scan button elements.scanButton.addEventListener('click', handleScan); elements.darkModeToggle.addEventListener('click', () => { const isDark = document.documentElement.classList.toggle('dark'); localStorage.setItem('darkMode', isDark); }); } // File Upload + Preview function handleFileUpload(file) { if (!file.type.startsWith('image/')) return alert('Please upload an image.'); const reader = new FileReader(); reader.onload = e => { state.uploadedImage = e.target.result; elements.previewImage.src = e.target.result; elements.uploadArea.style.display = 'none'; elements.previewSection.style.display = 'block'; elements.scanButton.style.display = 'flex'; elements.heroSection.style.display = 'none'; }; reader.readAsDataURL(file); } // Reset upload function resetUpload() { if (abortController) abortController.abort(); abortController = null; state.uploadedImage = null; state.detectedIngredients = []; state.isProcessing = false; state.currentPhase = null; // Reset UI elements.uploadArea.style.display = 'block'; elements.previewSection.style.display = 'none'; elements.scanButton.style.display = 'none'; elements.resultsSection.style.display = 'none'; elements.heroSection.style.display = 'block'; elements.ingredientsList.innerHTML = ''; elements.recipesList.innerHTML = ''; updateScanButton(false); } // Handle missing API key warning async function handleMissingApiKeyWarning() { if (state.geminiApiKey || state.skipApiKeyWarning) return; const proceed = confirm( "Continue without Gemini API key?\n\n• Slower recipe generation\n• Lower quality possible\n\nProceed anyway?" ); if (!proceed) throw new Error("User cancelled"); if (confirm("Don't show this again?")) { state.skipApiKeyWarning = true; localStorage.setItem('skipApiKeyWarning', 'true'); } } // Scan button state function updateScanButton(isProcessing) { if (isProcessing) { elements.scanButton.classList.add("cancel-mode"); elements.scanButtonText.textContent = "Cancel"; } else { elements.scanButton.classList.remove("cancel-mode"); elements.scanButtonText.textContent = "Scan Ingredients"; } } // Scan button handler (detect ingredients → generate recipe) async function handleScan() { // Cancel mode if (state.isProcessing) { if (abortController) abortController.abort(); return; } await handleMissingApiKeyWarning(); // Create new abort controller abortController = new AbortController(); state.isProcessing = true; updateScanButton(true); elements.resultsSection.style.display = "block"; // Reset UI elements.ingredientsList.innerHTML = ""; elements.recipesList.innerHTML = ""; elements.recipesSection.style.display = "none"; try { const blob = await (await fetch(state.uploadedImage)).blob(); // Detect ingredients const detectForm = new FormData(); detectForm.append("file", new File([blob], "fridge.jpg", { type: blob.type })); const detectResponse = await fetch("/detect-ingredients/", { method: "POST", body: detectForm, signal: abortController.signal }); if (!detectResponse.ok) { if (abortController.signal.aborted || detectResponse.status === 499) throw new Error("cancelled"); throw new Error("Detection failed"); } const { ingredients } = await detectResponse.json(); state.detectedIngredients = ingredients; displayIngredients(ingredients); // Generate recipe const card = document.createElement("div"); card.className = "recipe-card"; card.innerHTML = `

AI-Generated Recipe

Chef is thinking...

This may take up to 2 minutes without your Gemini API key.

`; elements.recipesList.appendChild(card); elements.recipesSection.style.display = "block"; const recipeForm = new FormData(); recipeForm.append("ingredients", ingredients.map(i => i.name).join(", ")); recipeForm.append("api_key", state.geminiApiKey); const recipeResponse = await fetch("/generate-recipe/", { method: "POST", body: recipeForm, signal: abortController.signal }); if (!recipeResponse.ok) { if (abortController.signal.aborted || recipeResponse.status === 499) throw new Error("cancelled"); throw new Error("Recipe generation failed"); } const { recipe } = await recipeResponse.json(); card.innerHTML = `

AI-Generated Recipe

${marked.parse(recipe)}
`; elements.recipesSection.style.display = "block"; } // Error handling catch (err) { if (err.message === "cancelled" || abortController.signal?.aborted) { elements.recipesList.innerHTML = `

Operation cancelled.

`; } else { console.error(err); elements.recipesList.innerHTML = `

Error

Something went wrong. Try again or add a Gemini API key.

`; } } // Finally cleanup finally { state.isProcessing = false; state.currentPhase = null; abortController = null; updateScanButton(false); } } // Display detected ingredients with confidence bars function displayIngredients(ingredients) { elements.ingredientsList.innerHTML = ''; ingredients.forEach((ing, i) => { const conf = Math.round((ing.confidence || 0.7) * 100); const color = conf >= 70 ? "#2ecc71" : conf >= 40 ? "#f1c40f" : "#e74c3c"; const item = document.createElement('div'); item.className = 'ingredient-item'; item.style.animation = `fadeIn 1s ease-out ${i * 0.1}s forwards`; item.innerHTML = `
${ing.name} ${conf}% confidence
`; elements.ingredientsList.appendChild(item); setTimeout(() => item.querySelector('.confidence-fill').style.width = `${conf}%`, 100); }); } // Animation document.head.insertAdjacentHTML('beforeend', ` `); // Start app document.readyState === 'loading' ? document.addEventListener('DOMContentLoaded', init) : init();