winebox 0.1.0__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.
@@ -0,0 +1,703 @@
1
+ /**
2
+ * WineBox - Wine Cellar Management Application
3
+ * Frontend JavaScript
4
+ */
5
+
6
+ const API_BASE = '/api';
7
+
8
+ // State
9
+ let currentPage = 'dashboard';
10
+ let authToken = localStorage.getItem('winebox_token');
11
+ let currentUser = null;
12
+
13
+ // Initialize app
14
+ document.addEventListener('DOMContentLoaded', () => {
15
+ initNavigation();
16
+ initForms();
17
+ initModals();
18
+ initAuth();
19
+ checkAuth();
20
+ });
21
+
22
+ // Authentication
23
+ function initAuth() {
24
+ // Login form
25
+ document.getElementById('login-form').addEventListener('submit', handleLogin);
26
+
27
+ // Logout button
28
+ document.getElementById('logout-btn').addEventListener('click', handleLogout);
29
+ }
30
+
31
+ async function checkAuth() {
32
+ if (!authToken) {
33
+ showLoginPage();
34
+ return;
35
+ }
36
+
37
+ try {
38
+ const response = await fetchWithAuth(`${API_BASE}/auth/me`);
39
+ if (!response.ok) {
40
+ throw new Error('Not authenticated');
41
+ }
42
+ currentUser = await response.json();
43
+ showMainApp();
44
+ } catch (error) {
45
+ localStorage.removeItem('winebox_token');
46
+ authToken = null;
47
+ showLoginPage();
48
+ }
49
+ }
50
+
51
+ function showLoginPage() {
52
+ document.body.classList.add('logged-out');
53
+ document.getElementById('page-login').classList.add('active');
54
+ document.getElementById('user-info').style.display = 'none';
55
+ }
56
+
57
+ function showMainApp() {
58
+ document.body.classList.remove('logged-out');
59
+ document.getElementById('page-login').classList.remove('active');
60
+ document.getElementById('user-info').style.display = 'flex';
61
+ document.getElementById('username-display').textContent = currentUser.username;
62
+ loadDashboard();
63
+ }
64
+
65
+ async function handleLogin(e) {
66
+ e.preventDefault();
67
+ const form = e.target;
68
+ const username = document.getElementById('login-username').value;
69
+ const password = document.getElementById('login-password').value;
70
+ const errorDiv = document.getElementById('login-error');
71
+
72
+ errorDiv.style.display = 'none';
73
+
74
+ try {
75
+ const formData = new URLSearchParams();
76
+ formData.append('username', username);
77
+ formData.append('password', password);
78
+
79
+ const response = await fetch(`${API_BASE}/auth/token`, {
80
+ method: 'POST',
81
+ headers: {
82
+ 'Content-Type': 'application/x-www-form-urlencoded',
83
+ },
84
+ body: formData
85
+ });
86
+
87
+ if (!response.ok) {
88
+ const error = await response.json();
89
+ throw new Error(error.detail || 'Login failed');
90
+ }
91
+
92
+ const data = await response.json();
93
+ authToken = data.access_token;
94
+ localStorage.setItem('winebox_token', authToken);
95
+
96
+ form.reset();
97
+ checkAuth();
98
+ } catch (error) {
99
+ errorDiv.textContent = error.message;
100
+ errorDiv.style.display = 'block';
101
+ }
102
+ }
103
+
104
+ function handleLogout() {
105
+ localStorage.removeItem('winebox_token');
106
+ authToken = null;
107
+ currentUser = null;
108
+ showLoginPage();
109
+ }
110
+
111
+ // Fetch with authentication
112
+ async function fetchWithAuth(url, options = {}) {
113
+ const headers = options.headers || {};
114
+ if (authToken) {
115
+ headers['Authorization'] = `Bearer ${authToken}`;
116
+ }
117
+
118
+ const response = await fetch(url, { ...options, headers });
119
+
120
+ // Handle 401 - redirect to login
121
+ if (response.status === 401) {
122
+ localStorage.removeItem('winebox_token');
123
+ authToken = null;
124
+ showLoginPage();
125
+ throw new Error('Session expired');
126
+ }
127
+
128
+ return response;
129
+ }
130
+
131
+ // Navigation
132
+ function initNavigation() {
133
+ document.querySelectorAll('.nav-link').forEach(link => {
134
+ link.addEventListener('click', (e) => {
135
+ e.preventDefault();
136
+ const page = link.dataset.page;
137
+ navigateTo(page);
138
+ });
139
+ });
140
+ }
141
+
142
+ function navigateTo(page) {
143
+ // Update nav links
144
+ document.querySelectorAll('.nav-link').forEach(link => {
145
+ link.classList.toggle('active', link.dataset.page === page);
146
+ });
147
+
148
+ // Update pages
149
+ document.querySelectorAll('.page').forEach(p => {
150
+ p.classList.toggle('active', p.id === `page-${page}`);
151
+ });
152
+
153
+ currentPage = page;
154
+
155
+ // Load page data
156
+ switch (page) {
157
+ case 'dashboard':
158
+ loadDashboard();
159
+ break;
160
+ case 'cellar':
161
+ loadCellar();
162
+ break;
163
+ case 'history':
164
+ loadHistory();
165
+ break;
166
+ case 'search':
167
+ // Search results loaded on form submit
168
+ break;
169
+ }
170
+ }
171
+
172
+ // Forms
173
+ function initForms() {
174
+ // Check-in form
175
+ const checkinForm = document.getElementById('checkin-form');
176
+ checkinForm.addEventListener('submit', handleCheckin);
177
+ checkinForm.addEventListener('reset', () => {
178
+ document.getElementById('front-preview').innerHTML = 'Tap to take photo or select image';
179
+ document.getElementById('back-preview').innerHTML = 'Tap to take photo or select image';
180
+ });
181
+
182
+ // Image previews - make clickable to trigger file input
183
+ const frontLabel = document.getElementById('front-label');
184
+ const backLabel = document.getElementById('back-label');
185
+ const frontPreview = document.getElementById('front-preview');
186
+ const backPreview = document.getElementById('back-preview');
187
+
188
+ frontLabel.addEventListener('change', (e) => {
189
+ previewImage(e.target, 'front-preview');
190
+ });
191
+ backLabel.addEventListener('change', (e) => {
192
+ previewImage(e.target, 'back-preview');
193
+ });
194
+
195
+ // Click on preview to trigger file input
196
+ frontPreview.addEventListener('click', () => {
197
+ frontLabel.click();
198
+ });
199
+ backPreview.addEventListener('click', () => {
200
+ backLabel.click();
201
+ });
202
+
203
+ // Search form
204
+ document.getElementById('search-form').addEventListener('submit', handleSearch);
205
+
206
+ // Checkout form
207
+ document.getElementById('checkout-form').addEventListener('submit', handleCheckout);
208
+
209
+ // Cellar filter
210
+ document.getElementById('cellar-filter').addEventListener('change', loadCellar);
211
+ document.getElementById('cellar-search').addEventListener('input', debounce(loadCellar, 300));
212
+
213
+ // History filter
214
+ document.getElementById('history-filter').addEventListener('change', loadHistory);
215
+ }
216
+
217
+ function previewImage(input, previewId) {
218
+ const preview = document.getElementById(previewId);
219
+ if (input.files && input.files[0]) {
220
+ const reader = new FileReader();
221
+ reader.onload = (e) => {
222
+ preview.innerHTML = `<img src="${e.target.result}" alt="Preview">`;
223
+ };
224
+ reader.readAsDataURL(input.files[0]);
225
+ } else {
226
+ preview.innerHTML = '';
227
+ }
228
+ }
229
+
230
+ async function handleCheckin(e) {
231
+ e.preventDefault();
232
+ const form = e.target;
233
+ const formData = new FormData(form);
234
+
235
+ try {
236
+ const response = await fetchWithAuth(`${API_BASE}/wines/checkin`, {
237
+ method: 'POST',
238
+ body: formData
239
+ });
240
+
241
+ if (!response.ok) {
242
+ const error = await response.json();
243
+ throw new Error(error.detail || 'Check-in failed');
244
+ }
245
+
246
+ const wine = await response.json();
247
+ showToast(`Successfully checked in: ${wine.name}`, 'success');
248
+ form.reset();
249
+ navigateTo('cellar');
250
+ } catch (error) {
251
+ showToast(error.message, 'error');
252
+ }
253
+ }
254
+
255
+ async function handleSearch(e) {
256
+ e.preventDefault();
257
+ const form = e.target;
258
+ const params = new URLSearchParams();
259
+
260
+ // Add non-empty form values to params
261
+ const formData = new FormData(form);
262
+ for (const [key, value] of formData) {
263
+ if (value && key !== 'in_stock') {
264
+ params.append(key, value);
265
+ }
266
+ }
267
+
268
+ // Handle checkbox
269
+ const inStock = document.getElementById('search-in-stock');
270
+ if (inStock.checked) {
271
+ params.append('in_stock', 'true');
272
+ }
273
+
274
+ try {
275
+ const response = await fetchWithAuth(`${API_BASE}/search?${params}`);
276
+ const wines = await response.json();
277
+ renderWineGrid('search-results', wines);
278
+ } catch (error) {
279
+ showToast('Search failed', 'error');
280
+ }
281
+ }
282
+
283
+ async function handleCheckout(e) {
284
+ e.preventDefault();
285
+ const wineId = document.getElementById('checkout-wine-id').value;
286
+ const formData = new FormData(e.target);
287
+
288
+ try {
289
+ const response = await fetchWithAuth(`${API_BASE}/wines/${wineId}/checkout`, {
290
+ method: 'POST',
291
+ body: formData
292
+ });
293
+
294
+ if (!response.ok) {
295
+ const error = await response.json();
296
+ throw new Error(error.detail || 'Check-out failed');
297
+ }
298
+
299
+ const wine = await response.json();
300
+ showToast(`Successfully checked out: ${wine.name}`, 'success');
301
+ closeModals();
302
+ loadCellar();
303
+ loadDashboard();
304
+ } catch (error) {
305
+ showToast(error.message, 'error');
306
+ }
307
+ }
308
+
309
+ // Modals
310
+ function initModals() {
311
+ // Close buttons
312
+ document.querySelectorAll('.modal-close, .modal-cancel').forEach(btn => {
313
+ btn.addEventListener('click', closeModals);
314
+ });
315
+
316
+ // Click outside to close
317
+ document.querySelectorAll('.modal').forEach(modal => {
318
+ modal.addEventListener('click', (e) => {
319
+ if (e.target === modal) {
320
+ closeModals();
321
+ }
322
+ });
323
+ });
324
+
325
+ // Escape key to close
326
+ document.addEventListener('keydown', (e) => {
327
+ if (e.key === 'Escape') {
328
+ closeModals();
329
+ }
330
+ });
331
+ }
332
+
333
+ function closeModals() {
334
+ document.querySelectorAll('.modal').forEach(modal => {
335
+ modal.classList.remove('active');
336
+ });
337
+ }
338
+
339
+ function openModal(modalId) {
340
+ document.getElementById(modalId).classList.add('active');
341
+ }
342
+
343
+ // Dashboard
344
+ async function loadDashboard() {
345
+ try {
346
+ // Load summary
347
+ const summaryResponse = await fetchWithAuth(`${API_BASE}/cellar/summary`);
348
+ const summary = await summaryResponse.json();
349
+
350
+ document.getElementById('stat-total-bottles').textContent = summary.total_bottles;
351
+ document.getElementById('stat-unique-wines').textContent = summary.unique_wines;
352
+ document.getElementById('stat-total-tracked').textContent = summary.total_wines_tracked;
353
+
354
+ // Render breakdowns
355
+ renderChartList('by-country', summary.by_country);
356
+ renderChartList('by-grape', summary.by_grape_variety);
357
+ renderChartList('by-vintage', summary.by_vintage);
358
+
359
+ // Load recent transactions
360
+ const transResponse = await fetchWithAuth(`${API_BASE}/transactions?limit=10`);
361
+ const transactions = await transResponse.json();
362
+ renderActivityList(transactions);
363
+ } catch (error) {
364
+ console.error('Failed to load dashboard:', error);
365
+ }
366
+ }
367
+
368
+ function renderChartList(containerId, data) {
369
+ const container = document.getElementById(containerId);
370
+ if (!data || Object.keys(data).length === 0) {
371
+ container.innerHTML = '<div class="empty-state">No data yet</div>';
372
+ return;
373
+ }
374
+
375
+ container.innerHTML = Object.entries(data)
376
+ .sort((a, b) => b[1] - a[1])
377
+ .slice(0, 5)
378
+ .map(([label, value]) => `
379
+ <div class="chart-item">
380
+ <span class="label">${label}</span>
381
+ <span class="value">${value}</span>
382
+ </div>
383
+ `).join('');
384
+ }
385
+
386
+ function renderActivityList(transactions) {
387
+ const container = document.getElementById('recent-activity');
388
+ if (!transactions || transactions.length === 0) {
389
+ container.innerHTML = '<div class="empty-state">No recent activity</div>';
390
+ return;
391
+ }
392
+
393
+ container.innerHTML = transactions.map(t => `
394
+ <div class="activity-item">
395
+ <div class="activity-icon ${t.transaction_type === 'CHECK_IN' ? 'check-in' : 'check-out'}">
396
+ ${t.transaction_type === 'CHECK_IN' ? '+' : '-'}
397
+ </div>
398
+ <div class="activity-content">
399
+ <div class="activity-title">
400
+ ${t.wine ? t.wine.name : 'Unknown Wine'}
401
+ ${t.wine && t.wine.vintage ? `(${t.wine.vintage})` : ''}
402
+ </div>
403
+ <div class="activity-meta">
404
+ ${t.quantity} bottle${t.quantity > 1 ? 's' : ''} &middot;
405
+ ${formatDate(t.transaction_date)}
406
+ </div>
407
+ </div>
408
+ </div>
409
+ `).join('');
410
+ }
411
+
412
+ // Cellar
413
+ async function loadCellar() {
414
+ const filter = document.getElementById('cellar-filter').value;
415
+ const search = document.getElementById('cellar-search').value;
416
+
417
+ let url = `${API_BASE}/wines?`;
418
+ if (filter === 'in-stock') {
419
+ url += 'in_stock=true&';
420
+ } else if (filter === 'out-of-stock') {
421
+ url += 'in_stock=false&';
422
+ }
423
+
424
+ try {
425
+ const response = await fetchWithAuth(url);
426
+ let wines = await response.json();
427
+
428
+ // Client-side search filter
429
+ if (search) {
430
+ const searchLower = search.toLowerCase();
431
+ wines = wines.filter(w =>
432
+ w.name.toLowerCase().includes(searchLower) ||
433
+ (w.winery && w.winery.toLowerCase().includes(searchLower)) ||
434
+ (w.grape_variety && w.grape_variety.toLowerCase().includes(searchLower))
435
+ );
436
+ }
437
+
438
+ renderWineGrid('cellar-list', wines);
439
+ } catch (error) {
440
+ console.error('Failed to load cellar:', error);
441
+ }
442
+ }
443
+
444
+ function renderWineGrid(containerId, wines) {
445
+ const container = document.getElementById(containerId);
446
+ if (!wines || wines.length === 0) {
447
+ container.innerHTML = '<div class="empty-state"><h3>No wines found</h3><p>Try adjusting your filters</p></div>';
448
+ return;
449
+ }
450
+
451
+ container.innerHTML = wines.map(wine => {
452
+ const quantity = wine.inventory ? wine.inventory.quantity : 0;
453
+ const inStock = quantity > 0;
454
+
455
+ return `
456
+ <div class="wine-card" data-wine-id="${wine.id}">
457
+ <div class="wine-card-image">
458
+ ${wine.front_label_image_path
459
+ ? `<img src="/api/images/${wine.front_label_image_path}" alt="${wine.name}">`
460
+ : '<span style="color: white; opacity: 0.6;">No Image</span>'
461
+ }
462
+ </div>
463
+ <div class="wine-card-content">
464
+ <div class="wine-card-title">${wine.name}</div>
465
+ <div class="wine-card-subtitle">
466
+ ${wine.winery ? wine.winery : ''}
467
+ ${wine.vintage ? ` - ${wine.vintage}` : ''}
468
+ </div>
469
+ <div class="wine-card-details">
470
+ ${wine.grape_variety ? `<span class="wine-tag">${wine.grape_variety}</span>` : ''}
471
+ ${wine.region ? `<span class="wine-tag">${wine.region}</span>` : ''}
472
+ ${wine.country ? `<span class="wine-tag">${wine.country}</span>` : ''}
473
+ </div>
474
+ <div class="wine-card-footer">
475
+ <span class="wine-quantity ${inStock ? '' : 'out-of-stock'}">
476
+ ${inStock ? `${quantity} bottle${quantity > 1 ? 's' : ''}` : 'Out of stock'}
477
+ </span>
478
+ ${inStock ? `<button class="btn btn-small btn-primary checkout-btn" data-wine-id="${wine.id}" data-quantity="${quantity}">Check Out</button>` : ''}
479
+ </div>
480
+ </div>
481
+ </div>
482
+ `;
483
+ }).join('');
484
+
485
+ // Add click handlers
486
+ container.querySelectorAll('.wine-card').forEach(card => {
487
+ card.addEventListener('click', (e) => {
488
+ if (!e.target.classList.contains('checkout-btn')) {
489
+ showWineDetail(card.dataset.wineId);
490
+ }
491
+ });
492
+ });
493
+
494
+ container.querySelectorAll('.checkout-btn').forEach(btn => {
495
+ btn.addEventListener('click', (e) => {
496
+ e.stopPropagation();
497
+ openCheckoutModal(btn.dataset.wineId, btn.dataset.quantity);
498
+ });
499
+ });
500
+ }
501
+
502
+ async function showWineDetail(wineId) {
503
+ try {
504
+ const response = await fetchWithAuth(`${API_BASE}/wines/${wineId}`);
505
+ const wine = await response.json();
506
+
507
+ const quantity = wine.inventory ? wine.inventory.quantity : 0;
508
+
509
+ document.getElementById('wine-detail').innerHTML = `
510
+ <div class="wine-detail-images">
511
+ ${wine.front_label_image_path
512
+ ? `<div class="wine-detail-image"><img src="/api/images/${wine.front_label_image_path}" alt="Front label"></div>`
513
+ : ''
514
+ }
515
+ ${wine.back_label_image_path
516
+ ? `<div class="wine-detail-image"><img src="/api/images/${wine.back_label_image_path}" alt="Back label"></div>`
517
+ : ''
518
+ }
519
+ </div>
520
+ <div class="wine-detail-info">
521
+ <h3>${wine.name}</h3>
522
+ <div class="wine-detail-meta">
523
+ ${wine.winery ? wine.winery : ''}
524
+ ${wine.vintage ? ` - ${wine.vintage}` : ''}
525
+ </div>
526
+
527
+ <div class="wine-detail-field">
528
+ <div class="label">In Stock</div>
529
+ <div class="value">${quantity} bottle${quantity !== 1 ? 's' : ''}</div>
530
+ </div>
531
+
532
+ ${wine.grape_variety ? `
533
+ <div class="wine-detail-field">
534
+ <div class="label">Grape Variety</div>
535
+ <div class="value">${wine.grape_variety}</div>
536
+ </div>
537
+ ` : ''}
538
+
539
+ ${wine.region ? `
540
+ <div class="wine-detail-field">
541
+ <div class="label">Region</div>
542
+ <div class="value">${wine.region}</div>
543
+ </div>
544
+ ` : ''}
545
+
546
+ ${wine.country ? `
547
+ <div class="wine-detail-field">
548
+ <div class="label">Country</div>
549
+ <div class="value">${wine.country}</div>
550
+ </div>
551
+ ` : ''}
552
+
553
+ ${wine.alcohol_percentage ? `
554
+ <div class="wine-detail-field">
555
+ <div class="label">Alcohol</div>
556
+ <div class="value">${wine.alcohol_percentage}%</div>
557
+ </div>
558
+ ` : ''}
559
+
560
+ ${wine.front_label_text ? `
561
+ <div class="wine-detail-field">
562
+ <div class="label">OCR Text (Front Label)</div>
563
+ <div class="wine-detail-ocr">${wine.front_label_text}</div>
564
+ </div>
565
+ ` : ''}
566
+
567
+ <div style="margin-top: 1.5rem; display: flex; gap: 1rem;">
568
+ ${quantity > 0 ? `<button class="btn btn-primary" onclick="openCheckoutModal('${wine.id}', ${quantity})">Check Out</button>` : ''}
569
+ <button class="btn btn-danger" onclick="deleteWine('${wine.id}')">Delete Wine</button>
570
+ </div>
571
+ </div>
572
+
573
+ ${wine.transactions && wine.transactions.length > 0 ? `
574
+ <div class="wine-detail-transactions">
575
+ <h3>Transaction History</h3>
576
+ <div class="transaction-list">
577
+ ${wine.transactions.map(t => `
578
+ <div class="transaction-item">
579
+ <span class="transaction-type ${t.transaction_type === 'CHECK_IN' ? 'check-in' : 'check-out'}">
580
+ ${t.transaction_type === 'CHECK_IN' ? 'In' : 'Out'}
581
+ </span>
582
+ <span class="transaction-quantity">${t.quantity} bottle${t.quantity > 1 ? 's' : ''}</span>
583
+ <span class="transaction-date">${formatDate(t.transaction_date)}</span>
584
+ ${t.notes ? `<span>${t.notes}</span>` : ''}
585
+ </div>
586
+ `).join('')}
587
+ </div>
588
+ </div>
589
+ ` : ''}
590
+ `;
591
+
592
+ openModal('wine-modal');
593
+ } catch (error) {
594
+ showToast('Failed to load wine details', 'error');
595
+ }
596
+ }
597
+
598
+ function openCheckoutModal(wineId, availableQuantity) {
599
+ document.getElementById('checkout-wine-id').value = wineId;
600
+ document.getElementById('checkout-quantity').max = availableQuantity;
601
+ document.getElementById('checkout-quantity').value = 1;
602
+ document.getElementById('checkout-available').textContent = `(${availableQuantity} available)`;
603
+ document.getElementById('checkout-notes').value = '';
604
+ openModal('checkout-modal');
605
+ }
606
+
607
+ async function deleteWine(wineId) {
608
+ if (!confirm('Are you sure you want to delete this wine and all its history?')) {
609
+ return;
610
+ }
611
+
612
+ try {
613
+ const response = await fetchWithAuth(`${API_BASE}/wines/${wineId}`, {
614
+ method: 'DELETE'
615
+ });
616
+
617
+ if (!response.ok) {
618
+ throw new Error('Delete failed');
619
+ }
620
+
621
+ showToast('Wine deleted', 'success');
622
+ closeModals();
623
+ loadCellar();
624
+ loadDashboard();
625
+ } catch (error) {
626
+ showToast('Failed to delete wine', 'error');
627
+ }
628
+ }
629
+
630
+ // History
631
+ async function loadHistory() {
632
+ const filter = document.getElementById('history-filter').value;
633
+ let url = `${API_BASE}/transactions?limit=100`;
634
+ if (filter !== 'all') {
635
+ url += `&transaction_type=${filter}`;
636
+ }
637
+
638
+ try {
639
+ const response = await fetchWithAuth(url);
640
+ const transactions = await response.json();
641
+ renderTransactionList(transactions);
642
+ } catch (error) {
643
+ console.error('Failed to load history:', error);
644
+ }
645
+ }
646
+
647
+ function renderTransactionList(transactions) {
648
+ const container = document.getElementById('history-list');
649
+ if (!transactions || transactions.length === 0) {
650
+ container.innerHTML = '<div class="empty-state"><h3>No transactions yet</h3><p>Check in some wine to get started</p></div>';
651
+ return;
652
+ }
653
+
654
+ container.innerHTML = transactions.map(t => `
655
+ <div class="transaction-item">
656
+ <span class="transaction-type ${t.transaction_type === 'CHECK_IN' ? 'check-in' : 'check-out'}">
657
+ ${t.transaction_type === 'CHECK_IN' ? 'In' : 'Out'}
658
+ </span>
659
+ <span class="transaction-wine">
660
+ ${t.wine ? t.wine.name : 'Unknown Wine'}
661
+ ${t.wine && t.wine.vintage ? `<span class="vintage">(${t.wine.vintage})</span>` : ''}
662
+ </span>
663
+ <span class="transaction-quantity">${t.quantity} bottle${t.quantity > 1 ? 's' : ''}</span>
664
+ <span class="transaction-date">${formatDate(t.transaction_date)}</span>
665
+ </div>
666
+ `).join('');
667
+ }
668
+
669
+ // Utilities
670
+ function formatDate(dateString) {
671
+ const date = new Date(dateString);
672
+ return date.toLocaleDateString('en-US', {
673
+ year: 'numeric',
674
+ month: 'short',
675
+ day: 'numeric',
676
+ hour: '2-digit',
677
+ minute: '2-digit'
678
+ });
679
+ }
680
+
681
+ function showToast(message, type = 'info') {
682
+ const container = document.getElementById('toast-container');
683
+ const toast = document.createElement('div');
684
+ toast.className = `toast ${type}`;
685
+ toast.textContent = message;
686
+ container.appendChild(toast);
687
+
688
+ setTimeout(() => {
689
+ toast.remove();
690
+ }, 5000);
691
+ }
692
+
693
+ function debounce(func, wait) {
694
+ let timeout;
695
+ return function executedFunction(...args) {
696
+ const later = () => {
697
+ clearTimeout(timeout);
698
+ func(...args);
699
+ };
700
+ clearTimeout(timeout);
701
+ timeout = setTimeout(later, wait);
702
+ };
703
+ }