winebox 0.1.2__py3-none-any.whl → 0.1.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- winebox/__init__.py +1 -1
- winebox/config.py +40 -5
- winebox/main.py +48 -1
- winebox/models/user.py +2 -0
- winebox/routers/auth.py +117 -3
- winebox/routers/wines.py +227 -32
- winebox/services/image_storage.py +138 -9
- winebox/services/ocr.py +37 -0
- winebox/services/vision.py +278 -0
- winebox/static/css/style.css +545 -0
- winebox/static/favicon.svg +22 -0
- winebox/static/index.html +233 -2
- winebox/static/js/app.js +583 -8
- {winebox-0.1.2.dist-info → winebox-0.1.4.dist-info}/METADATA +37 -1
- {winebox-0.1.2.dist-info → winebox-0.1.4.dist-info}/RECORD +18 -16
- {winebox-0.1.2.dist-info → winebox-0.1.4.dist-info}/WHEEL +0 -0
- {winebox-0.1.2.dist-info → winebox-0.1.4.dist-info}/entry_points.txt +0 -0
- {winebox-0.1.2.dist-info → winebox-0.1.4.dist-info}/licenses/LICENSE +0 -0
winebox/static/js/app.js
CHANGED
|
@@ -9,6 +9,7 @@ const API_BASE = '/api';
|
|
|
9
9
|
let currentPage = 'dashboard';
|
|
10
10
|
let authToken = localStorage.getItem('winebox_token');
|
|
11
11
|
let currentUser = null;
|
|
12
|
+
let lastScanResult = null; // Store last scan result to avoid rescanning on checkin
|
|
12
13
|
|
|
13
14
|
// Initialize app
|
|
14
15
|
document.addEventListener('DOMContentLoaded', () => {
|
|
@@ -17,8 +18,23 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
|
17
18
|
initModals();
|
|
18
19
|
initAuth();
|
|
19
20
|
checkAuth();
|
|
21
|
+
loadAppInfo();
|
|
20
22
|
});
|
|
21
23
|
|
|
24
|
+
// Load app info for footer
|
|
25
|
+
async function loadAppInfo() {
|
|
26
|
+
try {
|
|
27
|
+
const response = await fetch('/health');
|
|
28
|
+
const data = await response.json();
|
|
29
|
+
const appInfo = document.getElementById('app-info');
|
|
30
|
+
if (appInfo && data.app_name && data.version) {
|
|
31
|
+
appInfo.innerHTML = `${data.app_name} <span class="version">v${data.version}</span>`;
|
|
32
|
+
}
|
|
33
|
+
} catch (error) {
|
|
34
|
+
console.log('Could not load app info');
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
22
38
|
// Authentication
|
|
23
39
|
function initAuth() {
|
|
24
40
|
// Login form
|
|
@@ -26,6 +42,38 @@ function initAuth() {
|
|
|
26
42
|
|
|
27
43
|
// Logout button
|
|
28
44
|
document.getElementById('logout-btn').addEventListener('click', handleLogout);
|
|
45
|
+
|
|
46
|
+
// Username link to settings
|
|
47
|
+
document.getElementById('username-display').addEventListener('click', (e) => {
|
|
48
|
+
e.preventDefault();
|
|
49
|
+
navigateTo('settings');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// Password toggle for all password fields
|
|
53
|
+
initPasswordToggles();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function initPasswordToggles() {
|
|
57
|
+
document.querySelectorAll('.password-toggle').forEach(toggle => {
|
|
58
|
+
toggle.addEventListener('click', function() {
|
|
59
|
+
const wrapper = this.closest('.password-input-wrapper');
|
|
60
|
+
const passwordInput = wrapper.querySelector('input[type="password"], input[type="text"]');
|
|
61
|
+
const eyeIcon = this.querySelector('.eye-icon');
|
|
62
|
+
const eyeOffIcon = this.querySelector('.eye-off-icon');
|
|
63
|
+
|
|
64
|
+
if (passwordInput.type === 'password') {
|
|
65
|
+
passwordInput.type = 'text';
|
|
66
|
+
eyeIcon.style.display = 'none';
|
|
67
|
+
eyeOffIcon.style.display = 'block';
|
|
68
|
+
this.setAttribute('aria-label', 'Hide password');
|
|
69
|
+
} else {
|
|
70
|
+
passwordInput.type = 'password';
|
|
71
|
+
eyeIcon.style.display = 'block';
|
|
72
|
+
eyeOffIcon.style.display = 'none';
|
|
73
|
+
this.setAttribute('aria-label', 'Show password');
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
});
|
|
29
77
|
}
|
|
30
78
|
|
|
31
79
|
async function checkAuth() {
|
|
@@ -58,7 +106,9 @@ function showMainApp() {
|
|
|
58
106
|
document.body.classList.remove('logged-out');
|
|
59
107
|
document.getElementById('page-login').classList.remove('active');
|
|
60
108
|
document.getElementById('user-info').style.display = 'flex';
|
|
61
|
-
|
|
109
|
+
// Display full name if available, otherwise username
|
|
110
|
+
const displayName = currentUser.full_name || currentUser.username;
|
|
111
|
+
document.getElementById('username-display').textContent = displayName;
|
|
62
112
|
loadDashboard();
|
|
63
113
|
}
|
|
64
114
|
|
|
@@ -166,6 +216,9 @@ function navigateTo(page) {
|
|
|
166
216
|
case 'search':
|
|
167
217
|
// Search results loaded on form submit
|
|
168
218
|
break;
|
|
219
|
+
case 'settings':
|
|
220
|
+
loadSettings();
|
|
221
|
+
break;
|
|
169
222
|
}
|
|
170
223
|
}
|
|
171
224
|
|
|
@@ -177,6 +230,8 @@ function initForms() {
|
|
|
177
230
|
checkinForm.addEventListener('reset', () => {
|
|
178
231
|
document.getElementById('front-preview').innerHTML = 'Tap to take photo or select image';
|
|
179
232
|
document.getElementById('back-preview').innerHTML = 'Tap to take photo or select image';
|
|
233
|
+
clearRawLabelText();
|
|
234
|
+
lastScanResult = null; // Clear stored scan result
|
|
180
235
|
});
|
|
181
236
|
|
|
182
237
|
// Image previews - make clickable to trigger file input
|
|
@@ -187,9 +242,11 @@ function initForms() {
|
|
|
187
242
|
|
|
188
243
|
frontLabel.addEventListener('change', (e) => {
|
|
189
244
|
previewImage(e.target, 'front-preview');
|
|
245
|
+
scanLabels();
|
|
190
246
|
});
|
|
191
247
|
backLabel.addEventListener('change', (e) => {
|
|
192
248
|
previewImage(e.target, 'back-preview');
|
|
249
|
+
scanLabels();
|
|
193
250
|
});
|
|
194
251
|
|
|
195
252
|
// Click on preview to trigger file input
|
|
@@ -200,6 +257,25 @@ function initForms() {
|
|
|
200
257
|
backLabel.click();
|
|
201
258
|
});
|
|
202
259
|
|
|
260
|
+
// Label text collapsible toggle
|
|
261
|
+
const labelTextToggle = document.getElementById('label-text-toggle');
|
|
262
|
+
if (labelTextToggle) {
|
|
263
|
+
labelTextToggle.addEventListener('click', () => {
|
|
264
|
+
const section = document.getElementById('label-text-section');
|
|
265
|
+
const content = document.getElementById('label-text-content');
|
|
266
|
+
const icon = section.querySelector('.collapse-icon');
|
|
267
|
+
|
|
268
|
+
section.classList.toggle('open');
|
|
269
|
+
if (section.classList.contains('open')) {
|
|
270
|
+
content.style.display = 'block';
|
|
271
|
+
icon.textContent = '-';
|
|
272
|
+
} else {
|
|
273
|
+
content.style.display = 'none';
|
|
274
|
+
icon.textContent = '+';
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
203
279
|
// Search form
|
|
204
280
|
document.getElementById('search-form').addEventListener('submit', handleSearch);
|
|
205
281
|
|
|
@@ -212,6 +288,12 @@ function initForms() {
|
|
|
212
288
|
|
|
213
289
|
// History filter
|
|
214
290
|
document.getElementById('history-filter').addEventListener('change', loadHistory);
|
|
291
|
+
|
|
292
|
+
// Settings forms
|
|
293
|
+
document.getElementById('profile-form').addEventListener('submit', handleProfileUpdate);
|
|
294
|
+
document.getElementById('password-form').addEventListener('submit', handlePasswordChange);
|
|
295
|
+
document.getElementById('api-key-form').addEventListener('submit', handleApiKeyUpdate);
|
|
296
|
+
document.getElementById('delete-api-key-btn').addEventListener('click', handleApiKeyDelete);
|
|
215
297
|
}
|
|
216
298
|
|
|
217
299
|
function previewImage(input, previewId) {
|
|
@@ -227,10 +309,279 @@ function previewImage(input, previewId) {
|
|
|
227
309
|
}
|
|
228
310
|
}
|
|
229
311
|
|
|
230
|
-
async function
|
|
312
|
+
async function scanLabels() {
|
|
313
|
+
const frontLabel = document.getElementById('front-label');
|
|
314
|
+
|
|
315
|
+
// Only scan if front label is present
|
|
316
|
+
if (!frontLabel.files || !frontLabel.files[0]) {
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const backLabel = document.getElementById('back-label');
|
|
321
|
+
const formData = new FormData();
|
|
322
|
+
formData.append('front_label', frontLabel.files[0]);
|
|
323
|
+
|
|
324
|
+
if (backLabel.files && backLabel.files[0]) {
|
|
325
|
+
formData.append('back_label', backLabel.files[0]);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Show scanning indicator
|
|
329
|
+
showScanningIndicator(true);
|
|
330
|
+
|
|
331
|
+
try {
|
|
332
|
+
const response = await fetchWithAuth(`${API_BASE}/wines/scan`, {
|
|
333
|
+
method: 'POST',
|
|
334
|
+
body: formData
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
if (!response.ok) {
|
|
338
|
+
const error = await response.json();
|
|
339
|
+
throw new Error(error.detail || 'Scan failed');
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const result = await response.json();
|
|
343
|
+
lastScanResult = result; // Store for checkin
|
|
344
|
+
populateFormFromScan(result);
|
|
345
|
+
const methodName = result.method === 'claude_vision' ? 'Claude Vision' : 'Tesseract OCR';
|
|
346
|
+
showToast(`Label scanned with ${methodName}`, 'success');
|
|
347
|
+
} catch (error) {
|
|
348
|
+
showToast(`Scan failed: ${error.message}`, 'error');
|
|
349
|
+
} finally {
|
|
350
|
+
showScanningIndicator(false);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function populateFormFromScan(result) {
|
|
355
|
+
const parsed = result.parsed;
|
|
356
|
+
|
|
357
|
+
// Update fields with scanned values (overwrites previous scan results)
|
|
358
|
+
const fields = {
|
|
359
|
+
'wine-name': parsed.name,
|
|
360
|
+
'winery': parsed.winery,
|
|
361
|
+
'vintage': parsed.vintage,
|
|
362
|
+
'grape-variety': parsed.grape_variety,
|
|
363
|
+
'region': parsed.region,
|
|
364
|
+
'country': parsed.country,
|
|
365
|
+
'alcohol': parsed.alcohol_percentage
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
for (const [fieldId, value] of Object.entries(fields)) {
|
|
369
|
+
const input = document.getElementById(fieldId);
|
|
370
|
+
if (input && value !== null && value !== undefined) {
|
|
371
|
+
input.value = value;
|
|
372
|
+
// Add visual indicator that field was auto-filled
|
|
373
|
+
input.classList.add('auto-filled');
|
|
374
|
+
setTimeout(() => input.classList.remove('auto-filled'), 2000);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Populate raw label text section
|
|
379
|
+
populateRawLabelText(result.ocr, result.method);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function populateRawLabelText(ocr, method) {
|
|
383
|
+
const section = document.getElementById('label-text-section');
|
|
384
|
+
const frontText = document.getElementById('raw-front-label-text');
|
|
385
|
+
const backSection = document.getElementById('raw-back-label-section');
|
|
386
|
+
const backText = document.getElementById('raw-back-label-text');
|
|
387
|
+
const header = section.querySelector('h3');
|
|
388
|
+
|
|
389
|
+
// Update header to show scan method
|
|
390
|
+
const methodName = method === 'claude_vision' ? 'Claude Vision' : 'Tesseract OCR';
|
|
391
|
+
header.innerHTML = `Raw Label Text <span class="scan-method-badge">${methodName}</span>`;
|
|
392
|
+
|
|
393
|
+
if (ocr.front_label_text) {
|
|
394
|
+
frontText.textContent = ocr.front_label_text;
|
|
395
|
+
section.style.display = 'block';
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
if (ocr.back_label_text) {
|
|
399
|
+
backText.textContent = ocr.back_label_text;
|
|
400
|
+
backSection.style.display = 'block';
|
|
401
|
+
} else {
|
|
402
|
+
backSection.style.display = 'none';
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function clearRawLabelText() {
|
|
407
|
+
const section = document.getElementById('label-text-section');
|
|
408
|
+
const frontText = document.getElementById('raw-front-label-text');
|
|
409
|
+
const backSection = document.getElementById('raw-back-label-section');
|
|
410
|
+
const backText = document.getElementById('raw-back-label-text');
|
|
411
|
+
|
|
412
|
+
section.style.display = 'none';
|
|
413
|
+
section.classList.remove('open');
|
|
414
|
+
document.getElementById('label-text-content').style.display = 'none';
|
|
415
|
+
document.querySelector('#label-text-section .collapse-icon').textContent = '+';
|
|
416
|
+
frontText.textContent = '';
|
|
417
|
+
backText.textContent = '';
|
|
418
|
+
backSection.style.display = 'none';
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function showScanningIndicator(show) {
|
|
422
|
+
const submitBtn = document.querySelector('#checkin-form button[type="submit"]');
|
|
423
|
+
const formNote = document.querySelector('#checkin-form .form-note');
|
|
424
|
+
|
|
425
|
+
if (show) {
|
|
426
|
+
if (submitBtn) {
|
|
427
|
+
submitBtn.disabled = true;
|
|
428
|
+
submitBtn.dataset.originalText = submitBtn.textContent;
|
|
429
|
+
submitBtn.textContent = 'Scanning...';
|
|
430
|
+
}
|
|
431
|
+
if (formNote) {
|
|
432
|
+
formNote.dataset.originalText = formNote.textContent;
|
|
433
|
+
formNote.textContent = 'Analyzing label with Claude Vision...';
|
|
434
|
+
formNote.classList.add('scanning');
|
|
435
|
+
}
|
|
436
|
+
} else {
|
|
437
|
+
if (submitBtn) {
|
|
438
|
+
submitBtn.disabled = false;
|
|
439
|
+
submitBtn.textContent = submitBtn.dataset.originalText || 'Check In Wine';
|
|
440
|
+
}
|
|
441
|
+
if (formNote) {
|
|
442
|
+
formNote.textContent = formNote.dataset.originalText || 'Leave fields blank to use OCR-detected values';
|
|
443
|
+
formNote.classList.remove('scanning');
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Store pending checkin data for confirmation
|
|
449
|
+
let pendingCheckinData = null;
|
|
450
|
+
|
|
451
|
+
function handleCheckin(e) {
|
|
231
452
|
e.preventDefault();
|
|
232
|
-
|
|
233
|
-
const
|
|
453
|
+
|
|
454
|
+
const frontLabel = document.getElementById('front-label');
|
|
455
|
+
if (!frontLabel.files || !frontLabel.files[0]) {
|
|
456
|
+
showToast('Please select a front label image', 'error');
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// Store the form data for later submission
|
|
461
|
+
pendingCheckinData = {
|
|
462
|
+
frontLabel: frontLabel.files[0],
|
|
463
|
+
backLabel: document.getElementById('back-label').files?.[0] || null,
|
|
464
|
+
name: document.getElementById('wine-name').value,
|
|
465
|
+
winery: document.getElementById('winery').value,
|
|
466
|
+
vintage: document.getElementById('vintage').value,
|
|
467
|
+
grapeVariety: document.getElementById('grape-variety').value,
|
|
468
|
+
region: document.getElementById('region').value,
|
|
469
|
+
country: document.getElementById('country').value,
|
|
470
|
+
alcohol: document.getElementById('alcohol').value,
|
|
471
|
+
quantity: document.getElementById('quantity').value || '1',
|
|
472
|
+
notes: document.getElementById('notes').value,
|
|
473
|
+
frontLabelText: lastScanResult?.ocr?.front_label_text || '',
|
|
474
|
+
backLabelText: lastScanResult?.ocr?.back_label_text || ''
|
|
475
|
+
};
|
|
476
|
+
|
|
477
|
+
// Show the confirmation modal with editable fields
|
|
478
|
+
showCheckinConfirmation();
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function showCheckinConfirmation() {
|
|
482
|
+
const modal = document.getElementById('checkin-confirm-modal');
|
|
483
|
+
const data = pendingCheckinData;
|
|
484
|
+
|
|
485
|
+
// Set image preview
|
|
486
|
+
const imageContainer = document.getElementById('checkin-confirm-image');
|
|
487
|
+
if (data.frontLabel) {
|
|
488
|
+
const reader = new FileReader();
|
|
489
|
+
reader.onload = (e) => {
|
|
490
|
+
imageContainer.innerHTML = `<img src="${e.target.result}" alt="Wine label">`;
|
|
491
|
+
};
|
|
492
|
+
reader.readAsDataURL(data.frontLabel);
|
|
493
|
+
} else {
|
|
494
|
+
imageContainer.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;height:100%;color:var(--text-muted);">No image</div>';
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// Populate editable fields
|
|
498
|
+
document.getElementById('confirm-wine-name').value = data.name || '';
|
|
499
|
+
document.getElementById('confirm-winery').value = data.winery || '';
|
|
500
|
+
document.getElementById('confirm-vintage').value = data.vintage || '';
|
|
501
|
+
document.getElementById('confirm-grape-variety').value = data.grapeVariety || '';
|
|
502
|
+
document.getElementById('confirm-region').value = data.region || '';
|
|
503
|
+
document.getElementById('confirm-country').value = data.country || '';
|
|
504
|
+
document.getElementById('confirm-alcohol').value = data.alcohol || '';
|
|
505
|
+
document.getElementById('confirm-quantity').value = data.quantity || '1';
|
|
506
|
+
document.getElementById('confirm-notes').value = data.notes || '';
|
|
507
|
+
|
|
508
|
+
// Set OCR text (hidden by default)
|
|
509
|
+
const ocrSection = document.getElementById('confirm-ocr-section');
|
|
510
|
+
const ocrContent = document.getElementById('confirm-ocr-content');
|
|
511
|
+
const ocrToggle = document.getElementById('confirm-ocr-toggle');
|
|
512
|
+
|
|
513
|
+
if (data.frontLabelText) {
|
|
514
|
+
document.getElementById('checkin-confirm-front-ocr').textContent = data.frontLabelText;
|
|
515
|
+
ocrSection.style.display = 'block';
|
|
516
|
+
ocrContent.style.display = 'none'; // Hidden by default
|
|
517
|
+
ocrSection.classList.remove('open');
|
|
518
|
+
ocrToggle.querySelector('.collapse-icon').textContent = '+';
|
|
519
|
+
ocrToggle.querySelector('.label').textContent = 'Show Raw Label Text';
|
|
520
|
+
} else {
|
|
521
|
+
ocrSection.style.display = 'none';
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
const backOcrSection = document.getElementById('checkin-confirm-back-ocr-section');
|
|
525
|
+
if (data.backLabelText) {
|
|
526
|
+
backOcrSection.style.display = 'block';
|
|
527
|
+
document.getElementById('checkin-confirm-back-ocr').textContent = data.backLabelText;
|
|
528
|
+
} else {
|
|
529
|
+
backOcrSection.style.display = 'none';
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// Show modal
|
|
533
|
+
modal.classList.add('active');
|
|
534
|
+
|
|
535
|
+
// Set up OCR toggle
|
|
536
|
+
ocrToggle.onclick = () => {
|
|
537
|
+
ocrSection.classList.toggle('open');
|
|
538
|
+
if (ocrSection.classList.contains('open')) {
|
|
539
|
+
ocrContent.style.display = 'block';
|
|
540
|
+
ocrToggle.querySelector('.collapse-icon').textContent = '-';
|
|
541
|
+
ocrToggle.querySelector('.label').textContent = 'Hide Raw Label Text';
|
|
542
|
+
} else {
|
|
543
|
+
ocrContent.style.display = 'none';
|
|
544
|
+
ocrToggle.querySelector('.collapse-icon').textContent = '+';
|
|
545
|
+
ocrToggle.querySelector('.label').textContent = 'Show Raw Label Text';
|
|
546
|
+
}
|
|
547
|
+
};
|
|
548
|
+
|
|
549
|
+
// Set up button handlers
|
|
550
|
+
document.getElementById('checkin-confirm-btn').onclick = submitCheckin;
|
|
551
|
+
document.getElementById('checkin-cancel-btn').onclick = cancelCheckin;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
async function submitCheckin() {
|
|
555
|
+
const modal = document.getElementById('checkin-confirm-modal');
|
|
556
|
+
const data = pendingCheckinData;
|
|
557
|
+
|
|
558
|
+
// Build form data from confirmation modal fields
|
|
559
|
+
const formData = new FormData();
|
|
560
|
+
formData.append('front_label', data.frontLabel);
|
|
561
|
+
if (data.backLabel) {
|
|
562
|
+
formData.append('back_label', data.backLabel);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// Get values from confirmation modal (may have been edited)
|
|
566
|
+
formData.append('name', document.getElementById('confirm-wine-name').value);
|
|
567
|
+
formData.append('winery', document.getElementById('confirm-winery').value);
|
|
568
|
+
const vintage = document.getElementById('confirm-vintage').value;
|
|
569
|
+
if (vintage) formData.append('vintage', vintage);
|
|
570
|
+
formData.append('grape_variety', document.getElementById('confirm-grape-variety').value);
|
|
571
|
+
formData.append('region', document.getElementById('confirm-region').value);
|
|
572
|
+
formData.append('country', document.getElementById('confirm-country').value);
|
|
573
|
+
const alcohol = document.getElementById('confirm-alcohol').value;
|
|
574
|
+
if (alcohol) formData.append('alcohol_percentage', alcohol);
|
|
575
|
+
formData.append('quantity', document.getElementById('confirm-quantity').value || '1');
|
|
576
|
+
formData.append('notes', document.getElementById('confirm-notes').value);
|
|
577
|
+
|
|
578
|
+
// Include pre-scanned OCR text to avoid rescanning (saves API costs)
|
|
579
|
+
if (data.frontLabelText) {
|
|
580
|
+
formData.append('front_label_text', data.frontLabelText);
|
|
581
|
+
}
|
|
582
|
+
if (data.backLabelText) {
|
|
583
|
+
formData.append('back_label_text', data.backLabelText);
|
|
584
|
+
}
|
|
234
585
|
|
|
235
586
|
try {
|
|
236
587
|
const response = await fetchWithAuth(`${API_BASE}/wines/checkin`, {
|
|
@@ -245,13 +596,29 @@ async function handleCheckin(e) {
|
|
|
245
596
|
|
|
246
597
|
const wine = await response.json();
|
|
247
598
|
showToast(`Successfully checked in: ${wine.name}`, 'success');
|
|
248
|
-
|
|
599
|
+
|
|
600
|
+
// Close modal and reset form
|
|
601
|
+
modal.classList.remove('active');
|
|
602
|
+
document.getElementById('checkin-form').reset();
|
|
603
|
+
document.getElementById('front-preview').innerHTML = 'Tap to take photo or select image';
|
|
604
|
+
document.getElementById('back-preview').innerHTML = 'Tap to take photo or select image';
|
|
605
|
+
clearRawLabelText();
|
|
606
|
+
lastScanResult = null;
|
|
607
|
+
pendingCheckinData = null;
|
|
608
|
+
|
|
609
|
+
// Navigate to cellar
|
|
249
610
|
navigateTo('cellar');
|
|
250
611
|
} catch (error) {
|
|
251
612
|
showToast(error.message, 'error');
|
|
252
613
|
}
|
|
253
614
|
}
|
|
254
615
|
|
|
616
|
+
function cancelCheckin() {
|
|
617
|
+
const modal = document.getElementById('checkin-confirm-modal');
|
|
618
|
+
modal.classList.remove('active');
|
|
619
|
+
// Keep the form data so user can make changes and try again
|
|
620
|
+
}
|
|
621
|
+
|
|
255
622
|
async function handleSearch(e) {
|
|
256
623
|
e.preventDefault();
|
|
257
624
|
const form = e.target;
|
|
@@ -558,9 +925,25 @@ async function showWineDetail(wineId) {
|
|
|
558
925
|
` : ''}
|
|
559
926
|
|
|
560
927
|
${wine.front_label_text ? `
|
|
561
|
-
<div class="wine-detail-
|
|
562
|
-
<div class="
|
|
563
|
-
|
|
928
|
+
<div class="wine-detail-label-text collapsible">
|
|
929
|
+
<div class="collapsible-header" onclick="toggleWineDetailLabelText(this)">
|
|
930
|
+
<span class="label">Show Raw Label Text</span>
|
|
931
|
+
<span class="collapse-icon">+</span>
|
|
932
|
+
</div>
|
|
933
|
+
<div class="collapsible-content" style="display: none;">
|
|
934
|
+
<div class="ocr-raw-text">
|
|
935
|
+
<div class="ocr-raw-section">
|
|
936
|
+
<label>Front Label:</label>
|
|
937
|
+
<pre>${escapeHtml(wine.front_label_text)}</pre>
|
|
938
|
+
</div>
|
|
939
|
+
${wine.back_label_text ? `
|
|
940
|
+
<div class="ocr-raw-section">
|
|
941
|
+
<label>Back Label:</label>
|
|
942
|
+
<pre>${escapeHtml(wine.back_label_text)}</pre>
|
|
943
|
+
</div>
|
|
944
|
+
` : ''}
|
|
945
|
+
</div>
|
|
946
|
+
</div>
|
|
564
947
|
</div>
|
|
565
948
|
` : ''}
|
|
566
949
|
|
|
@@ -678,6 +1061,30 @@ function formatDate(dateString) {
|
|
|
678
1061
|
});
|
|
679
1062
|
}
|
|
680
1063
|
|
|
1064
|
+
function escapeHtml(text) {
|
|
1065
|
+
const div = document.createElement('div');
|
|
1066
|
+
div.textContent = text;
|
|
1067
|
+
return div.innerHTML;
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
function toggleWineDetailLabelText(header) {
|
|
1071
|
+
const section = header.parentElement;
|
|
1072
|
+
const content = section.querySelector('.collapsible-content');
|
|
1073
|
+
const icon = header.querySelector('.collapse-icon');
|
|
1074
|
+
const label = header.querySelector('.label');
|
|
1075
|
+
|
|
1076
|
+
section.classList.toggle('open');
|
|
1077
|
+
if (section.classList.contains('open')) {
|
|
1078
|
+
content.style.display = 'block';
|
|
1079
|
+
icon.textContent = '-';
|
|
1080
|
+
label.textContent = 'Hide Raw Label Text';
|
|
1081
|
+
} else {
|
|
1082
|
+
content.style.display = 'none';
|
|
1083
|
+
icon.textContent = '+';
|
|
1084
|
+
label.textContent = 'Show Raw Label Text';
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
|
|
681
1088
|
function showToast(message, type = 'info') {
|
|
682
1089
|
const container = document.getElementById('toast-container');
|
|
683
1090
|
const toast = document.createElement('div');
|
|
@@ -701,3 +1108,171 @@ function debounce(func, wait) {
|
|
|
701
1108
|
timeout = setTimeout(later, wait);
|
|
702
1109
|
};
|
|
703
1110
|
}
|
|
1111
|
+
|
|
1112
|
+
// Settings
|
|
1113
|
+
function loadSettings() {
|
|
1114
|
+
// Populate profile form with current user data
|
|
1115
|
+
document.getElementById('settings-username').value = currentUser.username;
|
|
1116
|
+
document.getElementById('settings-fullname').value = currentUser.full_name || '';
|
|
1117
|
+
|
|
1118
|
+
// Update API key status
|
|
1119
|
+
updateApiKeyStatus(currentUser.has_api_key);
|
|
1120
|
+
|
|
1121
|
+
// Clear password form
|
|
1122
|
+
document.getElementById('password-form').reset();
|
|
1123
|
+
|
|
1124
|
+
// Clear API key form
|
|
1125
|
+
document.getElementById('api-key').value = '';
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
function updateApiKeyStatus(hasApiKey) {
|
|
1129
|
+
const statusDiv = document.getElementById('api-key-status');
|
|
1130
|
+
const statusText = statusDiv.querySelector('.status-text');
|
|
1131
|
+
const deleteBtn = document.getElementById('delete-api-key-btn');
|
|
1132
|
+
|
|
1133
|
+
statusDiv.classList.remove('configured', 'not-configured');
|
|
1134
|
+
|
|
1135
|
+
if (hasApiKey) {
|
|
1136
|
+
statusDiv.classList.add('configured');
|
|
1137
|
+
statusText.textContent = 'API key is configured';
|
|
1138
|
+
deleteBtn.style.display = 'inline-block';
|
|
1139
|
+
} else {
|
|
1140
|
+
statusDiv.classList.add('not-configured');
|
|
1141
|
+
statusText.textContent = 'No API key configured';
|
|
1142
|
+
deleteBtn.style.display = 'none';
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
async function handleProfileUpdate(e) {
|
|
1147
|
+
e.preventDefault();
|
|
1148
|
+
|
|
1149
|
+
const fullName = document.getElementById('settings-fullname').value.trim();
|
|
1150
|
+
|
|
1151
|
+
try {
|
|
1152
|
+
const response = await fetchWithAuth(`${API_BASE}/auth/profile`, {
|
|
1153
|
+
method: 'PUT',
|
|
1154
|
+
headers: {
|
|
1155
|
+
'Content-Type': 'application/json',
|
|
1156
|
+
},
|
|
1157
|
+
body: JSON.stringify({ full_name: fullName || null })
|
|
1158
|
+
});
|
|
1159
|
+
|
|
1160
|
+
if (!response.ok) {
|
|
1161
|
+
const error = await response.json();
|
|
1162
|
+
throw new Error(error.detail || 'Failed to update profile');
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
const updatedUser = await response.json();
|
|
1166
|
+
currentUser = updatedUser;
|
|
1167
|
+
|
|
1168
|
+
// Update display name in header
|
|
1169
|
+
const displayName = currentUser.full_name || currentUser.username;
|
|
1170
|
+
document.getElementById('username-display').textContent = displayName;
|
|
1171
|
+
|
|
1172
|
+
showToast('Profile updated successfully', 'success');
|
|
1173
|
+
} catch (error) {
|
|
1174
|
+
showToast(error.message, 'error');
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
async function handlePasswordChange(e) {
|
|
1179
|
+
e.preventDefault();
|
|
1180
|
+
|
|
1181
|
+
const currentPassword = document.getElementById('current-password').value;
|
|
1182
|
+
const newPassword = document.getElementById('new-password').value;
|
|
1183
|
+
const confirmPassword = document.getElementById('confirm-password').value;
|
|
1184
|
+
|
|
1185
|
+
if (newPassword !== confirmPassword) {
|
|
1186
|
+
showToast('New passwords do not match', 'error');
|
|
1187
|
+
return;
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
if (newPassword.length < 6) {
|
|
1191
|
+
showToast('Password must be at least 6 characters', 'error');
|
|
1192
|
+
return;
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
try {
|
|
1196
|
+
const response = await fetchWithAuth(`${API_BASE}/auth/password`, {
|
|
1197
|
+
method: 'PUT',
|
|
1198
|
+
headers: {
|
|
1199
|
+
'Content-Type': 'application/json',
|
|
1200
|
+
},
|
|
1201
|
+
body: JSON.stringify({
|
|
1202
|
+
current_password: currentPassword,
|
|
1203
|
+
new_password: newPassword
|
|
1204
|
+
})
|
|
1205
|
+
});
|
|
1206
|
+
|
|
1207
|
+
if (!response.ok) {
|
|
1208
|
+
const error = await response.json();
|
|
1209
|
+
throw new Error(error.detail || 'Failed to change password');
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
document.getElementById('password-form').reset();
|
|
1213
|
+
showToast('Password changed successfully', 'success');
|
|
1214
|
+
} catch (error) {
|
|
1215
|
+
showToast(error.message, 'error');
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
async function handleApiKeyUpdate(e) {
|
|
1220
|
+
e.preventDefault();
|
|
1221
|
+
|
|
1222
|
+
const apiKey = document.getElementById('api-key').value.trim();
|
|
1223
|
+
|
|
1224
|
+
if (!apiKey) {
|
|
1225
|
+
showToast('Please enter an API key', 'error');
|
|
1226
|
+
return;
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
if (!apiKey.startsWith('sk-ant-')) {
|
|
1230
|
+
showToast('Invalid API key format. Anthropic API keys start with sk-ant-', 'error');
|
|
1231
|
+
return;
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
try {
|
|
1235
|
+
const response = await fetchWithAuth(`${API_BASE}/auth/api-key`, {
|
|
1236
|
+
method: 'PUT',
|
|
1237
|
+
headers: {
|
|
1238
|
+
'Content-Type': 'application/json',
|
|
1239
|
+
},
|
|
1240
|
+
body: JSON.stringify({ api_key: apiKey })
|
|
1241
|
+
});
|
|
1242
|
+
|
|
1243
|
+
if (!response.ok) {
|
|
1244
|
+
const error = await response.json();
|
|
1245
|
+
throw new Error(error.detail || 'Failed to update API key');
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
currentUser.has_api_key = true;
|
|
1249
|
+
updateApiKeyStatus(true);
|
|
1250
|
+
document.getElementById('api-key').value = '';
|
|
1251
|
+
showToast('API key saved successfully', 'success');
|
|
1252
|
+
} catch (error) {
|
|
1253
|
+
showToast(error.message, 'error');
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
async function handleApiKeyDelete() {
|
|
1258
|
+
if (!confirm('Are you sure you want to delete your API key? Wine label scanning will use the default system key if available.')) {
|
|
1259
|
+
return;
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
try {
|
|
1263
|
+
const response = await fetchWithAuth(`${API_BASE}/auth/api-key`, {
|
|
1264
|
+
method: 'DELETE'
|
|
1265
|
+
});
|
|
1266
|
+
|
|
1267
|
+
if (!response.ok) {
|
|
1268
|
+
const error = await response.json();
|
|
1269
|
+
throw new Error(error.detail || 'Failed to delete API key');
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
currentUser.has_api_key = false;
|
|
1273
|
+
updateApiKeyStatus(false);
|
|
1274
|
+
showToast('API key deleted successfully', 'success');
|
|
1275
|
+
} catch (error) {
|
|
1276
|
+
showToast(error.message, 'error');
|
|
1277
|
+
}
|
|
1278
|
+
}
|