Files
homelab/stacks/recipe/js/views/Pantry.js

413 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { INGREDIENTS, CATEGORY_LABELS } from '../data/catalog.js';
import { addIngredientToKitchenList, categoryLabel, loadPantry, setPantryQty } from '../services/pantryShopping.js';
import { showAppToast } from '../ui/toast.js';
function escapeHtml(s) {
return String(s)
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function pantryUnitLabel(u) {
if (u === 'szt') return 'szt.';
return u;
}
function normalizeSearch(q) {
return String(q).trim().toLowerCase();
}
const PANTRY_SHOP_BOTTOM = '5.25rem';
const PANTRY_SHOP_OFF = `translateY(calc(100% + ${PANTRY_SHOP_BOTTOM}))`;
/** @type {string | null} */
let shopPickerIngredientId = null;
/** @type {number} */
let shopPickerStep = 1;
export function getPantryHTML() {
return `
<div id="pantry-view" class="hidden flex flex-col h-full absolute inset-0 overflow-hidden bg-gray-50 z-10 pb-24">
<div class="shrink-0 bg-white border-b border-gray-200 mt-3 px-4 pt-2 pb-3 space-y-3">
<div class="flex items-center w-full border border-gray-300 rounded-xl bg-white focus-within:border-gray-400 transition-colors">
<span class="pl-3 text-gray-400"><i class="fas fa-search text-sm"></i></span>
<input type="search" id="pantry-search" autocomplete="off" placeholder="Szukaj po nazwie…" class="flex-1 py-2.5 px-2 bg-transparent outline-none text-sm text-gray-800 placeholder-gray-400" />
</div>
<div id="pantry-category-filters" class="flex gap-1.5 overflow-x-auto no-scrollbar pb-0.5 -mx-1 px-1"></div>
</div>
<div id="pantry-scroll" class="flex-1 overflow-y-auto px-4 pt-3 pb-4 no-scrollbar">
<div id="pantry-results" class="space-y-2"></div>
</div>
<div id="pantry-shop-backdrop" class="absolute inset-0 z-[38] bg-black/40 hidden opacity-0 transition-opacity duration-200" aria-hidden="true"></div>
<div id="pantry-shop-sheet" class="absolute left-0 right-0 z-[40] bg-white rounded-t-3xl shadow-[0_-10px_40px_rgba(0,0,0,0.12)] px-4 pt-2 pb-5 flex flex-col gap-3 max-h-[55%] min-h-0" style="bottom: ${PANTRY_SHOP_BOTTOM}; transform: ${PANTRY_SHOP_OFF}; transition: transform 300ms cubic-bezier(0.32, 0.72, 0, 1)" role="dialog" aria-labelledby="pantry-shop-heading" aria-modal="true">
<div class="w-10 h-1 bg-gray-200 rounded-full mx-auto shrink-0" aria-hidden="true"></div>
<div class="shrink-0">
<h2 id="pantry-shop-heading" class="text-lg font-bold text-gray-900 leading-tight"></h2>
<p id="pantry-shop-sub" class="text-xs text-gray-500 mt-1"></p>
</div>
<div class="flex items-center justify-center gap-4 py-2 shrink-0">
<button type="button" id="pantry-shop-minus" class="w-11 h-11 rounded-xl bg-gray-100 text-gray-800 hover:bg-gray-200 flex items-center justify-center transition-colors" aria-label="Mniej na liście"><i class="fas fa-minus text-sm"></i></button>
<input type="number" id="pantry-shop-qty" min="1" step="1" inputmode="numeric" class="w-24 text-center text-2xl font-bold tabular-nums border border-gray-200 rounded-xl py-2 outline-none focus:border-gray-400" value="1" />
<button type="button" id="pantry-shop-plus" class="w-11 h-11 rounded-xl bg-gray-100 text-gray-800 hover:bg-gray-200 flex items-center justify-center transition-colors" aria-label="Więcej na liście"><i class="fas fa-plus text-sm"></i></button>
</div>
<button type="button" id="pantry-shop-add" class="shrink-0 w-full py-3.5 rounded-xl bg-gray-900 text-white text-sm font-semibold hover:bg-black transition-colors">Dodaj na listę</button>
<button type="button" id="pantry-shop-cancel" class="shrink-0 w-full py-2 text-sm font-medium text-gray-500 hover:text-gray-800">Anuluj</button>
</div>
</div>
`;
}
let pantryFilterCategory = '';
/** Zwijanie sekcji (ignorowane przy aktywnym wyszukiwaniu — widać wszystkie trafienia). */
let pantryAccordionHaveOpen = true;
let pantryAccordionCatalogOpen = false;
function allCategoryKeys() {
const s = new Set();
Object.values(INGREDIENTS).forEach((d) => s.add(d.category));
return [...s].sort((a, b) => categoryLabel(a).localeCompare(categoryLabel(b)));
}
function renderCategoryChips() {
const wrap = document.getElementById('pantry-category-filters');
if (!wrap) return;
const keys = allCategoryKeys();
const chips = [
{ id: '', label: 'Wszystkie' },
...keys.map((k) => ({ id: k, label: categoryLabel(k) })),
];
wrap.innerHTML = chips.map((c) => {
const active = c.id === pantryFilterCategory;
const cls = active
? 'shrink-0 px-3 py-1.5 rounded-full text-[11px] font-semibold bg-gray-900 text-white'
: 'shrink-0 px-3 py-1.5 rounded-full text-[11px] font-semibold bg-gray-100 text-gray-600 hover:bg-gray-200';
return `<button type="button" data-pantry-cat="${escapeHtml(c.id)}" class="pantry-cat-btn ${cls}">${escapeHtml(c.label)}</button>`;
}).join('');
wrap.querySelectorAll('.pantry-cat-btn').forEach((btn) => {
btn.addEventListener('click', () => {
pantryFilterCategory = btn.getAttribute('data-pantry-cat') || '';
renderCategoryChips();
renderPantryResults();
});
});
}
function filterIds(searchRaw) {
const q = normalizeSearch(searchRaw);
return Object.keys(INGREDIENTS)
.filter((id) => {
const d = INGREDIENTS[id];
if (pantryFilterCategory && d.category !== pantryFilterCategory) return false;
if (!q) return true;
const name = d.name.toLowerCase();
const cat = (CATEGORY_LABELS[d.category] || '').toLowerCase();
return name.includes(q) || cat.includes(q);
})
.sort((a, b) => INGREDIENTS[a].name.localeCompare(INGREDIENTS[b].name, 'pl'));
}
/** Krok +/-: tylko liczby całkowite (szt. ±1, g/ml ±10). */
function qtyStepForIngredient(id) {
const u = INGREDIENTS[id]?.pantryUnit;
return u === 'szt' ? 1 : 10;
}
function splitHaveAndCatalog(ids, pantry) {
const have = ids.filter((id) => (Number(pantry[id]) || 0) > 0);
const catalogOnly = ids.filter((id) => !pantry[id] || Number(pantry[id]) <= 0);
return { have, catalogOnly };
}
/**
* @param {'have' | 'catalog'} sectionKey
* @param {{ title: string, hint: string, count: number, tone: 'emerald' | 'slate', open: boolean, searching: boolean, bodyInner: string }} opts
*/
function pantryAccordionSection(sectionKey, opts) {
const { title, hint, count, tone, open, searching, bodyInner } = opts;
const showToggle = !searching && count > 0;
const isOpen = searching || open || count === 0;
const ring = tone === 'emerald' ? 'ring-emerald-100/90' : 'ring-gray-200/90';
const dot = tone === 'emerald' ? 'bg-emerald-500 shadow-[0_0_0_3px_rgba(16,185,129,0.2)]' : 'bg-slate-400 shadow-[0_0_0_3px_rgba(148,163,184,0.25)]';
const chevronRot = isOpen ? '' : '-rotate-90';
const rowCls =
'w-full flex items-center gap-3 px-3.5 py-3 text-left min-h-[3.25rem]' +
(showToggle ? ' hover:bg-gray-50/80 transition-colors pantry-acc-toggle cursor-pointer' : '');
const chevron =
showToggle
? `<span class="shrink-0 w-8 h-8 rounded-xl bg-gray-100 flex items-center justify-center text-gray-600" aria-hidden="true"><i class="fas fa-chevron-down text-[10px] transition-transform duration-200 pantry-acc-chevron ${chevronRot}"></i></span>`
: '';
const headerInner = `
<span class="${dot} w-2 h-2 rounded-full shrink-0" aria-hidden="true"></span>
<span class="flex-1 min-w-0">
<span class="flex items-baseline flex-wrap gap-x-2 gap-y-0.5">
<span class="text-[13px] font-bold text-gray-900 tracking-tight">${escapeHtml(title)}</span>
<span class="text-xs font-semibold tabular-nums text-gray-500">${count}</span>
</span>
<span class="block text-[11px] text-gray-500 mt-0.5">${escapeHtml(hint)}</span>
</span>
${chevron}`;
const header =
showToggle
? `<button type="button" class="${rowCls}" data-pantry-acc="${escapeHtml(sectionKey)}" aria-expanded="${isOpen}">${headerInner}</button>`
: `<div class="${rowCls}">${headerInner}</div>`;
return `
<section class="rounded-2xl bg-white border border-gray-200/90 shadow-sm ring-1 ${ring} overflow-hidden mb-3 last:mb-0" data-pantry-acc-wrap="${escapeHtml(sectionKey)}">
${header}
<div class="pantry-acc-panel px-2.5 pb-2.5 pt-0 ${isOpen ? '' : 'hidden'}" data-pantry-acc-panel="${escapeHtml(sectionKey)}">
${bodyInner}
</div>
</section>`;
}
function pantryCardHtml(id, pantry, variant) {
const def = INGREDIENTS[id];
const unit = pantryUnitLabel(def.pantryUnit);
const qty = Number(pantry[id]) || 0;
const val = qty > 0 ? String(Math.round(qty)) : '';
const step = qtyStepForIngredient(id);
const shell = variant === 'have'
? 'rounded-xl border border-emerald-200 bg-gradient-to-br from-emerald-50/80 to-white p-3 shadow-sm ring-1 ring-emerald-100/80'
: 'rounded-xl border border-dashed border-gray-200 bg-gray-50/90 p-3 shadow-sm';
return `
<div class="${shell}" data-ingredient-id="${escapeHtml(id)}" data-pantry-variant="${variant}">
<div class="flex items-start justify-between gap-2 mb-2">
<div class="min-w-0">
<p class="text-sm font-semibold text-gray-900">${escapeHtml(def.name)}</p>
<p class="text-[10px] text-gray-500 mt-0.5">${escapeHtml(categoryLabel(def.category))} · magazyn: ${unit}</p>
</div>
</div>
<div class="flex flex-wrap items-center gap-2">
<div class="flex items-center gap-1 ${variant === 'have' ? 'bg-emerald-100/60' : 'bg-gray-100'} rounded-lg p-0.5">
<button type="button" class="pantry-qty-minus w-8 h-8 rounded-md bg-white shadow-sm text-gray-700 hover:text-gray-900 flex items-center justify-center" aria-label="Mniej"><i class="fas fa-minus text-[10px]"></i></button>
<input type="number" min="0" step="1" inputmode="numeric" pattern="[0-9]*" data-pantry-qty data-pantry-step="${step}"
class="w-16 text-center text-sm font-semibold tabular-nums bg-transparent border-0 outline-none py-1"
value="${val}" placeholder="0" title="Wpisz liczbę całkowitą; +/ zmienia o ${step} ${unit}" />
<button type="button" class="pantry-qty-plus w-8 h-8 rounded-md bg-white shadow-sm text-gray-700 hover:text-gray-900 flex items-center justify-center" aria-label="Więcej"><i class="fas fa-plus text-[10px]"></i></button>
</div>
<button type="button" class="pantry-add-shop ml-auto flex items-center gap-1.5 px-3 py-2 rounded-lg bg-gray-900 text-white text-xs font-semibold hover:bg-black transition-colors">
<i class="fas fa-cart-plus text-[10px]"></i>
Na listę…
</button>
</div>
</div>`;
}
function renderPantryResults() {
const root = document.getElementById('pantry-results');
if (!root) return;
const searchEl = document.getElementById('pantry-search');
const q = searchEl?.value || '';
const searching = normalizeSearch(q) !== '';
const pantry = loadPantry();
const ids = filterIds(q);
if (ids.length === 0) {
root.innerHTML = '<p class="text-sm text-gray-500 text-center py-10">Brak wyników — zmień wyszukiwanie lub filtr kategorii.</p>';
return;
}
const { have, catalogOnly } = splitHaveAndCatalog(ids, pantry);
const haveBody = have.length
? `<div class="space-y-2">${have.map((id) => pantryCardHtml(id, pantry, 'have')).join('')}</div>`
: '<p class="text-xs text-gray-500 text-center py-6 px-2">Żaden z widocznych produktów nie ma jeszcze zapasu — ustaw ilość w katalogu poniżej.</p>';
const catBody = catalogOnly.length
? `<div class="space-y-2">${catalogOnly.map((id) => pantryCardHtml(id, pantry, 'catalog')).join('')}</div>`
: '<p class="text-xs text-gray-500 text-center py-6 px-2">Wszystkie widoczne pozycje są na stanie.</p>';
const haveHint =
have.length === 0
? 'Brak zapasu w tym widoku'
: have.length === 1
? '1 produkt z zapasem'
: `${have.length} produktów z zapasem`;
const catHint =
catalogOnly.length === 0
? 'Nic do uzupełnienia w tym widoku'
: catalogOnly.length === 1
? '1 pozycja bez zapasu'
: `${catalogOnly.length} pozycji bez zapasu`;
root.innerHTML =
pantryAccordionSection('have', {
title: 'Na stanie',
hint: haveHint,
count: have.length,
tone: 'emerald',
open: pantryAccordionHaveOpen,
searching,
bodyInner: haveBody,
}) +
pantryAccordionSection('catalog', {
title: 'Katalog — bez zapasu',
hint: catHint,
count: catalogOnly.length,
tone: 'slate',
open: pantryAccordionCatalogOpen,
searching,
bodyInner: catBody,
});
root.querySelectorAll('.pantry-acc-toggle').forEach((btn) => {
btn.addEventListener('click', () => {
const key = btn.getAttribute('data-pantry-acc');
if (key === 'have') pantryAccordionHaveOpen = !pantryAccordionHaveOpen;
else if (key === 'catalog') pantryAccordionCatalogOpen = !pantryAccordionCatalogOpen;
renderPantryResults();
});
});
root.querySelectorAll('[data-ingredient-id]').forEach((card) => {
const id = card.getAttribute('data-ingredient-id');
if (!id) return;
const input = card.querySelector('[data-pantry-qty]');
const step = parseInt(String(input?.getAttribute('data-pantry-step')), 10) || qtyStepForIngredient(id);
const applyQty = (n) => {
const v = Math.max(0, Math.round(Number(n)) || 0);
setPantryQty(id, v);
if (input) {
input.value = v > 0 ? String(v) : '';
}
const prevVariant = card.getAttribute('data-pantry-variant');
const nowHave = v > 0;
const expectVariant = nowHave ? 'have' : 'catalog';
if (prevVariant !== expectVariant) {
renderPantryResults();
}
};
card.querySelector('.pantry-qty-minus')?.addEventListener('click', () => {
const cur = Math.round(parseFloat(String(input?.value).replace(',', '.')) || 0);
applyQty(Math.max(0, cur - step));
});
card.querySelector('.pantry-qty-plus')?.addEventListener('click', () => {
const cur = Math.round(parseFloat(String(input?.value).replace(',', '.')) || 0);
applyQty(cur + step);
});
input?.addEventListener('change', () => {
const raw = String(input.value).replace(',', '.').trim();
const v = raw === '' ? 0 : Math.round(parseFloat(raw));
applyQty(Number.isFinite(v) ? v : 0);
});
card.querySelector('.pantry-add-shop')?.addEventListener('click', () => {
openPantryShopPicker(id);
});
});
}
function readShopPickerQty() {
const el = document.getElementById('pantry-shop-qty');
const n = Math.round(parseFloat(String(el?.value).replace(',', '.')) || 0);
return Math.max(1, n);
}
function setShopPickerQtyDisplay(v) {
const el = document.getElementById('pantry-shop-qty');
if (el) el.value = String(Math.max(1, Math.round(v)));
}
function openPantryShopPicker(ingredientId) {
const def = INGREDIENTS[ingredientId];
if (!def) return;
shopPickerIngredientId = ingredientId;
shopPickerStep = qtyStepForIngredient(ingredientId);
const unit = pantryUnitLabel(def.pantryUnit);
const heading = document.getElementById('pantry-shop-heading');
const sub = document.getElementById('pantry-shop-sub');
if (heading) heading.textContent = `Ile dodać: ${def.name}?`;
if (sub) {
sub.textContent = `Jednostka na liście: ${unit}. Przyciski +/: ${shopPickerStep} ${unit}.`;
}
setShopPickerQtyDisplay(shopPickerStep);
const backdrop = document.getElementById('pantry-shop-backdrop');
const sheet = document.getElementById('pantry-shop-sheet');
if (!backdrop || !sheet) return;
sheet.classList.remove('hidden');
backdrop.classList.remove('hidden');
requestAnimationFrame(() => {
backdrop.classList.remove('opacity-0');
sheet.style.transform = 'translateY(0)';
});
}
function closePantryShopPicker() {
shopPickerIngredientId = null;
const backdrop = document.getElementById('pantry-shop-backdrop');
const sheet = document.getElementById('pantry-shop-sheet');
if (sheet) {
sheet.style.transform = PANTRY_SHOP_OFF;
}
if (backdrop) {
backdrop.classList.add('opacity-0');
}
setTimeout(() => {
backdrop?.classList.add('hidden');
sheet?.classList.add('hidden');
}, 300);
}
function bindPantryShopSheet() {
document.getElementById('pantry-shop-backdrop')?.addEventListener('click', closePantryShopPicker);
document.getElementById('pantry-shop-cancel')?.addEventListener('click', closePantryShopPicker);
document.getElementById('pantry-shop-minus')?.addEventListener('click', () => {
const cur = readShopPickerQty();
setShopPickerQtyDisplay(Math.max(1, cur - shopPickerStep));
});
document.getElementById('pantry-shop-plus')?.addEventListener('click', () => {
const cur = readShopPickerQty();
setShopPickerQtyDisplay(cur + shopPickerStep);
});
document.getElementById('pantry-shop-qty')?.addEventListener('change', () => {
setShopPickerQtyDisplay(readShopPickerQty());
});
document.getElementById('pantry-shop-add')?.addEventListener('click', () => {
if (!shopPickerIngredientId) return;
const qty = readShopPickerQty();
addIngredientToKitchenList(shopPickerIngredientId, qty);
showAppToast(`Dodano ${qty} na listę kuchni.`);
closePantryShopPicker();
window.refreshShopping?.();
});
}
export function refreshPantry() {
renderCategoryChips();
renderPantryResults();
}
export function setupPantry() {
renderCategoryChips();
renderPantryResults();
bindPantryShopSheet();
document.getElementById('pantry-search')?.addEventListener('input', () => {
renderPantryResults();
});
window.refreshPantry = refreshPantry;
}