File size: 10,781 Bytes
f25591e
a0657cc
 
 
 
 
ca80efc
 
a0657cc
 
f25591e
a0657cc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52e3143
98d6f80
 
52e3143
f25591e
a0657cc
 
 
 
 
 
f25591e
a0657cc
 
 
f25591e
a0657cc
 
 
 
f25591e
a0657cc
 
 
f25591e
a0657cc
 
 
 
 
 
f25591e
a0657cc
f25591e
ca80efc
a0657cc
7c835d2
 
a0657cc
 
ca80efc
a0657cc
ca80efc
 
 
ce8b075
 
 
 
7c835d2
a0657cc
 
7c835d2
a0657cc
7c835d2
ce8b075
 
 
a0657cc
 
f25591e
0a70b8f
a0657cc
0a70b8f
a0657cc
ca80efc
a0657cc
 
 
 
 
 
f25591e
 
a0657cc
7c835d2
ca80efc
a0657cc
7c835d2
a0657cc
ca80efc
7c835d2
 
 
 
 
a0657cc
 
 
 
ce8b075
f25591e
a0657cc
ca80efc
ce8b075
ca80efc
ce8b075
 
 
ca80efc
ce8b075
 
a0657cc
 
 
 
 
ca80efc
ce8b075
 
a0657cc
ca80efc
 
ce8b075
ca80efc
7c835d2
 
ca80efc
 
 
 
98d6f80
ca80efc
7c835d2
 
 
 
 
a0657cc
ca80efc
52e3143
 
98d6f80
52e3143
 
98d6f80
52e3143
 
7c835d2
 
ca80efc
 
a0657cc
ca80efc
 
52e3143
ce8b075
52e3143
 
a0657cc
 
 
ca80efc
52e3143
a0657cc
98d6f80
ca80efc
52e3143
ca80efc
7c835d2
 
ca80efc
a0657cc
7c835d2
a0657cc
52e3143
 
7c835d2
98d6f80
a0657cc
7c835d2
 
52e3143
 
7c835d2
a0657cc
52e3143
98d6f80
ce8b075
52e3143
a0657cc
7c835d2
ca80efc
 
a0657cc
98d6f80
52e3143
 
ca80efc
 
 
 
 
f5846c6
ca80efc
 
52e3143
c565e6f
7c835d2
 
ca80efc
 
7c835d2
98d6f80
7c835d2
52e3143
 
7c835d2
a0657cc
98d6f80
 
ce8b075
52e3143
a0657cc
98d6f80
ca80efc
 
 
 
52e3143
c565e6f
 
ca80efc
 
 
 
 
 
 
 
 
 
52e3143
 
ca80efc
 
 
 
 
52e3143
ca80efc
 
 
 
7c835d2
ca80efc
52e3143
98d6f80
a0657cc
 
 
 
7c835d2
a0657cc
 
ca80efc
7c835d2
98d6f80
7c835d2
a0657cc
 
 
ce8b075
ca80efc
a0657cc
 
7c835d2
 
 
 
a0657cc
 
98d6f80
7c835d2
ca80efc
a0657cc
98d6f80
a0657cc
 
 
ca80efc
7c835d2
 
 
 
98d6f80
7c835d2
a0657cc
ca80efc
7c835d2
a0657cc
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
// 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 = `
            <div class="recipe-header"><h4>AI-Generated Recipe</h4></div>
            <div class="recipe-section">
                <p style="text-align:center;padding:3rem">
                    <em>Chef is thinking...</em><br><br>
                    This may take up to 2 minutes without your Gemini API key.
                </p>
            </div>`;
        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 = `
            <div class="recipe-header"><h4>AI-Generated Recipe</h4></div>
            <div class="recipe-section">
                <div class="recipe-markdown">${marked.parse(recipe)}</div>
            </div>`;
        
        elements.recipesSection.style.display = "block";
    }

    // Error handling
    catch (err) {
        if (err.message === "cancelled" || abortController.signal?.aborted) {
            elements.recipesList.innerHTML = `
                <div class="recipe-card" style="text-align:center;padding:2rem;color:var(--text-secondary)">
                    <p>Operation cancelled.</p>
                    <button onclick="handleScan()" class="scan-button small">Try Again</button>
                </div>`;
        } else {
            console.error(err);
            elements.recipesList.innerHTML = `
                <div class="recipe-card" style="color:var(--error);text-align:center;padding:2rem">
                    <h4>Error</h4>
                    <p>Something went wrong. Try again or add a Gemini API key.</p>
                </div>`;
        }
    }

    // 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 = `
            <div class="ingredient-header">
                <span class="ingredient-name">${ing.name}</span>
                <span class="confidence-badge" style="background:${color};color:${conf>=40?'#000':'#fff'}">
                    ${conf}% confidence
                </span>
            </div>
            <div class="confidence-bar">
                <div class="confidence-fill" style="background:${color};width:0%"></div>
            </div>`;

        elements.ingredientsList.appendChild(item);
        setTimeout(() => item.querySelector('.confidence-fill').style.width = `${conf}%`, 100);
    });
}

// Animation
document.head.insertAdjacentHTML('beforeend', `
<style>
@keyframes fadeIn{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}
.ingredient-item{opacity:0}
.scan-button.small{padding:0.5rem 1rem;font-size:0.9rem}
</style>`);


// Start app
document.readyState === 'loading'
    ? document.addEventListener('DOMContentLoaded', init)
    : init();