From 494f76c9c1db0544e5cfa8e5303694d5d8a939fb Mon Sep 17 00:00:00 2001 From: max Date: Sat, 10 Oct 2026 22:37:27 +0200 Subject: [PATCH] feat(ui): implement dual view/manage modes, live barcode camera scanner, and interactive grams adjuster --- public/app.js | 1050 +++++++++++++++++++++++++++++++++++++++++++++ public/index.html | 504 ++++++++++++++++++++++ public/styles.css | 82 ++++ 3 files changed, 1636 insertions(+) create mode 100644 public/app.js create mode 100644 public/index.html create mode 100644 public/styles.css diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..78d28a0 --- /dev/null +++ b/public/app.js @@ -0,0 +1,1050 @@ +/** + * Fridger Frontend Application + * Handles dual view/manage modes, live barcode scanning, grams adjustment, and item management. + */ + +// Application State +const state = { + currentMode: 'view', // 'view' or 'manage' + viewGrouping: 'shelf', // 'shelf', 'category', 'grid' + items: [], + meta: { + categories: [], + locations: [], + units: [] + }, + filters: { + search: '', + category: 'All', + status: 'all', + location: 'All' + }, + activeGramsItem: null, + activeGramsValue: 0, + html5QrCode: null, + isScanning: false, + openedFromModal: false +}; + +// Initialize Application +document.addEventListener('DOMContentLoaded', async () => { + await loadMetadata(); + await loadStats(); + await loadItems(); + initLucide(); +}); + +function initLucide() { + if (window.lucide) { + window.lucide.createIcons(); + } +} + +// ---------------------------------------------------- +// API Calls +// ---------------------------------------------------- + +async function loadMetadata() { + try { + const res = await fetch('/api/meta'); + if (res.ok) { + state.meta = await res.json(); + renderCategoryFilters(); + populateModalSelects(); + } + } catch (err) { + console.error('Failed to load metadata:', err); + } +} + +async function loadStats() { + try { + const res = await fetch('/api/stats'); + if (res.ok) { + const stats = await res.json(); + document.getElementById('stat-total').textContent = stats.total || 0; + document.getElementById('stat-fresh').textContent = stats.fresh || 0; + document.getElementById('stat-expiring').textContent = stats.expiringSoon || 0; + document.getElementById('stat-opened').textContent = stats.opened || 0; + } + } catch (err) { + console.error('Failed to load stats:', err); + } +} + +async function loadItems() { + try { + const params = new URLSearchParams(); + if (state.filters.search) params.append('search', state.filters.search); + if (state.filters.category && state.filters.category !== 'All') params.append('category', state.filters.category); + if (state.filters.location && state.filters.location !== 'All') params.append('location', state.filters.location); + if (state.filters.status && state.filters.status !== 'all') params.append('status', state.filters.status); + + const res = await fetch(`/api/items?${params.toString()}`); + if (res.ok) { + state.items = await res.json(); + renderApp(); + } + } catch (err) { + console.error('Failed to load items:', err); + showToast('Failed to load fridge items', 'error'); + } +} + +// ---------------------------------------------------- +// Mode Switching & Filtering +// ---------------------------------------------------- + +function switchMode(mode) { + state.currentMode = mode; + + const viewBtn = document.getElementById('mode-view-btn'); + const manageBtn = document.getElementById('mode-manage-btn'); + const viewContainer = document.getElementById('view-mode-container'); + const manageContainer = document.getElementById('manage-mode-container'); + + if (mode === 'view') { + viewBtn.className = 'flex items-center gap-2 px-3 sm:px-5 py-2 rounded-lg text-sm font-semibold transition-all duration-200 bg-teal-500 text-white shadow-md'; + manageBtn.className = 'flex items-center gap-2 px-3 sm:px-5 py-2 rounded-lg text-sm font-semibold transition-all duration-200 text-slate-300 hover:text-white'; + viewContainer.classList.remove('hidden'); + manageContainer.classList.add('hidden'); + } else { + manageBtn.className = 'flex items-center gap-2 px-3 sm:px-5 py-2 rounded-lg text-sm font-semibold transition-all duration-200 bg-teal-500 text-white shadow-md'; + viewBtn.className = 'flex items-center gap-2 px-3 sm:px-5 py-2 rounded-lg text-sm font-semibold transition-all duration-200 text-slate-300 hover:text-white'; + manageContainer.classList.remove('hidden'); + viewContainer.classList.add('hidden'); + } + + renderApp(); +} + +function setViewGrouping(grouping) { + state.viewGrouping = grouping; + + const btnShelf = document.getElementById('grp-shelf-btn'); + const btnCat = document.getElementById('grp-cat-btn'); + const btnGrid = document.getElementById('grp-grid-btn'); + + const activeClass = 'px-2.5 py-1 rounded bg-teal-500/20 text-teal-300 border border-teal-500/30 font-medium'; + const inactiveClass = 'px-2.5 py-1 rounded bg-slate-800 text-slate-400 border border-slate-700 font-medium'; + + btnShelf.className = grouping === 'shelf' ? activeClass : inactiveClass; + btnCat.className = grouping === 'category' ? activeClass : inactiveClass; + btnGrid.className = grouping === 'grid' ? activeClass : inactiveClass; + + renderFridgeView(); +} + +let searchDebounceTimer = null; +function handleSearch(val) { + state.filters.search = val.trim(); + const clearBtn = document.getElementById('clear-search'); + if (state.filters.search) { + clearBtn.classList.remove('hidden'); + } else { + clearBtn.classList.add('hidden'); + } + + clearTimeout(searchDebounceTimer); + searchDebounceTimer = setTimeout(() => { + loadItems(); + }, 250); +} + +function clearSearch() { + document.getElementById('search-input').value = ''; + document.getElementById('clear-search').classList.add('hidden'); + state.filters.search = ''; + loadItems(); +} + +function setStatusFilter(status) { + state.filters.status = status; + document.querySelectorAll('[data-status-btn]').forEach(btn => { + if (btn.getAttribute('data-status-btn') === status) { + btn.className = 'status-chip active px-3 py-2 rounded-lg bg-slate-800 border border-slate-700 text-white whitespace-nowrap transition font-semibold'; + } else { + btn.className = 'status-chip px-3 py-2 rounded-lg bg-slate-900 border border-slate-800 text-slate-400 hover:text-slate-200 whitespace-nowrap transition'; + } + }); + loadItems(); +} + +function setCategoryFilter(category) { + state.filters.category = category; + renderCategoryFilters(); + loadItems(); +} + +function renderCategoryFilters() { + const container = document.getElementById('category-filter-bar'); + if (!container) return; + + let html = ` + + `; + + state.meta.categories.forEach(cat => { + const isSelected = state.filters.category === cat.id; + html += ` + + `; + }); + + container.innerHTML = html; +} + +function populateModalSelects() { + const catSelect = document.getElementById('modal-category'); + const locSelect = document.getElementById('modal-location'); + + if (catSelect && state.meta.categories) { + catSelect.innerHTML = state.meta.categories.map(c => ` + + `).join(''); + } + + if (locSelect && state.meta.locations) { + locSelect.innerHTML = state.meta.locations.map(l => ` + + `).join(''); + } +} + +// ---------------------------------------------------- +// Rendering +// ---------------------------------------------------- + +function renderApp() { + const emptyState = document.getElementById('empty-state'); + const countLabel = document.getElementById('item-count-label'); + + if (countLabel) { + countLabel.textContent = `Showing ${state.items.length} item${state.items.length === 1 ? '' : 's'}`; + } + + if (state.items.length === 0) { + emptyState.classList.remove('hidden'); + document.getElementById('fridge-view-content').innerHTML = ''; + document.getElementById('manage-items-list').innerHTML = ''; + return; + } + + emptyState.classList.add('hidden'); + + if (state.currentMode === 'view') { + renderFridgeView(); + } else { + renderManageView(); + } + + initLucide(); +} + +function renderFridgeView() { + const container = document.getElementById('fridge-view-content'); + if (!container) return; + + if (state.viewGrouping === 'grid') { + container.innerHTML = ` +
+ ${state.items.map(item => renderFridgeItemCard(item)).join('')} +
+ `; + return; + } + + // Group by Shelf or Category + const isShelf = state.viewGrouping === 'shelf'; + const groups = {}; + + if (isShelf) { + state.meta.locations.forEach(loc => groups[loc] = []); + groups['Other Location'] = []; + } else { + state.meta.categories.forEach(cat => groups[cat.id] = []); + groups['Other'] = []; + } + + state.items.forEach(item => { + const key = isShelf ? (item.location || 'Other Location') : (item.category || 'Other'); + if (!groups[key]) groups[key] = []; + groups[key].push(item); + }); + + let html = ''; + for (const [groupName, groupItems] of Object.entries(groups)) { + if (groupItems.length === 0) continue; + + const iconName = isShelf ? 'refrigerator' : getCategoryIcon(groupName); + + html += ` +
+
+
+
+ +
+

