Files
Fridger/public/app.js
T
me0nline 2f7d2f1f47
Build and Push Fridger Container / Build & Push Fridger Image (push) Successful in 1m36s
feat(ui): simplify to 3-button manage layout and remove drawer grouping
2026-10-10 23:44:02 +02:00

928 lines
33 KiB
JavaScript

/**
* Fridger Frontend Application
* Simplified UI: Fridge View (drawer-free) and 3 primary actions: Manage Grams, Add Pack, Remove Pack.
*/
const state = {
currentMode: 'view', // 'view' or 'manage'
items: [],
meta: {
categories: [],
locations: [],
units: []
},
filters: {
search: '',
category: 'All',
status: '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.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 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 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 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 text-slate-300 hover:text-white';
manageContainer.classList.remove('hidden');
viewContainer.classList.add('hidden');
}
renderApp();
}
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();
}, 200);
}
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-1.5 rounded-lg bg-slate-800 border border-slate-700 text-white font-semibold';
} else {
btn.className = 'status-chip px-3 py-1.5 rounded-lg bg-slate-900 border border-slate-800 text-slate-400 hover:text-slate-200';
}
});
loadItems();
}
function setCategoryFilter(category) {
state.filters.category = category;
renderCategoryFilters();
loadItems();
}
function renderCategoryFilters() {
const container = document.getElementById('category-filter-bar');
if (!container) return;
let html = `
<button onclick="setCategoryFilter('All')" class="px-2.5 py-1 rounded-lg border whitespace-nowrap transition text-xs ${
state.filters.category === 'All'
? 'bg-teal-500/20 text-teal-300 border-teal-500/40 font-semibold'
: 'bg-slate-900 text-slate-400 border-slate-800 hover:text-slate-200'
}">
All Categories
</button>
`;
state.meta.categories.forEach(cat => {
const isSelected = state.filters.category === cat.id;
html += `
<button onclick="setCategoryFilter('${escapeHtml(cat.id)}')" class="px-2.5 py-1 rounded-lg border whitespace-nowrap transition text-xs ${
isSelected
? 'bg-teal-500/20 text-teal-300 border-teal-500/40 font-semibold'
: 'bg-slate-900 text-slate-400 border-slate-800 hover:text-slate-200'
}">
${escapeHtml(cat.name)}
</button>
`;
});
container.innerHTML = html;
}
function populateModalSelects() {
const catSelect = document.getElementById('modal-category');
if (catSelect && state.meta.categories) {
catSelect.innerHTML = state.meta.categories.map(c => `
<option value="${escapeHtml(c.id)}">${escapeHtml(c.name)}</option>
`).join('');
}
}
// ----------------------------------------------------
// Rendering UI
// ----------------------------------------------------
function renderApp() {
const emptyState = document.getElementById('empty-state');
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();
}
// MODE 1: Fridge View Mode (No drawers/shelves - clean grid)
function renderFridgeView() {
const container = document.getElementById('fridge-view-content');
if (!container) return;
container.innerHTML = state.items.map(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;
let progressBarColor = 'bg-teal-500';
if (percent <= 20) progressBarColor = 'bg-rose-500';
else if (percent <= 50) progressBarColor = 'bg-amber-500';
return `
<div class="shelf-card rounded-2xl p-4 flex flex-col justify-between transition hover:border-teal-500/40 space-y-3">
<div>
<!-- Header: Brand & Expiry -->
<div class="flex items-center justify-between gap-2 mb-2">
<span class="text-[11px] font-semibold text-teal-400 bg-teal-500/10 px-2 py-0.5 rounded-md truncate max-w-[130px]">
${escapeHtml(item.brand || item.category)}
</span>
<span class="text-[11px] font-semibold px-2 py-0.5 rounded-md flex items-center gap-1 shrink-0 ${expiryInfo.badgeClass}">
<i data-lucide="${expiryInfo.icon}" class="w-3 h-3"></i>
<span>${expiryInfo.label}</span>
</span>
</div>
<!-- Product Details & Image -->
<div class="flex items-start gap-3 my-1">
${item.image_url ? `
<img src="${escapeHtml(item.image_url)}" alt="${escapeHtml(item.name)}" class="w-12 h-12 object-contain rounded-xl bg-white/5 p-1 border border-slate-700/60 shrink-0">
` : `
<div class="w-12 h-12 rounded-xl bg-slate-800/80 border border-slate-700 text-teal-400 flex items-center justify-center shrink-0">
<i data-lucide="${getCategoryIcon(item.category)}" class="w-6 h-6"></i>
</div>
`}
<div class="min-w-0 flex-1">
<h4 class="font-bold text-slate-100 text-sm leading-snug">${escapeHtml(item.name)}</h4>
${item.notes ? `<p class="text-xs text-slate-400 italic mt-0.5 truncate">${escapeHtml(item.notes)}</p>` : ''}
</div>
</div>
<!-- Grams / Quantity Progress -->
<div class="mt-2.5 bg-slate-950/70 rounded-xl p-2.5 border border-slate-800/80">
<div class="flex items-center justify-between text-xs mb-1">
<span class="font-bold text-white flex items-center gap-1">
<span class="text-base font-black text-teal-300">${item.quantity}</span>
<span class="text-slate-400 font-normal">${item.unit}</span>
</span>
<span class="text-[11px] text-slate-400">
Pack: ${item.initial_quantity} ${item.unit} (${percent}%)
</span>
</div>
<div class="w-full bg-slate-800 h-1.5 rounded-full overflow-hidden">
<div class="${progressBarColor} h-full rounded-full transition-all duration-300" style="width: ${percent}%;"></div>
</div>
</div>
</div>
<!-- 3 Primary Action Buttons: Add Pack, Remove Pack, Manage Grams -->
<div class="grid grid-cols-3 gap-1.5 pt-2 border-t border-slate-800/80">
<button onclick="addWholePacket(${item.id})" class="flex items-center justify-center gap-1 py-1.5 px-2 rounded-xl bg-emerald-500/10 hover:bg-emerald-500/20 text-emerald-300 border border-emerald-500/30 text-xs font-semibold transition active:scale-95" title="Add 1 whole packet (+${item.initial_quantity}${item.unit})">
<i data-lucide="plus" class="w-3.5 h-3.5"></i>
<span>Add</span>
</button>
<button onclick="removeWholePacket(${item.id})" class="flex items-center justify-center gap-1 py-1.5 px-2 rounded-xl bg-rose-500/10 hover:bg-rose-500/20 text-rose-300 border border-rose-500/30 text-xs font-semibold transition active:scale-95" title="Remove 1 whole packet (-${item.initial_quantity}${item.unit})">
<i data-lucide="minus" class="w-3.5 h-3.5"></i>
<span>Remove</span>
</button>
<button onclick="openGramsModal(${item.id})" class="flex items-center justify-center gap-1 py-1.5 px-2 rounded-xl bg-teal-500/20 hover:bg-teal-500/30 text-teal-300 border border-teal-500/40 text-xs font-semibold transition active:scale-95" title="Adjust grams when not used up">
<i data-lucide="sliders" class="w-3.5 h-3.5"></i>
<span>Manage</span>
</button>
</div>
</div>
`;
}).join('');
}
// MODE 2: Manage Mode (Simplified list with the 3 main buttons)
function renderManageView() {
const container = document.getElementById('manage-items-list');
if (!container) return;
container.innerHTML = state.items.map(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;
return `
<div class="bg-slate-900 border border-slate-800 rounded-2xl p-3.5 shadow-md transition hover:border-slate-700 space-y-3">
<div class="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
<!-- Item info -->
<div class="flex items-center gap-3 min-w-0 flex-1">
${item.image_url ? `
<img src="${escapeHtml(item.image_url)}" alt="${escapeHtml(item.name)}" class="w-11 h-11 object-contain rounded-xl bg-white/5 p-1 border border-slate-800 shrink-0">
` : `
<div class="w-11 h-11 rounded-xl bg-slate-800 border border-slate-700 text-teal-400 flex items-center justify-center shrink-0">
<i data-lucide="${getCategoryIcon(item.category)}" class="w-5 h-5"></i>
</div>
`}
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2 flex-wrap">
<h4 class="font-bold text-white text-sm truncate">${escapeHtml(item.name)}</h4>
${item.brand ? `<span class="text-[11px] px-1.5 py-0.5 rounded bg-slate-800 text-slate-300">${escapeHtml(item.brand)}</span>` : ''}
</div>
<div class="text-xs text-slate-400 flex items-center gap-2 mt-0.5 flex-wrap">
<span class="font-semibold text-teal-300">${item.quantity} ${item.unit}</span>
<span>(Pack size: ${item.initial_quantity} ${item.unit})</span>
<span>&bull;</span>
<span class="${expiryInfo.badgeClass} px-1.5 py-0.2 rounded font-medium text-[11px]">${expiryInfo.label}</span>
</div>
</div>
</div>
<!-- The 3 Core Action Buttons: Manage, Add Pack, Remove Pack (+ Edit/Delete icons) -->
<div class="flex items-center gap-2 w-full sm:w-auto justify-end">
<!-- 1. Add Whole Packet -->
<button onclick="addWholePacket(${item.id})" class="px-3 py-1.5 rounded-xl bg-emerald-500/15 hover:bg-emerald-500/25 text-emerald-300 border border-emerald-500/30 text-xs font-semibold flex items-center gap-1 transition active:scale-95" title="Add 1 Whole Packet">
<i data-lucide="plus" class="w-3.5 h-3.5"></i>
<span>Add</span>
</button>
<!-- 2. Remove Whole Packet -->
<button onclick="removeWholePacket(${item.id})" class="px-3 py-1.5 rounded-xl bg-rose-500/15 hover:bg-rose-500/25 text-rose-300 border border-rose-500/30 text-xs font-semibold flex items-center gap-1 transition active:scale-95" title="Remove 1 Whole Packet">
<i data-lucide="minus" class="w-3.5 h-3.5"></i>
<span>Remove</span>
</button>
<!-- 3. Manage Grams / Partial Amount -->
<button onclick="openGramsModal(${item.id})" class="px-3.5 py-1.5 rounded-xl bg-teal-500 hover:bg-teal-400 text-white text-xs font-semibold flex items-center gap-1 shadow-sm transition active:scale-95" title="Adjust grams when partially used">
<i data-lucide="sliders" class="w-3.5 h-3.5"></i>
<span>Manage</span>
</button>
<!-- Secondary: Edit / Delete -->
<button onclick="openItemModal(${item.id})" class="p-1.5 rounded-xl bg-slate-800 hover:bg-slate-700 text-slate-400 hover:text-white border border-slate-700 text-xs ml-1" title="Edit Item Details">
<i data-lucide="pencil" class="w-3.5 h-3.5"></i>
</button>
<button onclick="confirmDeleteItem(${item.id}, '${escapeHtml(item.name)}')" class="p-1.5 rounded-xl bg-slate-800 hover:bg-rose-900/40 text-slate-400 hover:text-rose-300 border border-slate-700 text-xs" title="Delete">
<i data-lucide="trash-2" class="w-3.5 h-3.5"></i>
</button>
</div>
</div>
</div>
`;
}).join('');
}
// ----------------------------------------------------
// 3 Primary Action Handlers: Add Pack, Remove Pack, Manage Grams
// ----------------------------------------------------
async function addWholePacket(itemId) {
try {
const res = await fetch(`/api/items/${itemId}/add-packet`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ count: 1 })
});
if (res.ok) {
const updated = await res.json();
showToast(`Added 1 packet of ${updated.name} (Now: ${updated.quantity} ${updated.unit})`, 'success');
await loadStats();
await loadItems();
} else {
showToast('Failed to add packet', 'error');
}
} catch (err) {
console.error(err);
showToast('Network error', 'error');
}
}
async function removeWholePacket(itemId) {
try {
const res = await fetch(`/api/items/${itemId}/remove-packet`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ count: 1 })
});
if (res.ok) {
const updated = await res.json();
showToast(`Removed 1 packet of ${updated.name} (Remaining: ${updated.quantity} ${updated.unit})`, 'info');
await loadStats();
await loadItems();
} else {
showToast('Failed to remove packet', 'error');
}
} catch (err) {
console.error(err);
showToast('Network error', 'error');
}
}
// ----------------------------------------------------
// Manage Grams Modal Logic
// ----------------------------------------------------
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 = `1 Full Packet = ${item.initial_quantity} ${item.unit}`;
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 = Math.max(item.initial_quantity * 2, item.quantity, 100);
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 a full ${state.activeGramsItem.initial_quantity}${state.activeGramsItem.unit} packet`;
}
}
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(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 removed', 'info');
closeGramsModal();
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 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-expiry').value = item.expiry_date || '';
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 Item';
document.getElementById('modal-unit').value = 'g';
document.getElementById('modal-quantity').value = '';
document.getElementById('modal-initial-quantity').value = '';
}
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.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,
expiry_date: document.getElementById('modal-expiry').value || null,
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(`Remove "${name}" from 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: 240, height: 150 },
aspectRatio: 1.333
};
await state.html5QrCode.start(
{ facingMode: "environment" },
config,
onBarcodeScannedSuccess,
onBarcodeScanError
);
state.isScanning = true;
document.getElementById('scanner-status').textContent = 'Point camera 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) {
playBeepSound();
stopCameraScanner();
document.getElementById('scanner-status').textContent = `Scanned ${decodedText}!`;
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}. Enter details.`, 'info');
}
} catch (err) {
console.error(err);
closeScannerModal();
openItemModal();
document.getElementById('modal-barcode').value = decodedText;
}
}
function onBarcodeScanError() {}
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) {}
}
// ----------------------------------------------------
// Helpers
// ----------------------------------------------------
function getExpiryStatus(expiryDateStr) {
if (!expiryDateStr) {
return { label: 'No Expiry', 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
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 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 = `<span>${escapeHtml(message)}</span>`;
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);
}, 3000);
}
async function seedDemoData() {
try {
const res = await fetch('/api/seed?force=true', { method: 'POST' });
if (res.ok) {
showToast('Loaded sample items!', 'success');
await loadStats();
await loadItems();
}
} catch (err) {
console.error(err);
}
}