Brak wyników — zmień wyszukiwanie lub filtry.
`;
+ return;
+ }
+
+ const groups = groupByCategory(visible);
+ let html = '';
+ for (const { cat, ids } of groups) {
+ const icon = CATEGORY_ICONS[cat] || 'fa-jar';
+ html += `
+
+
${esc(refLabel)}
${nutritionListRow('Energia', `${n.kcal} kcal`)}
${nutritionListRow('Białko', `${n.protein} g`)}
${nutritionListRow('Tłuszcz', `${n.fat} g`)}
${nutritionListRow('Węglowodany', `${n.carbs} g`)}
-
`;
-
- return `
-
-
-
${escapeHtml(refLabel)}
- ${refList}
-
-
`;
-}
-
-function splitHaveAndCatalog(ids, pantry) {
- const now = Date.now();
- /** @type {string[]} */
- const have = [];
- /** @type {string[]} */
- const catalogOnly = [];
-
- for (const id of ids) {
- const pin = pantrySectionPins[id];
- if (pin && pin.until > now) {
- if (pin.section === 'have') have.push(id);
- else catalogOnly.push(id);
- continue;
- }
- if (pin && pin.until <= now) {
- delete pantrySectionPins[id];
- if (pantryPinTimers[id]) {
- clearTimeout(pantryPinTimers[id]);
- delete pantryPinTimers[id];
- }
- }
- const qty = Number(pantry[id]) || 0;
- if (qty > 0) have.push(id);
- else catalogOnly.push(id);
- }
- 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
- ? `
`
- : '';
-
- const headerInner = `
-
-
-
- ${escapeHtml(title)}
- ${count}
-
- ${escapeHtml(hint)}
-
- ${chevron}`;
-
- const header =
- showToggle
- ? `
`
- : `
${headerInner}
`;
-
- return `
-
- ${header}
-
- ${bodyInner}
-
- `;
-}
-
-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 = pantryQtyStep(id);
- const pack = def.purchasePack;
- const packPill = pack
- ? `
${escapeHtml(pack.label || `${pack.amount} ${unit}`)}`
- : '';
-
- const stepHint = pack
- ? `+/−: ${step} ${unit} (1 opak.)`
- : `+/−: ${step} ${unit}`;
-
- 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';
-
- const hasNutrition = Boolean(def.nutritionPer100g);
-
- return `
-
-
-
-
-
${escapeHtml(def.name)}
- ${packPill}
-
-
${escapeHtml(categoryLabel(def.category))} · stan w ${unit}
- ${packStockCaption(def, qty)}
-
- ${hasNutrition ? nutritionCornerToggle(id) : ''}
-
- ${hasNutrition ? nutritionPanelHtml(def, id) : ''}
-
-
-
-
-
-
-
${escapeHtml(stepHint)}
-
-
+
`;
}
-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 = '
Brak wyników — zmień wyszukiwanie lub filtr kategorii.
';
- return;
- }
-
- const { have, catalogOnly } = splitHaveAndCatalog(ids, pantry);
-
- const haveBody = have.length
- ? `
${have.map((id) => pantryCardHtml(id, pantry, 'have')).join('')}
`
- : '
Żaden z widocznych produktów nie ma jeszcze zapasu — ustaw ilość w katalogu poniżej.
';
-
- const catBody = catalogOnly.length
- ? `
${catalogOnly.map((id) => pantryCardHtml(id, pantry, 'catalog')).join('')}
`
- : '
Wszystkie widoczne pozycje są na stanie.
';
-
- 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 = parseFloat(String(input?.getAttribute('data-pantry-step'))) || pantryQtyStep(id);
-
- const applyQty = (n) => {
- const v = Math.max(0, Math.round(Number(n) * 1000) / 1000 || 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 === 'have' || prevVariant === 'catalog') {
- if (prevVariant !== expectVariant) {
- const existing = pantrySectionPins[id];
- const t = Date.now();
- const extending = Boolean(
- existing && existing.until > t && existing.section === prevVariant,
- );
- pinPantrySection(id, prevVariant);
- if (!extending) {
- renderPantryResults();
- }
- }
- }
- };
-
- card.querySelector('.pantry-qty-minus')?.addEventListener('click', () => {
- const cur = parseFloat(String(input?.value).replace(',', '.')) || 0;
- applyQty(Math.max(0, cur - step));
- });
- card.querySelector('.pantry-qty-plus')?.addEventListener('click', () => {
- const cur = parseFloat(String(input?.value).replace(',', '.')) || 0;
- applyQty(cur + step);
- });
- input?.addEventListener('change', () => {
- const raw = String(input.value).replace(',', '.').trim();
- const v = raw === '' ? 0 : parseFloat(raw);
- applyQty(Number.isFinite(v) ? v : 0);
- });
- card.querySelector('.pantry-add-shop')?.addEventListener('click', () => {
- openPantryShopPicker(id);
- });
-
- card.querySelector('.pantry-nutrition-toggle')?.addEventListener('click', (ev) => {
- ev.preventDefault();
- const btn = /** @type {HTMLButtonElement} */ (ev.currentTarget);
- const panel = card.querySelector('.pantry-nutrition-panel');
- const chevron = card.querySelector('.pantry-nutrition-chevron');
- if (!panel) return;
- const willOpen = panel.classList.contains('hidden');
- panel.classList.toggle('hidden', !willOpen);
- btn.setAttribute('aria-expanded', String(willOpen));
- chevron?.classList.toggle('rotate-180', willOpen);
- });
- });
+function closeEditSheet() {
+ editingId = null;
+ const bg = document.getElementById('pv2-edit-bg');
+ const sheet = document.getElementById('pv2-edit-sheet');
+ if (sheet) sheet.style.transform = HIDDEN_Y;
+ if (bg) bg.classList.add('opacity-0');
+ setTimeout(() => {
+ bg?.classList.add('hidden');
+ sheet?.classList.add('hidden');
+ }, 300);
+ renderBoard();
}
-function readShopPickerQty() {
- const el = document.getElementById('pantry-shop-qty');
- const raw = parseFloat(String(el?.value).replace(',', '.')) || 0;
- return Math.max(1, Math.round(raw));
+function getEditQty() {
+ const el = document.getElementById('pv2-edit-qty');
+ return Math.max(0, parseFloat(String(el?.value).replace(',', '.')) || 0);
}
-function setShopPickerQtyDisplay(v) {
- const el = document.getElementById('pantry-shop-qty');
+function setEditQty(v) {
+ const el = document.getElementById('pv2-edit-qty');
+ if (el) el.value = String(Math.max(0, Math.round(v)));
+}
+
+function applyEditQty(newQty) {
+ if (!editingId) return;
+ const v = Math.max(0, Math.round(Number(newQty) * 1000) / 1000 || 0);
+ setPantryQty(editingId, v);
+ setEditQty(v);
+}
+
+function readShopQty() {
+ const el = document.getElementById('pv2-shop-qty');
+ return Math.max(1, Math.round(parseFloat(String(el?.value).replace(',', '.')) || 0));
+}
+
+function setShopQty(v) {
+ const el = document.getElementById('pv2-shop-qty');
if (el) el.value = String(Math.max(1, Math.round(Number(v))));
}
-function openPantryShopPicker(ingredientId) {
- const def = INGREDIENTS[ingredientId];
- if (!def) return;
+function bindEditSheet() {
+ document.getElementById('pv2-edit-bg')?.addEventListener('click', closeEditSheet);
- shopPickerIngredientId = ingredientId;
- const unit = pantryUnitLabel(def.pantryUnit);
- const pack = def.purchasePack;
- shopPickerUsesPacks = Boolean(pack && pack.amount > 0);
-
- const heading = document.getElementById('pantry-shop-heading');
- const sub = document.getElementById('pantry-shop-sub');
- if (shopPickerUsesPacks) {
- shopPickerStep = 1;
- if (heading) heading.textContent = `Ile opakowań: ${def.name}?`;
- if (sub) {
- const lab = pack.label || `${pack.amount} ${unit}`;
- sub.textContent = `Jedno = ${lab}. Na listę trafi suma w ${unit} (${lab}).`;
- }
- setShopPickerQtyDisplay(1);
- } else {
- shopPickerStep = pantryQtyStep(ingredientId);
- 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;
- shopPickerUsesPacks = false;
- 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();
- const dec = shopPickerUsesPacks ? 1 : shopPickerStep;
- setShopPickerQtyDisplay(Math.max(1, cur - dec));
- });
- document.getElementById('pantry-shop-plus')?.addEventListener('click', () => {
- const cur = readShopPickerQty();
- const inc = shopPickerUsesPacks ? 1 : shopPickerStep;
- setShopPickerQtyDisplay(cur + inc);
- });
- document.getElementById('pantry-shop-qty')?.addEventListener('change', () => {
- setShopPickerQtyDisplay(readShopPickerQty());
+ document.getElementById('pv2-edit-minus')?.addEventListener('click', () => {
+ if (!editingId) return;
+ applyEditQty(Math.max(0, getEditQty() - pantryQtyStep(editingId)));
});
- document.getElementById('pantry-shop-add')?.addEventListener('click', () => {
- if (!shopPickerIngredientId) return;
- const def = INGREDIENTS[shopPickerIngredientId];
+ document.getElementById('pv2-edit-plus')?.addEventListener('click', () => {
+ if (!editingId) return;
+ applyEditQty(getEditQty() + pantryQtyStep(editingId));
+ });
+
+ document.getElementById('pv2-edit-qty')?.addEventListener('change', () => {
+ applyEditQty(getEditQty());
+ });
+
+ document.getElementById('pv2-shop-minus')?.addEventListener('click', () => {
+ setShopQty(Math.max(1, readShopQty() - editShopStep));
+ });
+
+ document.getElementById('pv2-shop-plus')?.addEventListener('click', () => {
+ setShopQty(readShopQty() + editShopStep);
+ });
+
+ document.getElementById('pv2-shop-qty')?.addEventListener('change', () => {
+ setShopQty(readShopQty());
+ });
+
+ document.getElementById('pv2-shop-add')?.addEventListener('click', () => {
+ if (!editingId) return;
+ const def = INGREDIENTS[editingId];
if (!def) return;
- const count = readShopPickerQty();
- const unit = pantryUnitLabel(def.pantryUnit);
+ const count = readShopQty();
+ const u = unitLabel(def.pantryUnit);
- if (shopPickerUsesPacks && def.purchasePack) {
+ if (editShopUsesPacks && def.purchasePack) {
const packAmt = def.purchasePack.amount;
const total = count * packAmt;
- const note = `${count}× ${def.purchasePack.label || `${packAmt} ${unit}`}`;
- addIngredientToKitchenList(shopPickerIngredientId, total, note);
- showAppToast(`Dodano ${count} op. (${total} ${unit}) na listę kuchni.`);
+ const note = `${count}× ${def.purchasePack.label || `${packAmt} ${u}`}`;
+ addIngredientToKitchenList(editingId, total, note);
+ showAppToast(`Dodano ${count} op. (${total} ${u}) na listę.`);
} else {
- addIngredientToKitchenList(shopPickerIngredientId, count);
- showAppToast(`Dodano ${count} ${unit} na listę kuchni.`);
+ addIngredientToKitchenList(editingId, count);
+ showAppToast(`Dodano ${count} ${u} na listę.`);
}
- closePantryShopPicker();
window.refreshShopping?.();
});
}
+/* ══════════════════════ PUBLIC API ══════════════════════ */
+
export function refreshPantry() {
renderCategoryChips();
- renderPantryResults();
+ renderBoard();
}
export function setupPantry() {
renderCategoryChips();
- renderPantryResults();
- bindPantryShopSheet();
+ renderBoard();
+ bindEditSheet();
- document.getElementById('pantry-search')?.addEventListener('input', () => {
- renderPantryResults();
+ document.getElementById('pantry-search')?.addEventListener('input', () => renderBoard());
+
+ document.getElementById('pantry-stock-toggle')?.addEventListener('click', () => {
+ showOnlyStock = !showOnlyStock;
+ updateToggleVisuals();
+ renderBoard();
});
window.refreshPantry = refreshPantry;
diff --git a/stacks/recipe/manifest.webmanifest b/stacks/recipe/manifest.webmanifest
new file mode 100644
index 0000000..92e00a2
--- /dev/null
+++ b/stacks/recipe/manifest.webmanifest
@@ -0,0 +1,31 @@
+{
+ "name": "Recipe App",
+ "short_name": "Recipe",
+ "description": "Plan posiłków, spiżarnia i zakupy",
+ "start_url": "./",
+ "scope": "./",
+ "display": "standalone",
+ "background_color": "#f3f4f6",
+ "theme_color": "#111827",
+ "lang": "pl",
+ "icons": [
+ {
+ "src": "./icons/icon-192.png",
+ "sizes": "192x192",
+ "type": "image/png",
+ "purpose": "any"
+ },
+ {
+ "src": "./icons/icon-512.png",
+ "sizes": "512x512",
+ "type": "image/png",
+ "purpose": "any"
+ },
+ {
+ "src": "./icons/icon-512.png",
+ "sizes": "512x512",
+ "type": "image/png",
+ "purpose": "maskable"
+ }
+ ]
+}
diff --git a/stacks/recipe/nginx/default.conf b/stacks/recipe/nginx/default.conf
index bce456a..5ba7c60 100644
--- a/stacks/recipe/nginx/default.conf
+++ b/stacks/recipe/nginx/default.conf
@@ -8,6 +8,15 @@ server {
add_header Cache-Control "no-store, no-cache, must-revalidate, max-age=0" always;
add_header Pragma "no-cache" always;
+ location = /manifest.webmanifest {
+ default_type application/manifest+json;
+ add_header Cache-Control "no-store, no-cache, must-revalidate, max-age=0" always;
+ }
+
+ location = /sw.js {
+ add_header Cache-Control "no-store, no-cache, must-revalidate, max-age=0" always;
+ }
+
location / {
try_files $uri $uri/ /index.html;
}
diff --git a/stacks/recipe/sw.js b/stacks/recipe/sw.js
new file mode 100644
index 0000000..da180d5
--- /dev/null
+++ b/stacks/recipe/sw.js
@@ -0,0 +1,13 @@
+/* Minimal service worker — wymagany m.in. przez Chrome, żeby pokazać „Zainstaluj aplikację”.
+ Sieć zawsze na pierwszym miejscu (bez cache’u zasobów). */
+self.addEventListener('install', (event) => {
+ self.skipWaiting();
+});
+
+self.addEventListener('activate', (event) => {
+ event.waitUntil(self.clients.claim());
+});
+
+self.addEventListener('fetch', (event) => {
+ event.respondWith(fetch(event.request));
+});