${escapeHtml(groupName)}

+
+ ${groupItems.length} item${groupItems.length === 1 ? '' : 's'} +
+ +
+ ${groupItems.map(item => renderFridgeItemCard(item)).join('')} +
+
+ `; + } + + container.innerHTML = html; +} + +function renderFridgeItemCard(item) { + const expiryInfo = getExpiryStatus(item.expiry_date); + const percent = item.initial_quantity > 0 + ? Math.min(100, Math.max(0, Math.round((item.quantity / item.initial_quantity) * 100))) + : 100; + + const isWeight = item.unit === 'g' || item.unit === 'ml' || item.unit === 'kg' || item.unit === 'l'; + const isLow = percent <= 25; + + let progressBarColor = 'bg-teal-500'; + if (percent <= 20) progressBarColor = 'bg-rose-500'; + else if (percent <= 50) progressBarColor = 'bg-amber-500'; + + return ` +
+ +
+ +
+ + ${escapeHtml(item.brand || item.category)} + + + + ${expiryInfo.label} + +
+ + +
+ ${item.image_url ? ` + ${escapeHtml(item.name)} + ` : ` +
+ +
+ `} + +
+

${escapeHtml(item.name)}

+ ${item.notes ? `

${escapeHtml(item.notes)}

` : ''} +
+
+ + +
+
+ + ${item.quantity} + ${item.unit} + + + of ${item.initial_quantity} ${item.unit} (${percent}%) + +
+ + +
+
+
+
+
+ + +
+ + + ${isWeight ? ` + + ` : ` + + `} + + +
+ +
+ `; +} + +function renderManageView() { + const container = document.getElementById('manage-items-list'); + if (!container) return; + + let html = ''; + + state.items.forEach(item => { + const expiryInfo = getExpiryStatus(item.expiry_date); + const percent = item.initial_quantity > 0 + ? Math.min(100, Math.max(0, Math.round((item.quantity / item.initial_quantity) * 100))) + : 100; + + const isWeight = item.unit === 'g' || item.unit === 'ml' || item.unit === 'kg' || item.unit === 'l'; + + html += ` +
+ +
+ + +
+ ${item.image_url ? ` + ${escapeHtml(item.name)} + ` : ` +
+ +
+ `} + +
+
+

