Spaces:
Running
Running
Update static/scripts.js
Browse files- static/scripts.js +37 -44
static/scripts.js
CHANGED
|
@@ -26,7 +26,8 @@ const elements = {
|
|
| 26 |
};
|
| 27 |
|
| 28 |
// Global abort controller
|
| 29 |
-
let abortController = null;
|
|
|
|
| 30 |
|
| 31 |
// Initialize app
|
| 32 |
function init() {
|
|
@@ -105,20 +106,23 @@ function handleFileUpload(file) {
|
|
| 105 |
|
| 106 |
// Reset upload
|
| 107 |
function resetUpload() {
|
| 108 |
-
state.
|
| 109 |
-
state
|
|
|
|
| 110 |
elements.fileInput.value = '';
|
| 111 |
elements.uploadArea.style.display = 'block';
|
| 112 |
elements.previewSection.style.display = 'none';
|
| 113 |
elements.scanButton.style.display = 'none';
|
| 114 |
elements.resultsSection.style.display = 'none';
|
| 115 |
elements.heroSection.style.display = 'block';
|
|
|
|
|
|
|
| 116 |
}
|
| 117 |
|
| 118 |
async function handleMissingApiKeyWarning() {
|
| 119 |
if (state.geminiApiKey || state.skipApiKeyWarning) return;
|
| 120 |
const proceed = confirm("Continue without Gemini API key?\n\n• Slower recipe generation\n• Lower quality possible\n\nProceed anyway?");
|
| 121 |
-
if (!proceed) throw new Error("
|
| 122 |
if (confirm("Don't show this again?")) {
|
| 123 |
state.skipApiKeyWarning = true;
|
| 124 |
localStorage.setItem('skipApiKeyWarning', 'true');
|
|
@@ -129,21 +133,21 @@ function updateScanButton(isProcessing) {
|
|
| 129 |
elements.scanButton.disabled = false;
|
| 130 |
|
| 131 |
if (isProcessing) {
|
| 132 |
-
elements.scanButton.classList.add("
|
| 133 |
elements.scanButtonText.textContent = "Cancel";
|
| 134 |
} else {
|
| 135 |
-
elements.scanButton.classList.remove("
|
| 136 |
elements.scanButtonText.textContent = "Scan Ingredients";
|
| 137 |
}
|
| 138 |
}
|
| 139 |
|
| 140 |
// Image Scan and Backend Processing
|
| 141 |
async function handleScan() {
|
| 142 |
-
// If already running →
|
| 143 |
if (state.isProcessing) {
|
| 144 |
if (abortController) {
|
| 145 |
abortController.abort();
|
| 146 |
-
console.log("
|
| 147 |
}
|
| 148 |
return;
|
| 149 |
}
|
|
@@ -153,10 +157,7 @@ async function handleScan() {
|
|
| 153 |
// Reset previous controller
|
| 154 |
abortController = new AbortController();
|
| 155 |
state.isProcessing = true;
|
| 156 |
-
|
| 157 |
-
// Show Cancel
|
| 158 |
-
elements.scanButtonText.textContent = "Cancel";
|
| 159 |
-
elements.scanButton.classList.add("cancel-mode");
|
| 160 |
|
| 161 |
// Reset results
|
| 162 |
elements.ingredientsList.innerHTML = "";
|
|
@@ -168,7 +169,7 @@ async function handleScan() {
|
|
| 168 |
|
| 169 |
// Detect ingredients
|
| 170 |
const detectForm = new FormData();
|
| 171 |
-
detectForm.append("file", new File([blob], "
|
| 172 |
|
| 173 |
const detectResponse = await fetch("/detect-ingredients/", {
|
| 174 |
method: "POST",
|
|
@@ -177,67 +178,61 @@ async function handleScan() {
|
|
| 177 |
});
|
| 178 |
|
| 179 |
if (!detectResponse.ok) {
|
| 180 |
-
|
| 181 |
-
throw new Error("
|
|
|
|
| 182 |
}
|
| 183 |
|
| 184 |
const { ingredients } = await detectResponse.json();
|
| 185 |
-
state.detectedIngredients = ingredients.map(i => ({
|
| 186 |
-
name: i.name,
|
| 187 |
-
confidence: i.confidence
|
| 188 |
-
}));
|
| 189 |
-
|
| 190 |
displayIngredients(state.detectedIngredients);
|
| 191 |
|
| 192 |
-
//
|
| 193 |
const card = document.createElement("div");
|
| 194 |
card.className = "recipe-card";
|
| 195 |
card.innerHTML = `<div class="recipe-header"><h4>AI-Generated Recipe</h4></div>
|
| 196 |
-
<div class="recipe-section"><p style="text-align:center;padding:
|
| 197 |
-
<em>Chef is
|
| 198 |
</p></div>`;
|
| 199 |
elements.recipesList.appendChild(card);
|
| 200 |
elements.recipesSection.style.display = "block";
|
| 201 |
|
| 202 |
-
// 2. Generate recipe
|
| 203 |
const recipeForm = new FormData();
|
| 204 |
recipeForm.append("ingredients", state.detectedIngredients.map(i => i.name).join(", "));
|
| 205 |
recipeForm.append("api_key", state.geminiApiKey.trim());
|
| 206 |
|
| 207 |
-
const
|
| 208 |
method: "POST",
|
| 209 |
body: recipeForm,
|
| 210 |
signal: abortController.signal
|
| 211 |
});
|
| 212 |
|
| 213 |
-
if (!
|
| 214 |
-
|
| 215 |
-
throw new Error("
|
|
|
|
| 216 |
}
|
| 217 |
|
| 218 |
-
const { recipe } = await
|
| 219 |
card.innerHTML = `<div class="recipe-header"><h4>AI-Generated Recipe</h4></div>
|
| 220 |
<div class="recipe-section"><div class="recipe-markdown">${marked.parse(recipe)}</div></div>`;
|
| 221 |
|
| 222 |
} catch (err) {
|
| 223 |
if (err.message === "cancelled" || abortController.signal.aborted) {
|
| 224 |
-
elements.recipesList.innerHTML = `<div class="recipe-card" style="text-align:center;padding:2rem">
|
| 225 |
-
<p>
|
| 226 |
-
<button onclick="handleScan()" class="scan-button">
|
| 227 |
-
<span id="scanButtonText">Try Again</span>
|
| 228 |
-
</button>
|
| 229 |
</div>`;
|
| 230 |
} else {
|
| 231 |
console.error(err);
|
| 232 |
elements.recipesList.innerHTML = `<div class="recipe-card" style="color:var(--error);text-align:center;padding:2rem">
|
| 233 |
-
<h4>Error</h4><p>Try again or add Gemini API key</p>
|
| 234 |
</div>`;
|
| 235 |
}
|
| 236 |
} finally {
|
| 237 |
state.isProcessing = false;
|
| 238 |
abortController = null;
|
| 239 |
-
|
| 240 |
-
elements.scanButton.classList.remove("cancel-mode");
|
| 241 |
}
|
| 242 |
}
|
| 243 |
|
|
@@ -246,12 +241,12 @@ async function handleScan() {
|
|
| 246 |
function displayIngredients(ingredients) {
|
| 247 |
elements.ingredientsList.innerHTML = '';
|
| 248 |
ingredients.forEach((ing, i) => {
|
| 249 |
-
const conf = Math.round(ing.confidence * 100);
|
| 250 |
const color = conf >= 70 ? "#2ecc71" : conf >= 40 ? "#f1c40f" : "#e74c3c";
|
| 251 |
|
| 252 |
const item = document.createElement('div');
|
| 253 |
item.className = 'ingredient-item';
|
| 254 |
-
item.style.animation = `fadeIn
|
| 255 |
item.innerHTML = `
|
| 256 |
<div class="ingredient-header">
|
| 257 |
<span class="ingredient-name">${ing.name}</span>
|
|
@@ -260,13 +255,10 @@ function displayIngredients(ingredients) {
|
|
| 260 |
</span>
|
| 261 |
</div>
|
| 262 |
<div class="confidence-bar">
|
| 263 |
-
<div class="confidence-fill" style="background:${color};width:0%
|
| 264 |
</div>`;
|
| 265 |
elements.ingredientsList.appendChild(item);
|
| 266 |
-
|
| 267 |
-
setTimeout(() => {
|
| 268 |
-
item.querySelector('.confidence-fill').style.width = `${conf}%`;
|
| 269 |
-
}, 100);
|
| 270 |
});
|
| 271 |
}
|
| 272 |
|
|
@@ -275,6 +267,7 @@ document.head.insertAdjacentHTML('beforeend', `
|
|
| 275 |
<style>
|
| 276 |
@keyframes fadeIn{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}
|
| 277 |
.ingredient-item{opacity:0}
|
|
|
|
| 278 |
</style>`);
|
| 279 |
|
| 280 |
// Start app
|
|
|
|
| 26 |
};
|
| 27 |
|
| 28 |
// Global abort controller
|
| 29 |
+
let abortController = null;
|
| 30 |
+
|
| 31 |
|
| 32 |
// Initialize app
|
| 33 |
function init() {
|
|
|
|
| 106 |
|
| 107 |
// Reset upload
|
| 108 |
function resetUpload() {
|
| 109 |
+
if (state.isProcessing && abortController) abortController.abort();
|
| 110 |
+
state = { uploadedImage: null, detectedIngredients: [], isProcessing: false, geminiApiKey: state.geminiApiKey, skipApiKeyWarning: state.skipApiKeyWarning };
|
| 111 |
+
abortController = null;
|
| 112 |
elements.fileInput.value = '';
|
| 113 |
elements.uploadArea.style.display = 'block';
|
| 114 |
elements.previewSection.style.display = 'none';
|
| 115 |
elements.scanButton.style.display = 'none';
|
| 116 |
elements.resultsSection.style.display = 'none';
|
| 117 |
elements.heroSection.style.display = 'block';
|
| 118 |
+
elements.scanButtonText.textContent = "Scan Ingredients";
|
| 119 |
+
elements.scanButton.classList.remove("cancel-mode");
|
| 120 |
}
|
| 121 |
|
| 122 |
async function handleMissingApiKeyWarning() {
|
| 123 |
if (state.geminiApiKey || state.skipApiKeyWarning) return;
|
| 124 |
const proceed = confirm("Continue without Gemini API key?\n\n• Slower recipe generation\n• Lower quality possible\n\nProceed anyway?");
|
| 125 |
+
if (!proceed) throw new Error("User cancelled");
|
| 126 |
if (confirm("Don't show this again?")) {
|
| 127 |
state.skipApiKeyWarning = true;
|
| 128 |
localStorage.setItem('skipApiKeyWarning', 'true');
|
|
|
|
| 133 |
elements.scanButton.disabled = false;
|
| 134 |
|
| 135 |
if (isProcessing) {
|
| 136 |
+
elements.scanButton.classList.add("cancel-mode");
|
| 137 |
elements.scanButtonText.textContent = "Cancel";
|
| 138 |
} else {
|
| 139 |
+
elements.scanButton.classList.remove("cancel-mode");
|
| 140 |
elements.scanButtonText.textContent = "Scan Ingredients";
|
| 141 |
}
|
| 142 |
}
|
| 143 |
|
| 144 |
// Image Scan and Backend Processing
|
| 145 |
async function handleScan() {
|
| 146 |
+
// If already running → display as Cancel button
|
| 147 |
if (state.isProcessing) {
|
| 148 |
if (abortController) {
|
| 149 |
abortController.abort();
|
| 150 |
+
console.log("Scan cancelled by user");
|
| 151 |
}
|
| 152 |
return;
|
| 153 |
}
|
|
|
|
| 157 |
// Reset previous controller
|
| 158 |
abortController = new AbortController();
|
| 159 |
state.isProcessing = true;
|
| 160 |
+
updateScanButton(true);
|
|
|
|
|
|
|
|
|
|
| 161 |
|
| 162 |
// Reset results
|
| 163 |
elements.ingredientsList.innerHTML = "";
|
|
|
|
| 169 |
|
| 170 |
// Detect ingredients
|
| 171 |
const detectForm = new FormData();
|
| 172 |
+
detectForm.append("file", new File([blob], "fridge.jpg", { type: blob.type }));
|
| 173 |
|
| 174 |
const detectResponse = await fetch("/detect-ingredients/", {
|
| 175 |
method: "POST",
|
|
|
|
| 178 |
});
|
| 179 |
|
| 180 |
if (!detectResponse.ok) {
|
| 181 |
+
const text = await detectResponse.text();
|
| 182 |
+
if (abortController.signal.aborted || detectResponse.status === 499) throw new Error("cancelled");
|
| 183 |
+
throw new Error(text || "Detection failed");
|
| 184 |
}
|
| 185 |
|
| 186 |
const { ingredients } = await detectResponse.json();
|
| 187 |
+
state.detectedIngredients = ingredients.map(i => ({ name: i.name, confidence: i.confidence }));
|
|
|
|
|
|
|
|
|
|
|
|
|
| 188 |
displayIngredients(state.detectedIngredients);
|
| 189 |
|
| 190 |
+
// Generate recipe
|
| 191 |
const card = document.createElement("div");
|
| 192 |
card.className = "recipe-card";
|
| 193 |
card.innerHTML = `<div class="recipe-header"><h4>AI-Generated Recipe</h4></div>
|
| 194 |
+
<div class="recipe-section"><p style="text-align:center;padding:3rem">
|
| 195 |
+
<em>Chef is thinking...</em><br><br>This can take up to 60s without a Gemini API key
|
| 196 |
</p></div>`;
|
| 197 |
elements.recipesList.appendChild(card);
|
| 198 |
elements.recipesSection.style.display = "block";
|
| 199 |
|
|
|
|
| 200 |
const recipeForm = new FormData();
|
| 201 |
recipeForm.append("ingredients", state.detectedIngredients.map(i => i.name).join(", "));
|
| 202 |
recipeForm.append("api_key", state.geminiApiKey.trim());
|
| 203 |
|
| 204 |
+
const recipeResponse = await fetch("/generate-recipe/", {
|
| 205 |
method: "POST",
|
| 206 |
body: recipeForm,
|
| 207 |
signal: abortController.signal
|
| 208 |
});
|
| 209 |
|
| 210 |
+
if (!recipeResponse.ok) {
|
| 211 |
+
const text = await recipeResponse.text();
|
| 212 |
+
if (abortController.signal.aborted || recipeResponse.status === 499) throw new Error("cancelled");
|
| 213 |
+
throw new Error(text || "Recipe generation failed");
|
| 214 |
}
|
| 215 |
|
| 216 |
+
const { recipe } = await recipeResponse.json();
|
| 217 |
card.innerHTML = `<div class="recipe-header"><h4>AI-Generated Recipe</h4></div>
|
| 218 |
<div class="recipe-section"><div class="recipe-markdown">${marked.parse(recipe)}</div></div>`;
|
| 219 |
|
| 220 |
} catch (err) {
|
| 221 |
if (err.message === "cancelled" || abortController.signal.aborted) {
|
| 222 |
+
elements.recipesList.innerHTML = `<div class="recipe-card" style="text-align:center;padding:2rem;color:var(--text-secondary)">
|
| 223 |
+
<p>Operation cancelled.</p>
|
| 224 |
+
<button onclick="handleScan()" class="scan-button small">Try Again</button>
|
|
|
|
|
|
|
| 225 |
</div>`;
|
| 226 |
} else {
|
| 227 |
console.error(err);
|
| 228 |
elements.recipesList.innerHTML = `<div class="recipe-card" style="color:var(--error);text-align:center;padding:2rem">
|
| 229 |
+
<h4>Error</h4><p>Something went wrong. Try again or add a Gemini API key for better results.</p>
|
| 230 |
</div>`;
|
| 231 |
}
|
| 232 |
} finally {
|
| 233 |
state.isProcessing = false;
|
| 234 |
abortController = null;
|
| 235 |
+
updateScanButton(false);
|
|
|
|
| 236 |
}
|
| 237 |
}
|
| 238 |
|
|
|
|
| 241 |
function displayIngredients(ingredients) {
|
| 242 |
elements.ingredientsList.innerHTML = '';
|
| 243 |
ingredients.forEach((ing, i) => {
|
| 244 |
+
const conf = Math.round((ing.confidence || 0.7) * 100);
|
| 245 |
const color = conf >= 70 ? "#2ecc71" : conf >= 40 ? "#f1c40f" : "#e74c3c";
|
| 246 |
|
| 247 |
const item = document.createElement('div');
|
| 248 |
item.className = 'ingredient-item';
|
| 249 |
+
item.style.animation = `fadeIn 2s ease-out ${i * 0.1}s forwards`;
|
| 250 |
item.innerHTML = `
|
| 251 |
<div class="ingredient-header">
|
| 252 |
<span class="ingredient-name">${ing.name}</span>
|
|
|
|
| 255 |
</span>
|
| 256 |
</div>
|
| 257 |
<div class="confidence-bar">
|
| 258 |
+
<div class="confidence-fill" style="background:${color};width:0%"></div>
|
| 259 |
</div>`;
|
| 260 |
elements.ingredientsList.appendChild(item);
|
| 261 |
+
setTimeout(() => item.querySelector('.confidence-fill').style.width = `${conf}%`, 100);
|
|
|
|
|
|
|
|
|
|
| 262 |
});
|
| 263 |
}
|
| 264 |
|
|
|
|
| 267 |
<style>
|
| 268 |
@keyframes fadeIn{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}
|
| 269 |
.ingredient-item{opacity:0}
|
| 270 |
+
.scan-button.small{padding:0.5rem 1rem;font-size:0.9rem}
|
| 271 |
</style>`);
|
| 272 |
|
| 273 |
// Start app
|