Reorganise pantry view
This commit is contained in:
@@ -3,6 +3,9 @@ FROM nginx:alpine
|
||||
|
||||
COPY nginx/default.conf /etc/nginx/conf.d/default.conf
|
||||
COPY index.html /usr/share/nginx/html/
|
||||
COPY manifest.webmanifest /usr/share/nginx/html/
|
||||
COPY sw.js /usr/share/nginx/html/
|
||||
COPY icons /usr/share/nginx/html/icons
|
||||
COPY js /usr/share/nginx/html/js
|
||||
COPY css /usr/share/nginx/html/css
|
||||
|
||||
|
||||
BIN
stacks/recipe/icons/apple-touch-icon.png
Normal file
BIN
stacks/recipe/icons/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 564 B |
BIN
stacks/recipe/icons/icon-192.png
Normal file
BIN
stacks/recipe/icons/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 593 B |
BIN
stacks/recipe/icons/icon-512.png
Normal file
BIN
stacks/recipe/icons/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.1 KiB |
@@ -3,7 +3,14 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="theme-color" content="#111827">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="apple-mobile-web-app-title" content="Recipe">
|
||||
<title>Recipe App - Modular</title>
|
||||
<link rel="manifest" href="./manifest.webmanifest">
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="./icons/icon-192.png">
|
||||
<link rel="apple-touch-icon" href="./icons/apple-touch-icon.png">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
<style>
|
||||
@@ -27,11 +34,16 @@
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border-width: 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gray-100 flex justify-center p-4 font-sans text-gray-800">
|
||||
<body class="m-0 min-h-dvh bg-white font-sans text-gray-800">
|
||||
|
||||
<div id="app-container" class="w-full max-w-[400px] h-[800px] bg-white border border-gray-300 rounded-3xl overflow-hidden relative flex flex-col shadow-xl">
|
||||
<div id="app-container" class="relative flex h-dvh min-h-0 w-full flex-col overflow-hidden bg-white">
|
||||
</div>
|
||||
|
||||
<script>
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.register('./sw.js', { scope: './' }).catch(() => {});
|
||||
}
|
||||
</script>
|
||||
<script type="module" src="js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -2,586 +2,462 @@ import {
|
||||
INGREDIENTS,
|
||||
CATEGORY_LABELS,
|
||||
pantryQtyStep,
|
||||
splitStockIntoPacks,
|
||||
} 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, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
/* ── helpers ── */
|
||||
|
||||
function esc(s) {
|
||||
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function pantryUnitLabel(u) {
|
||||
if (u === 'szt') return 'szt.';
|
||||
return u;
|
||||
function unitLabel(u) {
|
||||
return u === 'szt' ? 'szt.' : 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}))`;
|
||||
const CATEGORY_ICONS = {
|
||||
pieczywo: 'fa-bread-slice',
|
||||
nabial: 'fa-cheese',
|
||||
mieso_ryby: 'fa-drumstick-bite',
|
||||
warzywa: 'fa-carrot',
|
||||
owoce: 'fa-apple-whole',
|
||||
suche: 'fa-wheat-awn',
|
||||
przyprawy: 'fa-leaf',
|
||||
inne: 'fa-jar',
|
||||
};
|
||||
|
||||
/** @type {string | null} */
|
||||
let shopPickerIngredientId = null;
|
||||
/** @type {number} */
|
||||
let shopPickerStep = 1;
|
||||
/** Czy licznik w arkuszu to liczba opakowań (vs. jednostki magazynowe). */
|
||||
let shopPickerUsesPacks = false;
|
||||
/* ── state ── */
|
||||
|
||||
let showOnlyStock = false;
|
||||
let editingId = null;
|
||||
/** @type {Set<string>} */
|
||||
const selectedCategories = new Set();
|
||||
|
||||
let editShopStep = 1;
|
||||
let editShopUsesPacks = false;
|
||||
|
||||
const BOTTOM = '5.25rem';
|
||||
const HIDDEN_Y = `translateY(calc(100% + ${BOTTOM}))`;
|
||||
|
||||
/* ══════════════════════ HTML SHELL ══════════════════════ */
|
||||
|
||||
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 class="shrink-0 bg-white border-b border-gray-100 mt-3 px-4 pt-2 pb-2.5 space-y-2">
|
||||
<div class="flex items-center gap-2.5 bg-gray-100 rounded-2xl px-3.5 py-2.5 focus-within:ring-2 focus-within:ring-gray-900/10 transition-all">
|
||||
<i class="fas fa-search text-gray-400 text-xs"></i>
|
||||
<input type="search" id="pantry-search" autocomplete="off" placeholder="Szukaj produktu…"
|
||||
class="flex-1 bg-transparent outline-none text-sm text-gray-800 placeholder-gray-400" />
|
||||
</div>
|
||||
<div id="pantry-category-chips" class="flex gap-1.5 overflow-x-auto no-scrollbar -mx-1 px-1 pb-0.5"></div>
|
||||
<div class="flex items-center justify-end">
|
||||
<label class="flex items-center gap-2 cursor-pointer select-none">
|
||||
<span class="text-[11px] font-medium text-gray-500">Tylko na stanie</span>
|
||||
<button type="button" id="pantry-stock-toggle" role="switch" aria-checked="false"
|
||||
class="relative w-10 h-[22px] rounded-full bg-gray-200 transition-colors duration-200 shrink-0">
|
||||
<span class="absolute left-0.5 top-0.5 w-[18px] h-[18px] bg-white rounded-full shadow-sm transition-transform duration-200"></span>
|
||||
</button>
|
||||
</label>
|
||||
</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 id="pantry-scroll" class="flex-1 overflow-y-auto no-scrollbar">
|
||||
<div id="pantry-board" class="px-4 pt-3 pb-4 space-y-1"></div>
|
||||
</div>
|
||||
|
||||
<!-- ── product sheet ── -->
|
||||
<div id="pv2-edit-bg" class="absolute inset-0 z-[38] bg-black/40 hidden opacity-0 transition-opacity duration-200"></div>
|
||||
<div id="pv2-edit-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-5 pt-2 pb-4 flex flex-col gap-2.5 max-h-[75%] min-h-0 overflow-y-auto no-scrollbar"
|
||||
style="bottom:${BOTTOM};transform:${HIDDEN_Y};transition:transform 300ms cubic-bezier(.32,.72,0,1)">
|
||||
<div class="w-10 h-1 bg-gray-200 rounded-full mx-auto shrink-0"></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>
|
||||
<h2 id="pv2-edit-name" class="text-[15px] font-bold text-gray-900 leading-snug"></h2>
|
||||
<p id="pv2-edit-meta" class="text-[11px] text-gray-500"></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 class="shrink-0 flex items-center gap-2">
|
||||
<span class="text-[10px] font-semibold uppercase tracking-wider text-gray-400 w-[3.2rem] shrink-0">Zapas</span>
|
||||
<button type="button" id="pv2-edit-minus" class="w-9 h-9 rounded-xl bg-gray-100 text-gray-700 hover:bg-gray-200 flex items-center justify-center transition-colors active:scale-95 shrink-0">
|
||||
<i class="fas fa-minus text-xs"></i>
|
||||
</button>
|
||||
<div class="flex items-baseline gap-0.5">
|
||||
<input type="number" id="pv2-edit-qty" min="0" step="1" inputmode="decimal"
|
||||
class="w-14 text-center text-lg font-bold tabular-nums bg-transparent outline-none" value="0" />
|
||||
<span id="pv2-edit-unit" class="text-xs text-gray-400 font-medium"></span>
|
||||
</div>
|
||||
<button type="button" id="pv2-edit-plus" class="w-9 h-9 rounded-xl bg-gray-100 text-gray-700 hover:bg-gray-200 flex items-center justify-center transition-colors active:scale-95 shrink-0">
|
||||
<i class="fas fa-plus text-xs"></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 class="border-t border-gray-100 shrink-0"></div>
|
||||
|
||||
<div class="shrink-0 space-y-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-[10px] font-semibold uppercase tracking-wider text-gray-400 w-[3.2rem] shrink-0">Lista</span>
|
||||
<button type="button" id="pv2-shop-minus" class="w-9 h-9 rounded-xl bg-gray-100 text-gray-700 hover:bg-gray-200 flex items-center justify-center transition-colors active:scale-95 shrink-0">
|
||||
<i class="fas fa-minus text-xs"></i>
|
||||
</button>
|
||||
<div class="flex items-baseline gap-0.5">
|
||||
<input type="number" id="pv2-shop-qty" min="1" step="1" inputmode="numeric"
|
||||
class="w-14 text-center text-lg font-bold tabular-nums bg-transparent outline-none" value="1" />
|
||||
<span id="pv2-shop-unit" class="text-xs text-gray-400 font-medium"></span>
|
||||
</div>
|
||||
<button type="button" id="pv2-shop-plus" class="w-9 h-9 rounded-xl bg-gray-100 text-gray-700 hover:bg-gray-200 flex items-center justify-center transition-colors active:scale-95 shrink-0">
|
||||
<i class="fas fa-plus text-xs"></i>
|
||||
</button>
|
||||
<button type="button" id="pv2-shop-add" class="ml-auto shrink-0 px-3.5 py-2 rounded-xl bg-gray-900 text-white text-[11px] font-semibold hover:bg-black transition-colors active:scale-95">
|
||||
<i class="fas fa-cart-plus text-[9px] mr-1"></i>Dodaj
|
||||
</button>
|
||||
</div>
|
||||
<p id="pv2-shop-hint" class="text-[10px] text-gray-400 pl-[3.5rem]"></p>
|
||||
</div>
|
||||
|
||||
<div id="pv2-edit-nutrition" class="shrink-0"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
</div>`;
|
||||
}
|
||||
|
||||
let pantryFilterCategory = '';
|
||||
|
||||
/** Zwijanie sekcji (ignorowane przy aktywnym wyszukiwaniu — widać wszystkie trafienia). */
|
||||
let pantryAccordionHaveOpen = true;
|
||||
let pantryAccordionCatalogOpen = false;
|
||||
|
||||
/** Po zmianie ilości przez próg 0 ↔ zapas karta zostaje wizualnie w tej samej sekcji przez chwilę. */
|
||||
const PANTRY_SECTION_PIN_MS = 1400;
|
||||
|
||||
/** @type {Record<string, { section: 'have'|'catalog', until: number }>} */
|
||||
const pantrySectionPins = {};
|
||||
/** @type {Record<string, ReturnType<typeof setTimeout>>} */
|
||||
const pantryPinTimers = {};
|
||||
|
||||
/**
|
||||
* @param {string} id
|
||||
* @param {'have'|'catalog'} section
|
||||
*/
|
||||
function pinPantrySection(id, section) {
|
||||
pantrySectionPins[id] = { section, until: Date.now() + PANTRY_SECTION_PIN_MS };
|
||||
if (pantryPinTimers[id]) {
|
||||
clearTimeout(pantryPinTimers[id]);
|
||||
delete pantryPinTimers[id];
|
||||
}
|
||||
pantryPinTimers[id] = setTimeout(() => {
|
||||
delete pantrySectionPins[id];
|
||||
delete pantryPinTimers[id];
|
||||
renderPantryResults();
|
||||
}, PANTRY_SECTION_PIN_MS);
|
||||
}
|
||||
/* ══════════════════════ CATEGORY CHIPS (multi-select) ══════════════════════ */
|
||||
|
||||
function allCategoryKeys() {
|
||||
const s = new Set();
|
||||
Object.values(INGREDIENTS).forEach((d) => s.add(d.category));
|
||||
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');
|
||||
const wrap = document.getElementById('pantry-category-chips');
|
||||
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;
|
||||
wrap.innerHTML = keys.map(k => {
|
||||
const active = selectedCategories.has(k);
|
||||
const icon = CATEGORY_ICONS[k] || 'fa-jar';
|
||||
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>`;
|
||||
? 'shrink-0 inline-flex items-center gap-1 px-3 py-1.5 rounded-full text-[11px] font-semibold bg-gray-900 text-white transition-colors'
|
||||
: 'shrink-0 inline-flex items-center gap-1 px-3 py-1.5 rounded-full text-[11px] font-semibold bg-gray-100 text-gray-600 hover:bg-gray-200 transition-colors';
|
||||
return `<button type="button" data-cat="${esc(k)}" class="pv2-cat-chip ${cls}"><i class="fas ${icon} text-[9px]"></i>${esc(categoryLabel(k))}</button>`;
|
||||
}).join('');
|
||||
|
||||
wrap.querySelectorAll('.pantry-cat-btn').forEach((btn) => {
|
||||
wrap.querySelectorAll('.pv2-cat-chip').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
pantryFilterCategory = btn.getAttribute('data-pantry-cat') || '';
|
||||
const cat = btn.dataset.cat;
|
||||
if (selectedCategories.has(cat)) selectedCategories.delete(cat);
|
||||
else selectedCategories.add(cat);
|
||||
renderCategoryChips();
|
||||
renderPantryResults();
|
||||
renderBoard();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function filterIds(searchRaw) {
|
||||
/* ══════════════════════ BOARD RENDERING ══════════════════════ */
|
||||
|
||||
function getFilteredIds(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'));
|
||||
return Object.keys(INGREDIENTS).filter(id => {
|
||||
const d = INGREDIENTS[id];
|
||||
if (selectedCategories.size > 0 && !selectedCategories.has(d.category)) return false;
|
||||
if (!q) return true;
|
||||
return d.name.toLowerCase().includes(q) || (CATEGORY_LABELS[d.category] || '').toLowerCase().includes(q);
|
||||
}).sort((a, b) => INGREDIENTS[a].name.localeCompare(INGREDIENTS[b].name, 'pl'));
|
||||
}
|
||||
|
||||
function packStockCaption(def, stockQty) {
|
||||
const split = splitStockIntoPacks(def, stockQty);
|
||||
if (!split || !def.purchasePack) return '';
|
||||
const u = pantryUnitLabel(def.pantryUnit);
|
||||
const hint = def.purchasePack.label || `${def.purchasePack.amount} ${u}`;
|
||||
const { fullPacks, remainder } = split;
|
||||
if (fullPacks <= 0 && remainder <= 0) {
|
||||
return `<span class="text-[10px] text-gray-500 block mt-1">Kupujesz w: ${escapeHtml(hint)}</span>`;
|
||||
}
|
||||
const bits = [];
|
||||
if (fullPacks > 0) bits.push(`${fullPacks}× opak.`);
|
||||
if (remainder > 0) bits.push(`+ ${remainder} ${u}`);
|
||||
return `<span class="text-[10px] text-gray-600 block mt-1">${escapeHtml(bits.join(' '))} <span class="text-gray-400">(${escapeHtml(hint)})</span></span>`;
|
||||
}
|
||||
function chipHtml(id, pantry) {
|
||||
const def = INGREDIENTS[id];
|
||||
const qty = Number(pantry[id]) || 0;
|
||||
const u = unitLabel(def.pantryUnit);
|
||||
|
||||
/** Mała ikona w prawym górnym rogu karty — rozwija panel w dół. */
|
||||
function nutritionCornerToggle(ingredientId) {
|
||||
const panelId = `pantry-nut-${ingredientId}`;
|
||||
return `
|
||||
<button type="button"
|
||||
class="pantry-nutrition-toggle shrink-0 -mr-0.5 -mt-0.5 flex h-8 w-8 items-center justify-center rounded-lg text-gray-400 hover:text-gray-700 hover:bg-white/90 active:bg-white border border-transparent hover:border-gray-200/70 transition-colors"
|
||||
aria-expanded="false"
|
||||
aria-controls="${escapeHtml(panelId)}"
|
||||
title="Wartości odżywcze"
|
||||
aria-label="Pokaż lub ukryj wartości odżywcze">
|
||||
<i class="fas fa-chevron-down pantry-nutrition-chevron text-[11px] transition-transform duration-200" aria-hidden="true"></i>
|
||||
if (qty > 0) {
|
||||
return `<button type="button" class="pv2-chip inline-flex flex-col items-start px-3 py-2 rounded-xl bg-emerald-50 border border-emerald-200/80 text-left hover:bg-emerald-100/80 transition-colors active:scale-[0.96]" data-id="${esc(id)}">
|
||||
<span class="text-[12px] font-semibold text-gray-900 leading-tight whitespace-nowrap">${esc(def.name)}</span>
|
||||
<span class="text-[10px] text-emerald-600 font-semibold tabular-nums leading-tight mt-0.5">${Math.round(qty)} ${esc(u)}</span>
|
||||
</button>`;
|
||||
}
|
||||
|
||||
return `<button type="button" class="pv2-chip inline-flex items-center px-3 py-2 rounded-xl border border-dashed border-gray-200 text-left hover:border-gray-300 hover:bg-white transition-colors active:scale-[0.96] group" data-id="${esc(id)}">
|
||||
<span class="text-[12px] font-medium text-gray-400 group-hover:text-gray-600 whitespace-nowrap transition-colors">${esc(def.name)}</span>
|
||||
<i class="fas fa-plus text-[7px] text-gray-300 group-hover:text-gray-500 ml-1.5 transition-colors"></i>
|
||||
</button>`;
|
||||
}
|
||||
|
||||
function groupByCategory(ids) {
|
||||
/** @type {Map<string, string[]>} */
|
||||
const groups = new Map();
|
||||
for (const id of ids) {
|
||||
const cat = INGREDIENTS[id].category;
|
||||
if (!groups.has(cat)) groups.set(cat, []);
|
||||
groups.get(cat).push(id);
|
||||
}
|
||||
return [...groups.keys()]
|
||||
.sort((a, b) => categoryLabel(a).localeCompare(categoryLabel(b)))
|
||||
.map(cat => ({ cat, ids: groups.get(cat) }));
|
||||
}
|
||||
|
||||
function renderBoard() {
|
||||
const root = document.getElementById('pantry-board');
|
||||
if (!root) return;
|
||||
|
||||
const q = document.getElementById('pantry-search')?.value || '';
|
||||
const pantry = loadPantry();
|
||||
const allFiltered = getFilteredIds(q);
|
||||
const visible = showOnlyStock
|
||||
? allFiltered.filter(id => (Number(pantry[id]) || 0) > 0)
|
||||
: allFiltered;
|
||||
|
||||
if (visible.length === 0) {
|
||||
root.innerHTML = showOnlyStock
|
||||
? `<div class="flex flex-col items-center justify-center py-16 text-center">
|
||||
<div class="w-16 h-16 rounded-full bg-gray-100 flex items-center justify-center mb-4">
|
||||
<i class="fas fa-box-open text-2xl text-gray-300"></i>
|
||||
</div>
|
||||
<p class="text-sm font-semibold text-gray-700">Nic na stanie</p>
|
||||
<p class="text-xs text-gray-500 mt-1 max-w-[220px] leading-relaxed">Wyłącz filtr, aby zobaczyć cały katalog produktów</p>
|
||||
</div>`
|
||||
: `<p class="text-sm text-gray-500 text-center py-10">Brak wyników — zmień wyszukiwanie lub filtry.</p>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const groups = groupByCategory(visible);
|
||||
let html = '';
|
||||
for (const { cat, ids } of groups) {
|
||||
const icon = CATEGORY_ICONS[cat] || 'fa-jar';
|
||||
html += `
|
||||
<div class="mb-3 last:mb-0">
|
||||
<p class="text-[11px] font-semibold text-gray-400 uppercase tracking-wider mb-1.5 px-0.5">
|
||||
<i class="fas ${icon} text-[9px] mr-1"></i>${esc(categoryLabel(cat))}
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-1.5">${ids.map(id => chipHtml(id, pantry)).join('')}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
root.innerHTML = html;
|
||||
|
||||
root.querySelectorAll('.pv2-chip').forEach(btn => {
|
||||
btn.addEventListener('click', () => openEditSheet(btn.dataset.id));
|
||||
});
|
||||
}
|
||||
|
||||
/* ══════════════════════ STOCK TOGGLE ══════════════════════ */
|
||||
|
||||
function updateToggleVisuals() {
|
||||
const btn = document.getElementById('pantry-stock-toggle');
|
||||
if (!btn) return;
|
||||
const thumb = btn.querySelector('span');
|
||||
btn.setAttribute('aria-checked', String(showOnlyStock));
|
||||
if (showOnlyStock) {
|
||||
btn.classList.remove('bg-gray-200');
|
||||
btn.classList.add('bg-emerald-500');
|
||||
thumb?.classList.add('translate-x-[18px]');
|
||||
} else {
|
||||
btn.classList.add('bg-gray-200');
|
||||
btn.classList.remove('bg-emerald-500');
|
||||
thumb?.classList.remove('translate-x-[18px]');
|
||||
}
|
||||
}
|
||||
|
||||
/* ══════════════════════ EDIT BOTTOM SHEET ══════════════════════ */
|
||||
|
||||
function openEditSheet(ingredientId) {
|
||||
const def = INGREDIENTS[ingredientId];
|
||||
if (!def) return;
|
||||
editingId = ingredientId;
|
||||
|
||||
const pantry = loadPantry();
|
||||
const qty = Number(pantry[ingredientId]) || 0;
|
||||
const u = unitLabel(def.pantryUnit);
|
||||
const step = pantryQtyStep(ingredientId);
|
||||
const pack = def.purchasePack;
|
||||
|
||||
const nameEl = document.getElementById('pv2-edit-name');
|
||||
if (nameEl) nameEl.textContent = def.name;
|
||||
|
||||
const metaEl = document.getElementById('pv2-edit-meta');
|
||||
if (metaEl) {
|
||||
let meta = categoryLabel(def.category);
|
||||
if (pack) meta += ` · ${pack.label || `${pack.amount} ${u}`}`;
|
||||
metaEl.textContent = meta;
|
||||
}
|
||||
|
||||
const qtyEl = document.getElementById('pv2-edit-qty');
|
||||
if (qtyEl) qtyEl.value = qty > 0 ? String(Math.round(qty)) : '0';
|
||||
|
||||
const unitEl = document.getElementById('pv2-edit-unit');
|
||||
if (unitEl) unitEl.textContent = u;
|
||||
|
||||
editShopUsesPacks = Boolean(pack && pack.amount > 0);
|
||||
editShopStep = editShopUsesPacks ? 1 : step;
|
||||
|
||||
const shopQtyEl = document.getElementById('pv2-shop-qty');
|
||||
if (shopQtyEl) shopQtyEl.value = String(editShopStep);
|
||||
|
||||
const shopUnitEl = document.getElementById('pv2-shop-unit');
|
||||
if (shopUnitEl) shopUnitEl.textContent = editShopUsesPacks ? 'opak.' : u;
|
||||
|
||||
const shopHintEl = document.getElementById('pv2-shop-hint');
|
||||
if (shopHintEl) {
|
||||
if (editShopUsesPacks) {
|
||||
const lab = pack.label || `${pack.amount} ${u}`;
|
||||
shopHintEl.textContent = `1 opak. = ${lab}`;
|
||||
} else {
|
||||
shopHintEl.textContent = '';
|
||||
}
|
||||
}
|
||||
|
||||
renderNutritionInSheet(def);
|
||||
|
||||
const bg = document.getElementById('pv2-edit-bg');
|
||||
const sheet = document.getElementById('pv2-edit-sheet');
|
||||
if (!bg || !sheet) return;
|
||||
bg.classList.remove('hidden');
|
||||
sheet.classList.remove('hidden');
|
||||
requestAnimationFrame(() => {
|
||||
bg.classList.remove('opacity-0');
|
||||
sheet.style.transform = 'translateY(0)';
|
||||
});
|
||||
}
|
||||
|
||||
function nutritionListRow(label, valueHtml) {
|
||||
return `<li class="flex items-baseline justify-between gap-3 py-0.5 border-b border-gray-100/80 last:border-0">
|
||||
<span class="text-gray-500 shrink-0">${escapeHtml(label)}</span>
|
||||
<span class="text-gray-500 shrink-0">${esc(label)}</span>
|
||||
<span class="text-right font-semibold tabular-nums text-gray-800">${valueHtml}</span>
|
||||
</li>`;
|
||||
}
|
||||
|
||||
function nutritionPanelHtml(def, ingredientId) {
|
||||
function renderNutritionInSheet(def) {
|
||||
const wrap = document.getElementById('pv2-edit-nutrition');
|
||||
if (!wrap) return;
|
||||
const n = def.nutritionPer100g;
|
||||
if (!n) return '';
|
||||
const panelId = `pantry-nut-${ingredientId}`;
|
||||
const refLabel = def.pantryUnit === 'ml' ? '100 ml produktu' : '100 g produktu';
|
||||
if (!n) { wrap.innerHTML = ''; return; }
|
||||
|
||||
const refList = `
|
||||
const refLabel = def.pantryUnit === 'ml' ? '100 ml produktu' : '100 g produktu';
|
||||
wrap.innerHTML = `
|
||||
<div class="text-[10px] leading-snug mt-0.5 pt-2 border-t border-gray-100 space-y-1">
|
||||
<p class="text-[9px] font-semibold uppercase tracking-wide text-gray-500 px-0.5">${esc(refLabel)}</p>
|
||||
<ul class="space-y-0 rounded-lg bg-white/70 px-2 py-1 ring-1 ring-gray-100/90">
|
||||
${nutritionListRow('Energia', `${n.kcal} kcal`)}
|
||||
${nutritionListRow('Białko', `${n.protein} g`)}
|
||||
${nutritionListRow('Tłuszcz', `${n.fat} g`)}
|
||||
${nutritionListRow('Węglowodany', `${n.carbs} g`)}
|
||||
</ul>`;
|
||||
|
||||
return `
|
||||
<div id="${escapeHtml(panelId)}"
|
||||
class="pantry-nutrition-panel hidden text-[10px] leading-snug"
|
||||
role="region"
|
||||
aria-label="Wartości odżywcze">
|
||||
<div class="mt-2 pt-2.5 border-t border-gray-200/70 space-y-1">
|
||||
<p class="text-[9px] font-semibold uppercase tracking-wide text-gray-500 px-0.5">${escapeHtml(refLabel)}</p>
|
||||
${refList}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
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
|
||||
? `<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 = pantryQtyStep(id);
|
||||
const pack = def.purchasePack;
|
||||
const packPill = pack
|
||||
? `<span class="shrink-0 inline-flex items-center px-2 py-0.5 rounded-lg bg-violet-100 text-violet-800 text-[10px] font-bold">${escapeHtml(pack.label || `${pack.amount} ${unit}`)}</span>`
|
||||
: '';
|
||||
|
||||
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 `
|
||||
<div class="${shell}" data-ingredient-id="${escapeHtml(id)}" data-pantry-variant="${variant}">
|
||||
<div class="flex items-start justify-between gap-1 mb-1">
|
||||
<div class="min-w-0 flex-1 pr-1">
|
||||
<div class="flex items-start gap-2 flex-wrap">
|
||||
<p class="text-sm font-semibold text-gray-900">${escapeHtml(def.name)}</p>
|
||||
${packPill}
|
||||
</div>
|
||||
<p class="text-[10px] text-gray-500 mt-0.5">${escapeHtml(categoryLabel(def.category))} · stan w ${unit}</p>
|
||||
${packStockCaption(def, qty)}
|
||||
</div>
|
||||
${hasNutrition ? nutritionCornerToggle(id) : ''}
|
||||
</div>
|
||||
${hasNutrition ? nutritionPanelHtml(def, id) : ''}
|
||||
<div class="flex flex-wrap items-center gap-2 mt-2.5">
|
||||
<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="decimal" data-pantry-qty data-pantry-step="${step}"
|
||||
class="w-[4.25rem] text-center text-sm font-semibold tabular-nums bg-transparent border-0 outline-none py-1"
|
||||
value="${val}" placeholder="0" title="${escapeHtml(stepHint)}" />
|
||||
<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>
|
||||
<span class="text-[10px] text-gray-400">${escapeHtml(stepHint)}</span>
|
||||
<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>
|
||||
</ul>
|
||||
</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 = 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;
|
||||
|
||||
31
stacks/recipe/manifest.webmanifest
Normal file
31
stacks/recipe/manifest.webmanifest
Normal file
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
13
stacks/recipe/sw.js
Normal file
13
stacks/recipe/sw.js
Normal file
@@ -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));
|
||||
});
|
||||
Reference in New Issue
Block a user