${escapeHtml(item.name)}

+ ${item.brand ? `${escapeHtml(item.brand)}` : ''} + ${item.is_opened ? `Opened` : ''} +
+
+ 📍 ${escapeHtml(item.location || 'Fridge')} + • + ${expiryInfo.label} + ${item.barcode ? `•${escapeHtml(item.barcode)}` : ''} +
+
+
+ + +
+ + +
+ +
+ + +
+ + +
+
+
Remaining:
+
+ ${item.quantity} ${item.unit} (${percent}%) +
+
+ + +
+ + +
+ ${isWeight ? ` + + + + ` : ` + + + `} + +
+ +
+ +
+ `; + }); + + container.innerHTML = html; +} + +// ---------------------------------------------------- +// Grams & Quantity Adjustments +// ---------------------------------------------------- + +function openGramsModal(itemId) { + const item = state.items.find(i => i.id === itemId); + if (!item) return; + + state.activeGramsItem = item; + state.activeGramsValue = item.quantity; + + document.getElementById('grams-modal-title').textContent = item.name; + document.getElementById('grams-modal-sub').textContent = `Package Size: ${item.initial_quantity} ${item.unit} • Location: ${item.location}`; + document.getElementById('grams-modal-unit').textContent = item.unit; + document.getElementById('grams-current-val').textContent = item.quantity; + document.getElementById('grams-exact-input').value = item.quantity; + + const slider = document.getElementById('grams-slider'); + slider.max = item.initial_quantity || 1000; + slider.step = (item.unit === 'g' || item.unit === 'ml') ? '5' : '1'; + slider.value = item.quantity; + + updateGramsGauge(); + document.getElementById('grams-modal').classList.remove('hidden'); + initLucide(); +} + +function closeGramsModal() { + document.getElementById('grams-modal').classList.add('hidden'); + state.activeGramsItem = null; +} + +function onGramsSliderChange(val) { + state.activeGramsValue = parseFloat(val); + document.getElementById('grams-exact-input').value = state.activeGramsValue; + updateGramsGauge(); +} + +function onGramsExactInput(val) { + const num = parseFloat(val); + if (!isNaN(num) && num >= 0) { + state.activeGramsValue = num; + document.getElementById('grams-slider').value = num; + updateGramsGauge(); + } +} + +function applyGramsDelta(delta) { + state.activeGramsValue = Math.max(0, state.activeGramsValue + delta); + document.getElementById('grams-slider').value = state.activeGramsValue; + document.getElementById('grams-exact-input').value = state.activeGramsValue; + updateGramsGauge(); +} + +function setGramsPercent(ratio) { + if (!state.activeGramsItem) return; + state.activeGramsValue = Math.round(state.activeGramsItem.initial_quantity * ratio); + document.getElementById('grams-slider').value = state.activeGramsValue; + document.getElementById('grams-exact-input').value = state.activeGramsValue; + updateGramsGauge(); +} + +function updateGramsGauge() { + document.getElementById('grams-current-val').textContent = state.activeGramsValue; + if (state.activeGramsItem && state.activeGramsItem.initial_quantity > 0) { + const pct = Math.round((state.activeGramsValue / state.activeGramsItem.initial_quantity) * 100); + document.getElementById('grams-percent-text').textContent = `${pct}% remaining of original ${state.activeGramsItem.initial_quantity}${state.activeGramsItem.unit}`; + } +} + +async function saveGramsAdjustment() { + if (!state.activeGramsItem) return; + const itemId = state.activeGramsItem.id; + const newQty = state.activeGramsValue; + + try { + const res = await fetch(`/api/items/${itemId}/quantity`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ newQuantity: newQty, markOpened: true }) + }); + + if (res.ok) { + showToast(`Updated to ${newQty} ${state.activeGramsItem.unit}`, 'success'); + closeGramsModal(); + await loadStats(); + await loadItems(); + } else { + showToast('Failed to update quantity', 'error'); + } + } catch (err) { + console.error('Save grams error:', err); + showToast('Network error while updating quantity', 'error'); + } +} + +async function consumeAllAndRemove() { + if (!state.activeGramsItem) return; + const itemId = state.activeGramsItem.id; + + if (confirm(`Remove "${state.activeGramsItem.name}" from fridge?`)) { + try { + const res = await fetch(`/api/items/${itemId}`, { method: 'DELETE' }); + if (res.ok) { + showToast('Item finished & removed', 'info'); + closeGramsModal(); + await loadStats(); + await loadItems(); + } + } catch (err) { + console.error(err); + } + } +} + +async function quickDelta(itemId, delta) { + try { + const res = await fetch(`/api/items/${itemId}/quantity`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ delta, markOpened: true }) + }); + + if (res.ok) { + const updated = await res.json(); + showToast(`${updated.name}: ${delta > 0 ? '+' : ''}${delta} ${updated.unit} (Now: ${updated.quantity} ${updated.unit})`, 'success'); + await loadStats(); + await loadItems(); + } + } catch (err) { + console.error(err); + showToast('Failed to adjust amount', 'error'); + } +} + +async function onManageSliderCommit(itemId, newQty) { + try { + const res = await fetch(`/api/items/${itemId}/quantity`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ newQuantity: parseFloat(newQty), markOpened: true }) + }); + + if (res.ok) { + showToast(`Updated quantity`, 'success'); + await loadStats(); + await loadItems(); + } + } catch (err) { + console.error(err); + } +} + +// ---------------------------------------------------- +// Item Add / Edit Modal +// ---------------------------------------------------- + +function openItemModal(itemId = null) { + const form = document.getElementById('item-form'); + form.reset(); + document.getElementById('modal-image-container').classList.add('hidden'); + document.getElementById('modal-image-url').value = ''; + + if (itemId) { + const item = state.items.find(i => i.id === itemId); + if (!item) return; + + document.getElementById('modal-item-id').value = item.id; + document.getElementById('item-modal-title').textContent = 'Edit Fridge Item'; + document.getElementById('modal-barcode').value = item.barcode || ''; + document.getElementById('modal-name').value = item.name || ''; + document.getElementById('modal-brand').value = item.brand || ''; + document.getElementById('modal-quantity').value = item.quantity; + document.getElementById('modal-initial-quantity').value = item.initial_quantity || item.quantity; + document.getElementById('modal-unit').value = item.unit || 'g'; + document.getElementById('modal-category').value = item.category || 'General'; + document.getElementById('modal-location').value = item.location || 'Fridge - Middle Shelf'; + document.getElementById('modal-expiry').value = item.expiry_date || ''; + document.getElementById('modal-opened').checked = !!item.is_opened; + document.getElementById('modal-notes').value = item.notes || ''; + + if (item.image_url) { + document.getElementById('modal-image-url').value = item.image_url; + document.getElementById('modal-image-preview').src = item.image_url; + document.getElementById('modal-image-label').textContent = item.name; + document.getElementById('modal-image-container').classList.remove('hidden'); + } + } else { + document.getElementById('modal-item-id').value = ''; + document.getElementById('item-modal-title').textContent = 'Add New Fridge Item'; + document.getElementById('modal-unit').value = 'g'; + document.getElementById('modal-quantity').value = ''; + document.getElementById('modal-initial-quantity').value = ''; + document.getElementById('modal-location').value = 'Fridge - Middle Shelf'; + } + + document.getElementById('item-modal').classList.remove('hidden'); + initLucide(); +} + +function closeItemModal() { + document.getElementById('item-modal').classList.add('hidden'); +} + +async function lookupCurrentBarcode() { + const code = document.getElementById('modal-barcode').value.trim(); + if (!code) { + showToast('Please enter a barcode number first', 'error'); + return; + } + + const btn = document.getElementById('modal-barcode-lookup-btn'); + btn.textContent = 'Searching...'; + btn.disabled = true; + + try { + const res = await fetch(`/api/barcode/${encodeURIComponent(code)}`); + if (res.ok) { + const data = await res.json(); + applyProductDataToForm(data); + showToast(`Found: ${data.name}`, 'success'); + } else { + showToast('Product not found in Open Food Facts', 'info'); + } + } catch (err) { + console.error(err); + showToast('Barcode lookup failed', 'error'); + } finally { + btn.textContent = 'Lookup'; + btn.disabled = false; + } +} + +function applyProductDataToForm(data) { + if (data.name) document.getElementById('modal-name').value = data.name; + if (data.brand) document.getElementById('modal-brand').value = data.brand; + if (data.quantity) { + document.getElementById('modal-quantity').value = data.quantity; + document.getElementById('modal-initial-quantity').value = data.quantity; + } + if (data.unit) document.getElementById('modal-unit').value = data.unit; + if (data.category) document.getElementById('modal-category').value = data.category; + if (data.location) document.getElementById('modal-location').value = data.location; + if (data.barcode) document.getElementById('modal-barcode').value = data.barcode; + + if (data.image_url) { + document.getElementById('modal-image-url').value = data.image_url; + document.getElementById('modal-image-preview').src = data.image_url; + document.getElementById('modal-image-label').textContent = data.name; + document.getElementById('modal-image-container').classList.remove('hidden'); + } +} + +function setModalExpiryDays(days) { + const date = new Date(Date.now() + days * 24 * 60 * 60 * 1000); + document.getElementById('modal-expiry').value = date.toISOString().slice(0, 10); +} + +async function handleItemFormSubmit(event) { + event.preventDefault(); + + const itemId = document.getElementById('modal-item-id').value; + const qty = parseFloat(document.getElementById('modal-quantity').value); + const initQty = parseFloat(document.getElementById('modal-initial-quantity').value) || qty; + + const payload = { + name: document.getElementById('modal-name').value.trim(), + brand: document.getElementById('modal-brand').value.trim(), + barcode: document.getElementById('modal-barcode').value.trim(), + quantity: qty, + initial_quantity: initQty, + unit: document.getElementById('modal-unit').value, + category: document.getElementById('modal-category').value, + location: document.getElementById('modal-location').value, + expiry_date: document.getElementById('modal-expiry').value || null, + is_opened: document.getElementById('modal-opened').checked ? 1 : 0, + notes: document.getElementById('modal-notes').value.trim(), + image_url: document.getElementById('modal-image-url').value || null + }; + + try { + const url = itemId ? `/api/items/${itemId}` : '/api/items'; + const method = itemId ? 'PUT' : 'POST'; + + const res = await fetch(url, { + method: method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + + if (res.ok) { + showToast(itemId ? 'Item updated' : 'Item added to fridge', 'success'); + closeItemModal(); + await loadStats(); + await loadItems(); + } else { + const err = await res.json(); + showToast(err.error || 'Failed to save item', 'error'); + } + } catch (err) { + console.error(err); + showToast('Network error while saving item', 'error'); + } +} + +async function confirmDeleteItem(itemId, name) { + if (confirm(`Are you sure you want to remove "${name}" from your fridge?`)) { + try { + const res = await fetch(`/api/items/${itemId}`, { method: 'DELETE' }); + if (res.ok) { + showToast(`Removed "${name}"`, 'info'); + await loadStats(); + await loadItems(); + } + } catch (err) { + console.error(err); + showToast('Failed to delete item', 'error'); + } + } +} + +// ---------------------------------------------------- +// Live Camera Barcode Scanner +// ---------------------------------------------------- + +function openScannerModal(fromModal = false) { + state.openedFromModal = fromModal; + document.getElementById('scanner-modal').classList.remove('hidden'); + document.getElementById('scanner-manual-input').value = ''; + document.getElementById('scanner-status').textContent = 'Initializing camera...'; + + startCameraScanner(); + initLucide(); +} + +function closeScannerModal() { + stopCameraScanner(); + document.getElementById('scanner-modal').classList.add('hidden'); +} + +async function startCameraScanner() { + if (!window.Html5Qrcode) { + document.getElementById('scanner-status').textContent = 'Scanner library loading...'; + return; + } + + try { + if (state.html5QrCode) { + await state.html5QrCode.stop().catch(() => {}); + } + + state.html5QrCode = new Html5Qrcode("barcode-reader"); + const config = { + fps: 10, + qrbox: { width: 250, height: 160 }, + aspectRatio: 1.333 + }; + + await state.html5QrCode.start( + { facingMode: "environment" }, + config, + onBarcodeScannedSuccess, + onBarcodeScanError + ); + + state.isScanning = true; + document.getElementById('scanner-status').textContent = 'Camera active. Point at barcode.'; + } catch (err) { + console.warn('Camera start error:', err); + document.getElementById('scanner-status').textContent = 'Camera unavailable. Please enter barcode number below.'; + } +} + +async function stopCameraScanner() { + if (state.html5QrCode && state.isScanning) { + try { + await state.html5QrCode.stop(); + state.isScanning = false; + } catch (e) { + console.warn(e); + } + } +} + +async function onBarcodeScannedSuccess(decodedText) { + // Beep sound feedback + playBeepSound(); + + stopCameraScanner(); + document.getElementById('scanner-status').textContent = `Scanned ${decodedText}! Fetching info...`; + + try { + const res = await fetch(`/api/barcode/${encodeURIComponent(decodedText)}`); + closeScannerModal(); + + if (res.ok) { + const data = await res.json(); + openItemModal(); + applyProductDataToForm(data); + showToast(`Scanned: ${data.name}`, 'success'); + } else { + openItemModal(); + document.getElementById('modal-barcode').value = decodedText; + showToast(`Scanned barcode: ${decodedText}. Please enter details.`, 'info'); + } + } catch (err) { + console.error(err); + closeScannerModal(); + openItemModal(); + document.getElementById('modal-barcode').value = decodedText; + } +} + +function onBarcodeScanError(errorMessage) { + // Continuous scanning errors are expected until barcode is in frame +} + +function handleManualBarcodeSubmit() { + const code = document.getElementById('scanner-manual-input').value.trim(); + if (!code) return; + onBarcodeScannedSuccess(code); +} + +function playBeepSound() { + try { + const ctx = new (window.AudioContext || window.webkitAudioContext)(); + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.type = 'sine'; + osc.frequency.setValueAtTime(880, ctx.currentTime); + gain.gain.setValueAtTime(0.15, ctx.currentTime); + gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.15); + osc.connect(gain); + gain.connect(ctx.destination); + osc.start(); + osc.stop(ctx.currentTime + 0.15); + } catch (e) {} +} + +// ---------------------------------------------------- +// Utilities & Helpers +// ---------------------------------------------------- + +function getExpiryStatus(expiryDateStr) { + if (!expiryDateStr) { + return { label: 'No Expiry Set', badgeClass: 'bg-slate-800 text-slate-400', icon: 'calendar' }; + } + + const today = new Date(); + today.setHours(0, 0, 0, 0); + + const expDate = new Date(expiryDateStr); + expDate.setHours(0, 0, 0, 0); + + const diffDays = Math.round((expDate - today) / (1000 * 60 * 60 * 24)); + + if (diffDays < 0) { + return { + label: `Expired ${Math.abs(diffDays)}d ago`, + badgeClass: 'bg-rose-500/20 text-rose-300 border border-rose-500/30', + icon: 'alert-triangle' + }; + } + if (diffDays === 0) { + return { + label: 'Expires Today!', + badgeClass: 'bg-rose-500/20 text-rose-300 border border-rose-500/30 font-bold', + icon: 'alert-circle' + }; + } + if (diffDays === 1) { + return { + label: 'Expires Tomorrow', + badgeClass: 'bg-amber-500/20 text-amber-300 border border-amber-500/30', + icon: 'clock' + }; + } + if (diffDays <= 3) { + return { + label: `${diffDays} days left`, + badgeClass: 'bg-amber-500/20 text-amber-300 border border-amber-500/30', + icon: 'clock' + }; + } + return { + label: `${diffDays} days left`, + badgeClass: 'bg-emerald-500/10 text-emerald-400 border border-emerald-500/20', + icon: 'calendar-check' + }; +} + +function getCategoryIcon(category) { + const cat = state.meta.categories.find(c => c.id === category); + return cat ? cat.icon : 'package'; +} + +function escapeHtml(str) { + if (!str) return ''; + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function showToast(message, type = 'info') { + const container = document.getElementById('toast-container'); + if (!container) return; + + const toast = document.createElement('div'); + const colors = { + success: 'bg-teal-600 text-white border-teal-500', + error: 'bg-rose-600 text-white border-rose-500', + info: 'bg-slate-800 text-slate-100 border-slate-700' + }; + + toast.className = `p-3.5 rounded-xl border shadow-xl text-xs font-medium flex items-center gap-2 transform transition-all duration-300 translate-y-2 opacity-0 pointer-events-auto ${colors[type] || colors.info}`; + toast.innerHTML = `${escapeHtml(message)}`; + + container.appendChild(toast); + + requestAnimationFrame(() => { + toast.classList.remove('translate-y-2', 'opacity-0'); + }); + + setTimeout(() => { + toast.classList.add('translate-y-2', 'opacity-0'); + setTimeout(() => toast.remove(), 300); + }, 3500); +} + +async function seedDemoData() { + try { + const res = await fetch('/api/seed?force=true', { method: 'POST' }); + if (res.ok) { + showToast('Loaded sample fridge items!', 'success'); + await loadStats(); + await loadItems(); + } + } catch (err) { + console.error(err); + } +} diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..41679c2 --- /dev/null +++ b/public/index.html @@ -0,0 +1,504 @@ + + + + + + Fridger - Smart Fridge Inventory + + + + + + + + + + + + + + + + +
+
+
+ + +
+
+ 🧊 +
+
+

Fridger

+ +
+
+ + +
+ + +
+ + +
+ + +
+ +
+
+
+ + +
+
+
+
+
+ +
+
+
0
+
Total In Fridge
+
+
+ +
+
+ +
+
+
0
+
Fresh & Good
+
+
+ +
+
+ +
+
+
0
+
Expiring ≤ 3 Days
+
+
+ +
+
+ +
+
+
0
+
Opened / Partial
+
+
+
+
+
+ + +
+
+ + +
+ + + +
+ + +
+ + + + + +
+ +
+ + +
+ +
+
+ + +
+ + +
+ +
+
+ Showing all items +
+
+ + + + +
+
+ + +
+ +
+
+ + + + + + + +
+ + + + + + + + + + + +
+ + + + + diff --git a/public/styles.css b/public/styles.css new file mode 100644 index 0000000..817a5d7 --- /dev/null +++ b/public/styles.css @@ -0,0 +1,82 @@ +/* Custom styling & animations for Fridger */ + +@keyframes scaleUp { + from { + opacity: 0; + transform: scale(0.96); + } + to { + opacity: 1; + transform: scale(1); + } +} + +.animate-scale-up { + animation: scaleUp 0.18s cubic-bezier(0.16, 1, 0.3, 1) forwards; +} + +/* Glass Shelf Card effect */ +.shelf-card { + background: linear-gradient(135deg, rgba(30, 41, 59, 0.7) 0%, rgba(15, 23, 42, 0.8) 100%); + backdrop-filter: blur(12px); + border: 1px solid rgba(51, 65, 85, 0.6); + box-shadow: 0 4px 20px -2px rgba(0, 0, 0, 0.4); +} + +.shelf-card:hover { + border-color: rgba(45, 212, 191, 0.4); +} + +/* Custom Scrollbar */ +::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +::-webkit-scrollbar-track { + background: rgba(15, 23, 42, 0.5); +} + +::-webkit-scrollbar-thumb { + background: rgba(51, 65, 85, 0.6); + border-radius: 9999px; +} + +::-webkit-scrollbar-thumb:hover { + background: rgba(20, 184, 166, 0.6); +} + +/* Hide scrollbar for category pills */ +.scrollbar-none::-webkit-scrollbar { + display: none; +} +.scrollbar-none { + -ms-overflow-style: none; + scrollbar-width: none; +} + +/* HTML5 Barcode Reader overlay styling */ +#barcode-reader video { + width: 100% !important; + height: 100% !important; + object-fit: cover !important; + border-radius: 0.5rem; +} + +#barcode-reader { + border: none !important; +} + +#barcode-reader__scan_region { + background: transparent !important; +} + +#barcode-reader__dashboard_section_csr button { + background-color: #0d9488 !important; + color: white !important; + border-radius: 0.5rem !important; + padding: 0.35rem 0.75rem !important; + border: none !important; + font-size: 0.75rem !important; + margin: 0.25rem !important; +}