From b73d3b6250013befccf507b3c72f3dd92721c8a3 Mon Sep 17 00:00:00 2001 From: max Date: Sat, 10 Oct 2026 23:48:28 +0200 Subject: [PATCH] feat(manage): implement interactive Scan/Search -> Add/Remove -> Packet/Amount workflow --- public/app.js | 589 +++++++++++++++++++++++----------------------- public/index.html | 385 ++++++++++++++++++------------ 2 files changed, 531 insertions(+), 443 deletions(-) diff --git a/public/app.js b/public/app.js index bc51368..0860135 100644 --- a/public/app.js +++ b/public/app.js @@ -1,6 +1,6 @@ /** * Fridger Frontend Application - * Tablet-optimized UI: Main -> Scan -> Manage navigation and 3-button product management. + * Tablet-optimized: Main -> Scan -> Manage (Scan/Search -> Add/Remove -> Packet/Amount) */ const state = { @@ -16,8 +16,13 @@ const state = { category: 'All', status: 'all' }, - activeGramsItem: null, - activeGramsValue: 0, + manage: { + selectedItem: null, + action: null, // 'add' or 'remove' + qtyMode: 'packet', // 'packet' or 'amount' + packetCount: 1, + amountValue: 0 + }, html5QrCode: null, isScanning: false }; @@ -79,6 +84,7 @@ async function loadItems() { if (res.ok) { state.items = await res.json(); renderApp(); + renderManageQuickPicker(); } } catch (err) { console.error('Failed to load items:', err); @@ -95,27 +101,33 @@ function switchTab(tab) { const mainBtn = document.getElementById('nav-main-btn'); const manageBtn = document.getElementById('nav-manage-btn'); - const viewContainer = document.getElementById('view-tab-container'); - const manageContainer = document.getElementById('manage-tab-container'); + const mainView = document.getElementById('main-tab-view'); + const manageView = document.getElementById('manage-tab-view'); - const activeClasses = 'flex items-center justify-center gap-2 px-3 sm:px-6 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-3 sm:px-6 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]'; + 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; - viewContainer.classList.remove('hidden'); - manageContainer.classList.add('hidden'); + mainView.classList.remove('hidden'); + manageView.classList.add('hidden'); + renderApp(); } else if (tab === 'manage') { manageBtn.className = activeClasses; mainBtn.className = inactiveClasses; - manageContainer.classList.remove('hidden'); - viewContainer.classList.add('hidden'); + manageView.classList.remove('hidden'); + mainView.classList.add('hidden'); + renderManageQuickPicker(); } - renderApp(); + initLucide(); } +// ---------------------------------------------------- +// Filters & Search (Main Tab) +// ---------------------------------------------------- + let searchDebounceTimer = null; function handleSearch(val) { state.filters.search = val.trim(); @@ -197,35 +209,21 @@ function populateModalSelects() { } // ---------------------------------------------------- -// UI Rendering +// 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'); - document.getElementById('fridge-view-content').innerHTML = ''; - document.getElementById('manage-items-list').innerHTML = ''; + container.innerHTML = ''; return; } emptyState.classList.add('hidden'); - if (state.currentTab === 'main') { - renderFridgeView(); - } else { - renderManageView(); - } - - initLucide(); -} - -// MAIN TAB: Fridge View (Tablet-optimized cards) -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 @@ -284,282 +282,288 @@ function renderFridgeView() { - -
- - - -
+ + `; }).join(''); -} -// MANAGE TAB: Manage Products Interface (Optimized for Tablet with Large Controls) -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 ` -
- -
- - -
- ${item.image_url ? ` - ${escapeHtml(item.name)} - ` : ` -
- -
- `} - -
-
-

${escapeHtml(item.name)}

- ${item.brand ? `${escapeHtml(item.brand)}` : ''} -
-
- ${item.quantity} ${item.unit} - (Pack size: ${item.initial_quantity} ${item.unit}) - • - ${expiryInfo.label} -
-
-
- - -
- - - - - - - - - - - - - - -
- -
- -
- `; - }).join(''); -} - -// ---------------------------------------------------- -// 3 Primary Action Handlers -// ---------------------------------------------------- - -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(`+1 Pack of ${updated.name} (Total: ${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(`-1 Pack 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; +// ---------------------------------------------------- +// 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 = ` +
+ No matching fridge items found. Scan barcode or add as new product! +
+ `; + return; + } + + container.innerHTML = matched.map(item => ` + + `).join(''); + + initLucide(); } -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 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 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 selectManageItemById(id) { + const item = state.items.find(i => i.id === id); + if (item) selectManageItem(item); } -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 selectManageItem(item) { + state.manage.selectedItem = item; + state.manage.action = null; // reset step 2 + state.manage.packetCount = 1; + state.manage.amountValue = item.quantity; -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`; + // 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 = ``; + } else { + imgBox.innerHTML = ``; } + + // 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' }); } -async function saveGramsAdjustment() { - if (!state.activeGramsItem) return; - const itemId = state.activeGramsItem.id; - const newQty = state.activeGramsValue; +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 { - const res = await fetch(`/api/items/${itemId}/quantity`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ newQuantity: newQty, markOpened: true }) - }); + 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) { - 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 removed', 'info'); - closeGramsModal(); + 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(err); } + } catch (err) { + console.error('Execution error:', err); + showToast('Network error while updating item', 'error'); } } // ---------------------------------------------------- -// Item Add / Edit Modal +// Add / Edit Product Modal Logic // ---------------------------------------------------- function openItemModal(itemId = null) { @@ -690,10 +694,14 @@ async function handleItemFormSubmit(event) { }); 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'); @@ -704,22 +712,6 @@ async function handleItemFormSubmit(event) { } } -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 product', 'error'); - } - } -} - // ---------------------------------------------------- // Tablet Live Camera Barcode Scanner // ---------------------------------------------------- @@ -785,17 +777,25 @@ async function stopCameraScanner() { async function onBarcodeScannedSuccess(decodedText) { playBeepSound(); stopCameraScanner(); - document.getElementById('scanner-status').textContent = `Scanned ${decodedText}!`; + 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)}`); - closeScannerModal(); - if (res.ok) { const data = await res.json(); openItemModal(); applyProductDataToForm(data); - showToast(`Scanned: ${data.name}`, 'success'); + showToast(`Scanned: ${data.name}. Click Save to add!`, 'success'); } else { openItemModal(); document.getElementById('modal-barcode').value = decodedText; @@ -803,7 +803,6 @@ async function onBarcodeScannedSuccess(decodedText) { } } catch (err) { console.error(err); - closeScannerModal(); openItemModal(); document.getElementById('modal-barcode').value = decodedText; } diff --git a/public/index.html b/public/index.html index af5d3b6..a474a24 100644 --- a/public/index.html +++ b/public/index.html @@ -3,7 +3,7 @@ - Fridger - Smart Fridge Manager + Fridger - Tablet Fridge Manager @@ -18,9 +18,6 @@ 500: '#14b8a6', 600: '#0d9488', } - }, - screens: { - 'tablet': '640px', } } } @@ -37,74 +34,74 @@ - +
-
+
- +
🧊
- + - +
- +
-
+
@@ -126,154 +123,252 @@
- -
-
- - -
- - - -
- - -
- - - - - -
- -
- - -
- -
-
- - -
+ +
- -
-
- + +
+
+
+ + + +
+ +
+ + + + + +
+
+ +
+
- -
- -