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)}
+ ${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)}
+ ${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
+
Fridge Inventory & Grams Tracker
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
0
+
Expiring ≤ 3 Days
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Showing all items
+
+
+ Group by:
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Inventory Management
+
Add products, adjust grams after cooking, and manage stock
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
No items found
+
Your fridge is either empty or matches no search filters. Scan a barcode or add an item to get started!
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Adjust Amount
+
Item Name
+
Initial: 500g • Opened 2 days ago
+
+
+
+
+
+
+
+
+ 250
+ g
+
+
50% remaining
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Add New Fridge Item
+
Scan barcode or type item details below
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Scan Product Barcode
+
+
+
+
+
+
+
+
+
+
+ Point camera at standard EAN / UPC product barcode
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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;
+}