Wills17 commited on
Commit
7c835d2
·
verified ·
1 Parent(s): e65eb2a

Update static/scripts.js

Browse files
Files changed (1) hide show
  1. static/scripts.js +110 -287
static/scripts.js CHANGED
@@ -1,14 +1,13 @@
1
- // State management
 
2
  const state = {
3
  uploadedImage: null,
4
  detectedIngredients: [],
5
- recipes: [],
6
  isProcessing: false,
7
  geminiApiKey: "",
8
  skipApiKeyWarning: false
9
  };
10
 
11
- // DOM elements
12
  const elements = {
13
  apiKeyInput: document.getElementById('apiKey'),
14
  uploadArea: document.getElementById('uploadArea'),
@@ -26,115 +25,71 @@ const elements = {
26
  darkModeToggle: document.getElementById('darkModeToggle')
27
  };
28
 
29
- // Initialize app
30
  function init() {
31
  setupEventListeners();
32
  loadStoredPreferences();
33
  loadDarkModePreference();
34
  }
35
 
36
- // Load stored API key and preferences
37
  function loadStoredPreferences() {
38
  const savedKey = localStorage.getItem('geminiApiKey');
39
  const skipWarning = localStorage.getItem('skipApiKeyWarning') === 'true';
40
-
41
  if (savedKey) {
42
  state.geminiApiKey = savedKey;
43
  elements.apiKeyInput.value = savedKey;
44
  }
45
-
46
  state.skipApiKeyWarning = skipWarning;
47
  }
48
 
49
- // load dark mode preference
50
  function loadDarkModePreference() {
51
  if (localStorage.getItem('darkMode') === 'true') {
52
  document.documentElement.classList.add('dark');
53
  }
54
  }
55
 
56
- // setup event listeners
57
  function setupEventListeners() {
58
-
59
- // API key field
60
  elements.apiKeyInput.addEventListener('input', (e) => {
61
- const key = e.target.value.trim();
62
- state.geminiApiKey = key;
63
- localStorage.setItem('geminiApiKey', key);
64
  });
65
 
66
- // Upload area click triggers file input
67
  elements.uploadArea.addEventListener('click', () => elements.fileInput.click());
68
-
69
- // Drag & drop support
70
- elements.uploadArea.addEventListener('dragover', (e) => {
71
- e.preventDefault();
72
- elements.uploadArea.style.borderColor = 'var(--primary)';
73
- });
74
-
75
- elements.uploadArea.addEventListener('dragleave', () => {
76
- elements.uploadArea.style.borderColor = 'var(--border)';
77
- });
78
-
79
- elements.uploadArea.addEventListener('drop', (e) => {
80
  e.preventDefault();
81
  elements.uploadArea.style.borderColor = 'var(--border)';
82
- if (e.dataTransfer.files.length > 0) {
83
- handleFileUpload(e.dataTransfer.files[0]);
84
- }
85
  });
86
-
87
- // File input change
88
- elements.fileInput.addEventListener('change', (e) => {
89
- if (e.target.files.length > 0) {
90
- handleFileUpload(e.target.files[0]);
91
- }
92
  });
93
 
94
- // Reset
95
  elements.resetButton.addEventListener('click', resetUpload);
96
-
97
- // Scan button
98
  elements.scanButton.addEventListener('click', handleScan);
99
-
100
- // Dark mode toggle
101
  elements.darkModeToggle.addEventListener('click', () => {
102
  const isDark = document.documentElement.classList.toggle('dark');
103
  localStorage.setItem('darkMode', isDark);
104
  });
105
  }
106
 
107
-
108
- // File Upload + Preview
109
  function handleFileUpload(file) {
110
- if (!file.type.startsWith('image/')) {
111
- alert('Please upload a valid image file.');
112
- return;
113
- }
114
-
115
  const reader = new FileReader();
116
- reader.onload = (e) => {
117
  state.uploadedImage = e.target.result;
118
- showImagePreview(e.target.result);
 
 
 
 
119
  };
120
  reader.readAsDataURL(file);
121
  }
122
 
123
- function showImagePreview(imageUrl) {
124
- elements.previewImage.src = imageUrl;
125
-
126
- elements.uploadArea.style.display = 'none';
127
- elements.previewSection.style.display = 'block';
128
- elements.scanButton.style.display = 'flex';
129
- elements.heroSection.style.display = 'none';
130
- }
131
-
132
- // Reset upload
133
  function resetUpload() {
134
  state.uploadedImage = null;
135
  state.detectedIngredients = [];
136
- state.recipes = [];
137
-
138
  elements.fileInput.value = '';
139
  elements.uploadArea.style.display = 'block';
140
  elements.previewSection.style.display = 'none';
@@ -143,274 +98,142 @@ function resetUpload() {
143
  elements.heroSection.style.display = 'block';
144
  }
145
 
 
 
 
 
 
 
 
 
 
146
 
147
- // Image Scan and Backend Processing
 
 
 
 
 
148
  async function handleScan() {
149
- if (!state.uploadedImage) {
150
- alert('Please upload an image first.');
151
- return;
152
- }
153
 
154
  await handleMissingApiKeyWarning();
155
 
156
  state.isProcessing = true;
157
  updateScanButton(true);
158
 
159
- try {
160
- const formData = new FormData();
 
 
161
 
162
- // Convert Base64 → Blob → File
 
163
  const blob = await (await fetch(state.uploadedImage)).blob();
164
- formData.append("file", new File([blob], "upload.jpg", { type: blob.type }));
165
- formData.append("api_key", (state.geminiApiKey || "").trim());
166
 
167
- const response = await fetch("/upload-image/", { method: "POST", body: formData });
 
 
 
168
 
169
- if (!response.ok) throw new Error("Backend error: " + response.status);
170
 
171
- const data = await response.json();
172
 
173
- // Convert backend output to frontend format
174
- state.detectedIngredients = (data.ingredients || []).map(item => ({
175
  name: item.name,
176
  confidence: item.confidence
177
  }));
178
 
179
- state.recipes = [{
180
- name: "AI-Generated Recipe",
181
- ingredients: (data.ingredients || []).map(i => i.name),
182
- steps: [data.recipe]
183
- }];
184
-
185
-
186
  displayIngredients(state.detectedIngredients);
187
- displayRecipes(state.recipes);
188
- elements.resultsSection.style.display = 'block';
189
 
190
- } catch (err) {
191
- console.error(err);
192
- alert("Something went wrong while processing the image.");
193
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
194
 
195
- state.isProcessing = false;
196
- updateScanButton(false);
197
- }
198
 
 
199
 
200
- // Missing API Key Warning
201
- async function handleMissingApiKeyWarning() {
202
- if (state.geminiApiKey || state.skipApiKeyWarning) return;
203
-
204
- const proceed = confirm(
205
- "⚠️ Continue without a Gemini API key?\n\n" +
206
- "• Recipe quality may be downgraded\n" +
207
- "• AI creativity reduced\n\n" +
208
- "Proceed anyway?"
209
- );
210
-
211
- if (!proceed) throw new Error("User cancelled scan.");
212
 
213
- const dontShowAgain = confirm("Skip this warning next time?");
214
- if (dontShowAgain) {
215
- state.skipApiKeyWarning = true;
216
- localStorage.setItem('skipApiKeyWarning', 'true');
 
 
 
 
 
217
  }
218
  }
219
 
220
- // UI Helper: Scan Button State
221
- function updateScanButton(isLoading) {
222
- elements.scanButton.disabled = isLoading;
223
- elements.scanButtonText.textContent = isLoading ? "Processing..." : "Scan Ingredients";
224
- }
225
-
226
 
227
- // Rendering Ingredients UI
228
  function displayIngredients(ingredients) {
229
  elements.ingredientsList.innerHTML = '';
 
 
 
230
 
231
- ingredients.forEach((ingredient, index) => {
232
  const item = document.createElement('div');
233
  item.className = 'ingredient-item';
234
- item.style.animation = `fadeIn 1s ease-out ${index * 0.08}s forwards`;
235
-
236
- // Make sure confidence is numerical
237
- const confidence = Number(ingredient.confidence);
238
-
239
- // Determine bar & badge color
240
- let barColor;
241
- if (confidence >= 0.7) barColor = "#2ecc71"; // green
242
- else if (confidence >= 0.4) barColor = "#f1c40f"; // yellow
243
- else barColor = "#e74c3c"; // red
244
-
245
  item.innerHTML = `
246
  <div class="ingredient-header">
247
- <div class="ingredient-info">
248
- <span class="ingredient-name">${ingredient.name}</span>
249
- <span class="confidence-badge"
250
- style="
251
- background: ${barColor};
252
- color: ${confidence >= 0.4 ? '#000' : '#fff'};
253
- ">
254
- ${Math.round(confidence * 100)}% confidence
255
- </span>
256
- </div>
257
  </div>
258
-
259
  <div class="confidence-bar">
260
- <div class="confidence-fill"
261
- style="
262
- width: 0%;
263
- background: ${barColor};
264
- transition: width 0.9s ease-out;
265
- ">
266
- </div>
267
- </div>
268
- `;
269
-
270
  elements.ingredientsList.appendChild(item);
271
 
272
- // Animate bar fill
273
- requestAnimationFrame(() => {
274
- const fill = item.querySelector('.confidence-fill');
275
- fill.style.width = `${confidence * 100}%`;
276
- });
277
- });
278
- }
279
-
280
-
281
-
282
-
283
- // Rendering Recipes UI (Markdown support)
284
- function displayRecipes(recipes) {
285
- elements.recipesSection.style.display = 'block';
286
- elements.recipesList.innerHTML = '';
287
-
288
- recipes.forEach((recipe, index) => {
289
- const card = document.createElement('div');
290
- card.className = 'recipe-card';
291
- card.style.animation = `fadeIn 1s ease-out ${index * 0.15}s forwards`;
292
-
293
- // Short / long ingredients
294
- const shortIngredients = (recipe.ingredients || [])
295
- .map(i => (typeof i === "string" ? i : (i.name ?? "Unknown")))
296
- .slice(0, 5);
297
-
298
- const hasMoreIngredients = (recipe.ingredients || []).length > 5;
299
-
300
- // Steps handling: steps may be an array of short steps, or a single big markdown string
301
- const stepsArr = recipe.steps || [];
302
- const isSingleLongMarkdown = stepsArr.length === 1 && (stepsArr[0].includes('\n\n') || stepsArr[0].includes('#') || stepsArr[0].includes('- '));
303
-
304
- // Build Ingredients html
305
- const ingredientsHtml = `
306
- <div class="recipe-section">
307
- <h5 class="section-title">Ingredients</h5>
308
- <div class="ingredients-grid" data-full="${encodeURIComponent(JSON.stringify(recipe.ingredients || []))}">
309
- ${shortIngredients.map(i => `<span class="ingredient-tag">${i}</span>`).join('')}
310
- ${hasMoreIngredients ? `<button class="show-more-btn ingredient-more">+${(recipe.ingredients || []).length - 5} more</button>` : ''}
311
- </div>
312
- </div>
313
- `;
314
-
315
- // Build Steps html
316
- let stepsHtml = '';
317
- if (isSingleLongMarkdown) {
318
- // render whole markdown blob (not inside <ol>)
319
- const mdText = stepsArr[0] || '';
320
- const htmlFromMd = (typeof marked !== 'undefined')
321
- ? marked.parse(mdText)
322
- : mdText.replace(/\n\n/g, '<br><br>').replace(/\n/g, '<br>');
323
- stepsHtml = `
324
- <div class="recipe-section">
325
- <h5 class="section-title">Steps</h5>
326
- <div class="recipe-markdown">${htmlFromMd}</div>
327
- </div>
328
- `;
329
- } else {
330
- // render as ordered list of steps (short step items)
331
- const shortSteps = stepsArr.slice(0, 3);
332
- const hasMoreSteps = stepsArr.length > 3;
333
- const shortStepsHtml = shortSteps
334
- .map(s => {
335
- const rendered = (typeof marked !== 'undefined') ? marked.parseInline(s) : escapeHtml(s);
336
- return `<li>${rendered}</li>`;
337
- })
338
- .join('');
339
- stepsHtml = `
340
- <div class="recipe-section">
341
- <h5 class="section-title">Steps</h5>
342
- <ol class="steps-list" data-full="${encodeURIComponent(JSON.stringify(stepsArr))}">
343
- ${shortStepsHtml}
344
- </ol>
345
- ${hasMoreSteps ? `<button class="show-more-btn steps-more">Show ${stepsArr.length - 3} more</button>` : ''}
346
- </div>
347
- `;
348
- }
349
-
350
- card.innerHTML = `
351
- <div class="recipe-header">
352
- <h4 class="recipe-name">${escapeHtml(recipe.name || 'Recipe')}</h4>
353
- </div>
354
-
355
- ${ingredientsHtml}
356
- ${stepsHtml}
357
- `;
358
-
359
- elements.recipesList.appendChild(card);
360
- setupExpandButtons(card);
361
  });
362
  }
363
 
364
- // Setup expand buttons with markdown handling
365
- function setupExpandButtons(card) {
366
- // Ingredients expand
367
- const ingBtn = card.querySelector(".ingredient-more");
368
- if (ingBtn) {
369
- ingBtn.onclick = () => {
370
- const container = ingBtn.parentElement;
371
- const fullList = JSON.parse(decodeURIComponent(container.dataset.full || '[]'));
372
- container.innerHTML = fullList.map(i => `<span class="ingredient-tag">${escapeHtml(i)}</span>`).join('');
373
- };
374
- }
375
-
376
- // Steps expand (only applies when steps were short-array style)
377
- const stepBtn = card.querySelector(".steps-more");
378
- if (stepBtn) {
379
- stepBtn.onclick = () => {
380
- const ol = stepBtn.previousElementSibling;
381
- const fullList = JSON.parse(decodeURIComponent(ol.dataset.full || '[]'));
382
- if (typeof marked !== 'undefined') {
383
- ol.innerHTML = fullList.map(s => `<li>${marked.parseInline(s)}</li>`).join('');
384
- } else {
385
- ol.innerHTML = fullList.map(s => `<li>${escapeHtml(s)}</li>`).join('');
386
- }
387
- stepBtn.remove();
388
- };
389
- }
390
- }
391
-
392
- // escape HTML when marked is not available or for text content
393
- function escapeHtml(str) {
394
- if (!str) return '';
395
- return str
396
- .replace(/&/g, '&amp;')
397
- .replace(/</g, '&lt;')
398
- .replace(/>/g, '&gt;');
399
- }
400
-
401
-
402
- // Fade Animations
403
- const styleElement = document.createElement('style');
404
- styleElement.textContent = `
405
- @keyframes fadeIn {
406
- from { opacity: 0; transform: translateY(10px); }
407
- to { opacity: 1; transform: translateY(0); }
408
- }
409
- `;
410
- document.head.appendChild(styleElement);
411
-
412
 
413
- // Start Application
414
  document.readyState === 'loading'
415
  ? document.addEventListener('DOMContentLoaded', init)
416
  : init();
 
1
+ // scripts.js — FINAL VERSION (Instant Ingredients + Delayed Recipe)
2
+
3
  const state = {
4
  uploadedImage: null,
5
  detectedIngredients: [],
 
6
  isProcessing: false,
7
  geminiApiKey: "",
8
  skipApiKeyWarning: false
9
  };
10
 
 
11
  const elements = {
12
  apiKeyInput: document.getElementById('apiKey'),
13
  uploadArea: document.getElementById('uploadArea'),
 
25
  darkModeToggle: document.getElementById('darkModeToggle')
26
  };
27
 
 
28
  function init() {
29
  setupEventListeners();
30
  loadStoredPreferences();
31
  loadDarkModePreference();
32
  }
33
 
 
34
  function loadStoredPreferences() {
35
  const savedKey = localStorage.getItem('geminiApiKey');
36
  const skipWarning = localStorage.getItem('skipApiKeyWarning') === 'true';
 
37
  if (savedKey) {
38
  state.geminiApiKey = savedKey;
39
  elements.apiKeyInput.value = savedKey;
40
  }
 
41
  state.skipApiKeyWarning = skipWarning;
42
  }
43
 
 
44
  function loadDarkModePreference() {
45
  if (localStorage.getItem('darkMode') === 'true') {
46
  document.documentElement.classList.add('dark');
47
  }
48
  }
49
 
 
50
  function setupEventListeners() {
 
 
51
  elements.apiKeyInput.addEventListener('input', (e) => {
52
+ state.geminiApiKey = e.target.value.trim();
53
+ localStorage.setItem('geminiApiKey', state.geminiApiKey);
 
54
  });
55
 
 
56
  elements.uploadArea.addEventListener('click', () => elements.fileInput.click());
57
+ elements.uploadArea.addEventListener('dragover', e => { e.preventDefault(); elements.uploadArea.style.borderColor = 'var(--primary)'; });
58
+ elements.uploadArea.addEventListener('dragleave', () => elements.uploadArea.style.borderColor = 'var(--border)');
59
+ elements.uploadArea.addEventListener('drop', e => {
 
 
 
 
 
 
 
 
 
60
  e.preventDefault();
61
  elements.uploadArea.style.borderColor = 'var(--border)';
62
+ if (e.dataTransfer.files[0]) handleFileUpload(e.dataTransfer.files[0]);
 
 
63
  });
64
+ elements.fileInput.addEventListener('change', e => {
65
+ if (e.target.files[0]) handleFileUpload(e.target.files[0]);
 
 
 
 
66
  });
67
 
 
68
  elements.resetButton.addEventListener('click', resetUpload);
 
 
69
  elements.scanButton.addEventListener('click', handleScan);
 
 
70
  elements.darkModeToggle.addEventListener('click', () => {
71
  const isDark = document.documentElement.classList.toggle('dark');
72
  localStorage.setItem('darkMode', isDark);
73
  });
74
  }
75
 
 
 
76
  function handleFileUpload(file) {
77
+ if (!file.type.startsWith('image/')) return alert('Please upload an image.');
 
 
 
 
78
  const reader = new FileReader();
79
+ reader.onload = e => {
80
  state.uploadedImage = e.target.result;
81
+ elements.previewImage.src = e.target.result;
82
+ elements.uploadArea.style.display = 'none';
83
+ elements.previewSection.style.display = 'block';
84
+ elements.scanButton.style.display = 'flex';
85
+ elements.heroSection.style.display = 'none';
86
  };
87
  reader.readAsDataURL(file);
88
  }
89
 
 
 
 
 
 
 
 
 
 
 
90
  function resetUpload() {
91
  state.uploadedImage = null;
92
  state.detectedIngredients = [];
 
 
93
  elements.fileInput.value = '';
94
  elements.uploadArea.style.display = 'block';
95
  elements.previewSection.style.display = 'none';
 
98
  elements.heroSection.style.display = 'block';
99
  }
100
 
101
+ async function handleMissingApiKeyWarning() {
102
+ if (state.geminiApiKey || state.skipApiKeyWarning) return;
103
+ const proceed = confirm("Continue without Gemini API key?\n\n• Slower recipe generation\n• Lower quality possible\n\nProceed anyway?");
104
+ if (!proceed) throw new Error("Cancelled");
105
+ if (confirm("Don't show this again?")) {
106
+ state.skipApiKeyWarning = true;
107
+ localStorage.setItem('skipApiKeyWarning', 'true');
108
+ }
109
+ }
110
 
111
+ function updateScanButton(loading) {
112
+ elements.scanButton.disabled = loading;
113
+ elements.scanButtonText.textContent = loading ? "Processing..." : "Scan Ingredients";
114
+ }
115
+
116
+ // MAIN FUNCTION — THIS IS THE MAGIC
117
  async function handleScan() {
118
+ if (!state.uploadedImage) return alert("Upload an image first");
 
 
 
119
 
120
  await handleMissingApiKeyWarning();
121
 
122
  state.isProcessing = true;
123
  updateScanButton(true);
124
 
125
+ // Reset UI
126
+ elements.ingredientsList.innerHTML = "";
127
+ elements.recipesList.innerHTML = "";
128
+ elements.resultsSection.style.display = "block";
129
 
130
+ try {
131
+ // Detect ingredients
132
  const blob = await (await fetch(state.uploadedImage)).blob();
133
+ const detectForm = new FormData();
134
+ detectForm.append("file", new File([blob], "image.jpg", { type: blob.type }));
135
 
136
+ const detectResponse = await fetch("/detect-ingredients/", {
137
+ method: "POST",
138
+ body: detectForm
139
+ });
140
 
141
+ if (!detectResponse.ok) throw new Error("Detection failed");
142
 
143
+ const { ingredients } = await detectResponse.json();
144
 
145
+ // Display detected ingredients
146
+ state.detectedIngredients = ingredients.map(item => ({
147
  name: item.name,
148
  confidence: item.confidence
149
  }));
150
 
 
 
 
 
 
 
 
151
  displayIngredients(state.detectedIngredients);
 
 
152
 
153
+ // Show "thinking" card
154
+ const thinkingCard = document.createElement("div");
155
+ thinkingCard.className = "recipe-card";
156
+ thinkingCard.innerHTML = `
157
+ <div class="recipe-header"><h4>AI-Generated Recipe</h4></div>
158
+ <div class="recipe-section">
159
+ <p style="text-align:center; padding:2rem; color:var(--text-secondary)">
160
+ <em>Chef is thinking of something delicious...</em><br><br>
161
+ <span style="font-size:2rem">Cooking</span>
162
+ </p>
163
+ </div>`;
164
+ elements.recipesList.appendChild(thinkingCard);
165
+ elements.recipesSection.style.display = "block";
166
+
167
+ // Generate recipe in background
168
+ const recipeForm = new FormData();
169
+ recipeForm.append("ingredients", state.detectedIngredients.map(i => i.name).join(", "));
170
+ recipeForm.append("api_key", state.geminiApiKey.trim());
171
+
172
+ const recipeResponse = await fetch("/generate-recipe/", {
173
+ method: "POST",
174
+ body: recipeForm
175
+ });
176
 
177
+ if (!recipeResponse.ok) throw new Error("Recipe generation failed");
 
 
178
 
179
+ const { recipe } = await recipeResponse.json();
180
 
181
+ // Replace thinking card with real recipe
182
+ thinkingCard.innerHTML = `
183
+ <div class="recipe-header"><h4>AI-Generated Recipe</h4></div>
184
+ <div class="recipe-section">
185
+ <div class="recipe-markdown">${marked.parse(recipe)}</div>
186
+ </div>`;
 
 
 
 
 
 
187
 
188
+ } catch (err) {
189
+ console.error(err);
190
+ elements.recipesList.innerHTML = `<div class="recipe-card" style="color:var(--error);text-align:center;padding:2rem">
191
+ <h4>Failed to generate recipe</h4>
192
+ <p>Try again or add a Gemini API key for better results.</p>
193
+ </div>`;
194
+ } finally {
195
+ state.isProcessing = false;
196
+ updateScanButton(false);
197
  }
198
  }
199
 
 
 
 
 
 
 
200
 
201
+ // Display detected ingredients with confidence bars
202
  function displayIngredients(ingredients) {
203
  elements.ingredientsList.innerHTML = '';
204
+ ingredients.forEach((ing, i) => {
205
+ const conf = Math.round(ing.confidence * 100);
206
+ const color = conf >= 70 ? "#2ecc71" : conf >= 40 ? "#f1c40f" : "#e74c3c";
207
 
 
208
  const item = document.createElement('div');
209
  item.className = 'ingredient-item';
210
+ item.style.animation = `fadeIn 0.8s ease-out ${i * 0.1}s forwards`;
 
 
 
 
 
 
 
 
 
 
211
  item.innerHTML = `
212
  <div class="ingredient-header">
213
+ <span class="ingredient-name">${ing.name}</span>
214
+ <span class="confidence-badge" style="background:${color};color:${conf>=40?'#000':'#fff'}">
215
+ ${conf}% confidence
216
+ </span>
 
 
 
 
 
 
217
  </div>
 
218
  <div class="confidence-bar">
219
+ <div class="confidence-fill" style="background:${color};width:0%;transition:width 1s ease-out"></div>
220
+ </div>`;
 
 
 
 
 
 
 
 
221
  elements.ingredientsList.appendChild(item);
222
 
223
+ setTimeout(() => {
224
+ item.querySelector('.confidence-fill').style.width = `${conf}%`;
225
+ }, 100);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
226
  });
227
  }
228
 
229
+ // Fade-in animation
230
+ document.head.insertAdjacentHTML('beforeend', `
231
+ <style>
232
+ @keyframes fadeIn{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}
233
+ .ingredient-item{opacity:0}
234
+ </style>`);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
235
 
236
+ // Start app
237
  document.readyState === 'loading'
238
  ? document.addEventListener('DOMContentLoaded', init)
239
  : init();