Build and Push Fridger Container / Build & Push Fridger Image (push) Successful in 1m27s
940 lines
34 KiB
JavaScript
940 lines
34 KiB
JavaScript
/**
|
|
* Fridger Frontend Application
|
|
* Tablet-optimized: Main -> Scan -> Manage (Scan/Search -> Add/Remove -> Packet/Amount)
|
|
*/
|
|
|
|
const state = {
|
|
currentTab: 'main', // 'main' or 'manage'
|
|
items: [],
|
|
meta: {
|
|
categories: [],
|
|
locations: [],
|
|
units: []
|
|
},
|
|
filters: {
|
|
search: '',
|
|
category: 'All',
|
|
status: 'all'
|
|
},
|
|
manage: {
|
|
selectedItem: null,
|
|
action: null, // 'add' or 'remove'
|
|
qtyMode: 'packet', // 'packet' or 'amount'
|
|
packetCount: 1,
|
|
amountValue: 0
|
|
},
|
|
html5QrCode: null,
|
|
isScanning: 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();
|
|
renderManageQuickPicker();
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to load items:', err);
|
|
showToast('Failed to load fridge items', 'error');
|
|
}
|
|
}
|
|
|
|
// ----------------------------------------------------
|
|
// Navigation Tab Switching (Main -> Scan -> Manage)
|
|
// ----------------------------------------------------
|
|
|
|
function switchTab(tab) {
|
|
state.currentTab = tab;
|
|
|
|
const mainBtn = document.getElementById('nav-main-btn');
|
|
const manageBtn = document.getElementById('nav-manage-btn');
|
|
const mainView = document.getElementById('main-tab-view');
|
|
const manageView = document.getElementById('manage-tab-view');
|
|
|
|
const activeClasses = 'flex items-center justify-center gap-2 px-4 sm:px-7 py-2.5 sm:py-3 rounded-xl text-xs sm:text-sm font-bold transition-all bg-teal-500 text-white shadow-md active:scale-95 touch-manipulation min-h-[44px]';
|
|
const inactiveClasses = 'flex items-center justify-center gap-2 px-4 sm:px-7 py-2.5 sm:py-3 rounded-xl text-xs sm:text-sm font-bold transition-all text-slate-300 hover:text-white hover:bg-slate-800/80 active:scale-95 touch-manipulation min-h-[44px]';
|
|
|
|
if (tab === 'main') {
|
|
mainBtn.className = activeClasses;
|
|
manageBtn.className = inactiveClasses;
|
|
mainView.classList.remove('hidden');
|
|
manageView.classList.add('hidden');
|
|
renderApp();
|
|
} else if (tab === 'manage') {
|
|
manageBtn.className = activeClasses;
|
|
mainBtn.className = inactiveClasses;
|
|
manageView.classList.remove('hidden');
|
|
mainView.classList.add('hidden');
|
|
renderManageQuickPicker();
|
|
}
|
|
|
|
initLucide();
|
|
}
|
|
|
|
// ----------------------------------------------------
|
|
// Filters & Search (Main Tab)
|
|
// ----------------------------------------------------
|
|
|
|
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.5 py-2 rounded-xl bg-slate-800 border border-slate-700 text-white font-bold min-h-[38px] touch-manipulation';
|
|
} else {
|
|
btn.className = 'status-chip px-3.5 py-2 rounded-xl bg-slate-900 border border-slate-800 text-slate-400 hover:text-slate-200 min-h-[38px] touch-manipulation';
|
|
}
|
|
});
|
|
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-3.5 py-2 rounded-xl border whitespace-nowrap transition text-xs sm:text-sm min-h-[38px] touch-manipulation ${
|
|
state.filters.category === 'All'
|
|
? 'bg-teal-500/20 text-teal-300 border-teal-500/50 font-bold'
|
|
: '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-3.5 py-2 rounded-xl border whitespace-nowrap transition text-xs sm:text-sm min-h-[38px] touch-manipulation ${
|
|
isSelected
|
|
? 'bg-teal-500/20 text-teal-300 border-teal-500/50 font-bold'
|
|
: '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('');
|
|
}
|
|
}
|
|
|
|
// ----------------------------------------------------
|
|
// UI Rendering (Main Tab: Fridge Cards)
|
|
// ----------------------------------------------------
|
|
|
|
function renderApp() {
|
|
const emptyState = document.getElementById('empty-state');
|
|
const container = document.getElementById('fridge-view-content');
|
|
|
|
if (state.items.length === 0) {
|
|
emptyState.classList.remove('hidden');
|
|
container.innerHTML = '';
|
|
return;
|
|
}
|
|
|
|
emptyState.classList.add('hidden');
|
|
|
|
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 sm:rounded-3xl p-4 sm:p-5 flex flex-col justify-between transition hover:border-teal-500/40 space-y-3.5">
|
|
|
|
<div>
|
|
<!-- Header: Brand & Expiry -->
|
|
<div class="flex items-center justify-between gap-2 mb-2">
|
|
<span class="text-xs font-bold text-teal-400 bg-teal-500/10 px-2.5 py-1 rounded-lg truncate max-w-[140px]">
|
|
${escapeHtml(item.brand || item.category)}
|
|
</span>
|
|
<span class="text-xs font-bold px-2.5 py-1 rounded-lg flex items-center gap-1.5 shrink-0 ${expiryInfo.badgeClass}">
|
|
<i data-lucide="${expiryInfo.icon}" class="w-3.5 h-3.5"></i>
|
|
<span>${expiryInfo.label}</span>
|
|
</span>
|
|
</div>
|
|
|
|
<!-- Product Details & Image -->
|
|
<div class="flex items-center gap-3.5 my-2">
|
|
${item.image_url ? `
|
|
<img src="${escapeHtml(item.image_url)}" alt="${escapeHtml(item.name)}" class="w-14 h-14 object-contain rounded-2xl bg-white/5 p-1 border border-slate-700/60 shrink-0">
|
|
` : `
|
|
<div class="w-14 h-14 rounded-2xl 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-7 h-7"></i>
|
|
</div>
|
|
`}
|
|
|
|
<div class="min-w-0 flex-1">
|
|
<h4 class="font-bold text-white text-base leading-snug line-clamp-2">${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-3 bg-slate-950/80 rounded-2xl p-3 border border-slate-800/80">
|
|
<div class="flex items-center justify-between text-xs sm:text-sm mb-1.5">
|
|
<span class="font-bold text-white flex items-center gap-1">
|
|
<span class="text-lg font-black text-teal-300">${item.quantity}</span>
|
|
<span class="text-slate-400 font-normal">${item.unit}</span>
|
|
</span>
|
|
<span class="text-xs text-slate-400">
|
|
Pack: ${item.initial_quantity} ${item.unit} (${percent}%)
|
|
</span>
|
|
</div>
|
|
<div class="w-full bg-slate-800 h-2 rounded-full overflow-hidden">
|
|
<div class="${progressBarColor} h-full rounded-full transition-all duration-300" style="width: ${percent}%;"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Touch Manage Button -->
|
|
<button onclick="goToManageItem(${item.id})" class="w-full flex items-center justify-center gap-2 py-3 px-3 rounded-2xl bg-teal-500/15 hover:bg-teal-500/25 text-teal-300 border border-teal-500/40 text-xs sm:text-sm font-bold transition active:scale-95 touch-manipulation min-h-[44px]" title="Manage this item">
|
|
<i data-lucide="sliders-horizontal" class="w-4 h-4"></i>
|
|
<span>Manage Item</span>
|
|
</button>
|
|
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
|
|
initLucide();
|
|
}
|
|
|
|
// ----------------------------------------------------
|
|
// MANAGE TAB: 3-Step Flow (Scan/Search -> Add/Remove -> Packet/Amount)
|
|
// ----------------------------------------------------
|
|
|
|
function renderManageQuickPicker(filterQuery = '') {
|
|
const container = document.getElementById('manage-quick-picker');
|
|
if (!container) return;
|
|
|
|
const query = filterQuery.toLowerCase().trim();
|
|
const matched = state.items.filter(i => {
|
|
if (!query) return true;
|
|
return (i.name && i.name.toLowerCase().includes(query)) ||
|
|
(i.brand && i.brand.toLowerCase().includes(query)) ||
|
|
(i.barcode && i.barcode.includes(query));
|
|
});
|
|
|
|
if (matched.length === 0) {
|
|
container.innerHTML = `
|
|
<div class="col-span-full py-4 text-center text-xs text-slate-500">
|
|
No matching fridge items found. Scan barcode or add as new product!
|
|
</div>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = matched.map(item => `
|
|
<button
|
|
onclick="selectManageItemById(${item.id})"
|
|
class="p-3 rounded-2xl bg-slate-950 hover:bg-slate-800/80 border border-slate-800/90 text-left flex items-center gap-3 transition active:scale-95 touch-manipulation min-h-[50px] ${state.manage.selectedItem?.id === item.id ? 'ring-2 ring-teal-500 border-teal-500' : ''}"
|
|
>
|
|
${item.image_url ? `
|
|
<img src="${escapeHtml(item.image_url)}" class="w-9 h-9 object-contain rounded-xl bg-white/5 p-0.5 shrink-0">
|
|
` : `
|
|
<div class="w-9 h-9 rounded-xl bg-slate-800 text-teal-400 flex items-center justify-center shrink-0">
|
|
<i data-lucide="${getCategoryIcon(item.category)}" class="w-4 h-4"></i>
|
|
</div>
|
|
`}
|
|
<div class="min-w-0 flex-1">
|
|
<h5 class="text-xs sm:text-sm font-bold text-white truncate">${escapeHtml(item.name)}</h5>
|
|
<div class="text-[11px] text-teal-300/90 font-medium">
|
|
${item.quantity} ${item.unit} <span class="text-slate-500 font-normal">(${item.initial_quantity}${item.unit} pack)</span>
|
|
</div>
|
|
</div>
|
|
</button>
|
|
`).join('');
|
|
|
|
initLucide();
|
|
}
|
|
|
|
function handleManageSearch(val) {
|
|
renderManageQuickPicker(val);
|
|
|
|
// If barcode entered directly (e.g. 8-14 digits) and matches an item
|
|
const clean = val.trim();
|
|
if (/^\d{8,14}$/.test(clean)) {
|
|
const found = state.items.find(i => i.barcode === clean);
|
|
if (found) {
|
|
selectManageItem(found);
|
|
}
|
|
}
|
|
}
|
|
|
|
function selectManageItemById(id) {
|
|
const item = state.items.find(i => i.id === id);
|
|
if (item) selectManageItem(item);
|
|
}
|
|
|
|
function selectManageItem(item) {
|
|
state.manage.selectedItem = item;
|
|
state.manage.action = null; // reset step 2
|
|
state.manage.packetCount = 1;
|
|
state.manage.amountValue = item.quantity;
|
|
|
|
// Populate Selected Product Banner
|
|
document.getElementById('manage-selected-name').textContent = item.name;
|
|
document.getElementById('manage-selected-details').textContent = `Currently: ${item.quantity} ${item.unit} in fridge • 1 Full Pack = ${item.initial_quantity} ${item.unit}`;
|
|
document.getElementById('manage-pack-size-label').textContent = `${item.initial_quantity} ${item.unit}`;
|
|
document.getElementById('manage-amount-unit').textContent = item.unit;
|
|
document.getElementById('manage-amount-val').textContent = item.quantity;
|
|
|
|
const slider = document.getElementById('manage-amount-slider');
|
|
slider.max = Math.max(item.initial_quantity * 2, item.quantity + item.initial_quantity, 100);
|
|
slider.value = item.quantity;
|
|
slider.step = (item.unit === 'g' || item.unit === 'ml') ? '5' : '1';
|
|
|
|
const imgBox = document.getElementById('manage-selected-img');
|
|
if (item.image_url) {
|
|
imgBox.innerHTML = `<img src="${escapeHtml(item.image_url)}" class="w-full h-full object-contain p-1">`;
|
|
} else {
|
|
imgBox.innerHTML = `<i data-lucide="${getCategoryIcon(item.category)}" class="w-7 h-7 text-teal-400"></i>`;
|
|
}
|
|
|
|
// Reveal Action Card
|
|
document.getElementById('manage-action-card').classList.remove('hidden');
|
|
document.getElementById('manage-qty-type-section').classList.add('hidden');
|
|
|
|
// Reset Action Button Styles
|
|
resetActionButtonsStyle();
|
|
|
|
renderManageQuickPicker();
|
|
initLucide();
|
|
|
|
// Scroll smoothly to action card on tablet
|
|
document.getElementById('manage-action-card').scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
|
}
|
|
|
|
function clearSelectedManageItem() {
|
|
state.manage.selectedItem = null;
|
|
state.manage.action = null;
|
|
document.getElementById('manage-action-card').classList.add('hidden');
|
|
renderManageQuickPicker();
|
|
}
|
|
|
|
function goToManageItem(itemId) {
|
|
switchTab('manage');
|
|
selectManageItemById(itemId);
|
|
}
|
|
|
|
function setManageAction(action) {
|
|
state.manage.action = action; // 'add' or 'remove'
|
|
const btnAdd = document.getElementById('manage-btn-add');
|
|
const btnRemove = document.getElementById('manage-btn-remove');
|
|
const qtySection = document.getElementById('manage-qty-type-section');
|
|
const confirmBtn = document.getElementById('manage-confirm-btn');
|
|
const confirmLabel = document.getElementById('manage-confirm-label');
|
|
const step3Title = document.getElementById('manage-step3-title');
|
|
|
|
if (action === 'add') {
|
|
btnAdd.className = 'p-4 sm:p-5 rounded-2xl border-2 text-center font-extrabold transition-all active:scale-95 touch-manipulation flex flex-col items-center justify-center gap-2 min-h-[80px] bg-emerald-500/20 border-emerald-500 text-white shadow-lg shadow-emerald-500/20 ring-2 ring-emerald-500/40';
|
|
btnRemove.className = 'p-4 sm:p-5 rounded-2xl border-2 text-center font-extrabold transition-all active:scale-95 touch-manipulation flex flex-col items-center justify-center gap-2 min-h-[80px] bg-slate-950 border-slate-800 text-slate-400 hover:border-slate-700';
|
|
step3Title.textContent = `How much ${state.manage.selectedItem.name} to ADD?`;
|
|
confirmBtn.className = 'w-full py-4 rounded-2xl bg-emerald-500 hover:bg-emerald-400 text-white font-black text-base sm:text-lg shadow-xl shadow-emerald-500/25 active:scale-95 transition-all touch-manipulation flex items-center justify-center gap-2';
|
|
confirmLabel.textContent = `Confirm Add to Fridge`;
|
|
} else {
|
|
btnRemove.className = 'p-4 sm:p-5 rounded-2xl border-2 text-center font-extrabold transition-all active:scale-95 touch-manipulation flex flex-col items-center justify-center gap-2 min-h-[80px] bg-rose-500/20 border-rose-500 text-white shadow-lg shadow-rose-500/20 ring-2 ring-rose-500/40';
|
|
btnAdd.className = 'p-4 sm:p-5 rounded-2xl border-2 text-center font-extrabold transition-all active:scale-95 touch-manipulation flex flex-col items-center justify-center gap-2 min-h-[80px] bg-slate-950 border-slate-800 text-slate-400 hover:border-slate-700';
|
|
step3Title.textContent = `How much ${state.manage.selectedItem.name} to REMOVE?`;
|
|
confirmBtn.className = 'w-full py-4 rounded-2xl bg-rose-500 hover:bg-rose-400 text-white font-black text-base sm:text-lg shadow-xl shadow-rose-500/25 active:scale-95 transition-all touch-manipulation flex items-center justify-center gap-2';
|
|
confirmLabel.textContent = `Confirm Remove from Fridge`;
|
|
}
|
|
|
|
qtySection.classList.remove('hidden');
|
|
initLucide();
|
|
}
|
|
|
|
function resetActionButtonsStyle() {
|
|
const btnAdd = document.getElementById('manage-btn-add');
|
|
const btnRemove = document.getElementById('manage-btn-remove');
|
|
const defClasses = 'p-4 sm:p-5 rounded-2xl border-2 text-center font-extrabold transition-all active:scale-95 touch-manipulation flex flex-col items-center justify-center gap-2 min-h-[80px] bg-slate-950 border-slate-800 text-slate-300 hover:border-slate-700';
|
|
btnAdd.className = defClasses;
|
|
btnRemove.className = defClasses;
|
|
}
|
|
|
|
function setQtyMode(mode) {
|
|
state.manage.qtyMode = mode; // 'packet' or 'amount'
|
|
const btnPack = document.getElementById('qty-mode-packet-btn');
|
|
const btnAmount = document.getElementById('qty-mode-amount-btn');
|
|
const packControls = document.getElementById('qty-packet-controls');
|
|
const amountControls = document.getElementById('qty-amount-controls');
|
|
|
|
const active = 'py-3 px-3 rounded-xl font-bold text-xs sm:text-sm transition bg-teal-500 text-white shadow-md touch-manipulation flex items-center justify-center gap-2';
|
|
const inactive = 'py-3 px-3 rounded-xl font-bold text-xs sm:text-sm transition text-slate-300 hover:text-white touch-manipulation flex items-center justify-center gap-2';
|
|
|
|
if (mode === 'packet') {
|
|
btnPack.className = active;
|
|
btnAmount.className = inactive;
|
|
packControls.classList.remove('hidden');
|
|
amountControls.classList.add('hidden');
|
|
} else {
|
|
btnAmount.className = active;
|
|
btnPack.className = inactive;
|
|
amountControls.classList.remove('hidden');
|
|
packControls.classList.add('hidden');
|
|
}
|
|
|
|
initLucide();
|
|
}
|
|
|
|
// Packet count stepper
|
|
function changePacketCount(delta) {
|
|
state.manage.packetCount = Math.max(1, state.manage.packetCount + delta);
|
|
document.getElementById('manage-pack-count').textContent = state.manage.packetCount;
|
|
}
|
|
|
|
function setPacketCount(num) {
|
|
state.manage.packetCount = num;
|
|
document.getElementById('manage-pack-count').textContent = num;
|
|
}
|
|
|
|
// Specific amount slider & delta
|
|
function onManageAmountSlider(val) {
|
|
state.manage.amountValue = parseFloat(val);
|
|
document.getElementById('manage-amount-val').textContent = state.manage.amountValue;
|
|
}
|
|
|
|
function applyManageDelta(delta) {
|
|
state.manage.amountValue = Math.max(0, state.manage.amountValue + delta);
|
|
document.getElementById('manage-amount-slider').value = state.manage.amountValue;
|
|
document.getElementById('manage-amount-val').textContent = state.manage.amountValue;
|
|
}
|
|
|
|
function setManageAmountRatio(ratio) {
|
|
if (!state.manage.selectedItem) return;
|
|
state.manage.amountValue = Math.round(state.manage.selectedItem.initial_quantity * ratio);
|
|
document.getElementById('manage-amount-slider').value = state.manage.amountValue;
|
|
document.getElementById('manage-amount-val').textContent = state.manage.amountValue;
|
|
}
|
|
|
|
// ----------------------------------------------------
|
|
// EXECUTION: Commit Add / Remove
|
|
// ----------------------------------------------------
|
|
|
|
async function confirmManageExecution() {
|
|
const item = state.manage.selectedItem;
|
|
const action = state.manage.action;
|
|
const mode = state.manage.qtyMode;
|
|
|
|
if (!item || !action) {
|
|
showToast('Please select Add or Remove action first', 'error');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
if (mode === 'packet') {
|
|
const endpoint = action === 'add' ? `/api/items/${item.id}/add-packet` : `/api/items/${item.id}/remove-packet`;
|
|
const res = await fetch(endpoint, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ count: state.manage.packetCount })
|
|
});
|
|
|
|
if (res.ok) {
|
|
const updated = await res.json();
|
|
playBeepSound();
|
|
showToast(`✓ ${action === 'add' ? 'Added' : 'Removed'} ${state.manage.packetCount} packet(s) of ${updated.name} (Now: ${updated.quantity} ${updated.unit})`, 'success');
|
|
clearSelectedManageItem();
|
|
await loadStats();
|
|
await loadItems();
|
|
} else {
|
|
showToast('Failed to update packet', 'error');
|
|
}
|
|
} else {
|
|
// Specific Grams / Amount mode
|
|
const newQty = state.manage.amountValue;
|
|
const res = await fetch(`/api/items/${item.id}/quantity`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ newQuantity: newQty, markOpened: true })
|
|
});
|
|
|
|
if (res.ok) {
|
|
const updated = await res.json();
|
|
playBeepSound();
|
|
showToast(`✓ Set ${updated.name} to ${updated.quantity} ${updated.unit}`, 'success');
|
|
clearSelectedManageItem();
|
|
await loadStats();
|
|
await loadItems();
|
|
} else {
|
|
showToast('Failed to update amount', 'error');
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error('Execution error:', err);
|
|
showToast('Network error while updating item', 'error');
|
|
}
|
|
}
|
|
|
|
// ----------------------------------------------------
|
|
// Add / Edit Product Modal Logic
|
|
// ----------------------------------------------------
|
|
|
|
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 Product';
|
|
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 Product';
|
|
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) {
|
|
const saved = await res.json();
|
|
showToast(itemId ? 'Product updated' : 'Product added to fridge', 'success');
|
|
closeItemModal();
|
|
await loadStats();
|
|
await loadItems();
|
|
// Auto-select in manage tab
|
|
selectManageItem(saved);
|
|
switchTab('manage');
|
|
} else {
|
|
const err = await res.json();
|
|
showToast(err.error || 'Failed to save product', 'error');
|
|
}
|
|
} catch (err) {
|
|
console.error(err);
|
|
showToast('Network error while saving product', 'error');
|
|
}
|
|
}
|
|
|
|
// ----------------------------------------------------
|
|
// Tablet Live Camera Barcode Scanner
|
|
// ----------------------------------------------------
|
|
|
|
function openScannerModal() {
|
|
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: 260, height: 160 },
|
|
aspectRatio: 1.333
|
|
};
|
|
|
|
await state.html5QrCode.start(
|
|
{ facingMode: "environment" },
|
|
config,
|
|
onBarcodeScannedSuccess,
|
|
onBarcodeScanError
|
|
);
|
|
|
|
state.isScanning = true;
|
|
document.getElementById('scanner-status').textContent = 'Point tablet 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();
|
|
closeScannerModal();
|
|
|
|
// Check if item exists in fridge
|
|
const existing = state.items.find(i => i.barcode === decodedText);
|
|
if (existing) {
|
|
switchTab('manage');
|
|
selectManageItem(existing);
|
|
showToast(`Scanned: ${existing.name}`, 'success');
|
|
return;
|
|
}
|
|
|
|
// Otherwise fetch from API / Open Food Facts
|
|
try {
|
|
const res = await fetch(`/api/barcode/${encodeURIComponent(decodedText)}`);
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
openItemModal();
|
|
applyProductDataToForm(data);
|
|
showToast(`Scanned: ${data.name}. Click Save to add!`, 'success');
|
|
} else {
|
|
openItemModal();
|
|
document.getElementById('modal-barcode').value = decodedText;
|
|
showToast(`Scanned barcode: ${decodedText}. Enter details.`, 'info');
|
|
}
|
|
} catch (err) {
|
|
console.error(err);
|
|
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, '&')
|
|
.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-2xl border shadow-2xl text-xs sm:text-sm font-semibold 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);
|
|
}
|
|
}
|