Update planner search and planner editor

This commit is contained in:
2026-04-08 22:16:20 +02:00
parent 4706430316
commit 165f39d0b7
7 changed files with 693 additions and 301 deletions

View File

@@ -37,6 +37,11 @@ import {
syncCalendarTodayButton,
syncCollapsibleCalendarMode,
} from '../ui/mealCalendar.js?v=1';
import {
filterRecipesByQuery,
renderRecipeGrid,
} from '../ui/recipeGrid.js';
import { getRecipeSearchFieldHTML } from '../ui/recipeSearchField.js';
const WEEKDAYS_LONG = [
'Niedziela', 'Poniedziałek', 'Wtorek', 'Środa', 'Czwartek', 'Piątek', 'Sobota',
@@ -45,6 +50,11 @@ const WEEKDAYS_LONG = [
const PLANNER_SHEET_BOTTOM_INSET = '5.25rem';
const PLANNER_SHEET_MAX_HEIGHT = '70vh';
const PLANNER_SHEET_OFF_TRANSFORM = `translateY(calc(100% + ${PLANNER_SHEET_BOTTOM_INSET}))`;
const PLANNER_PICKER_OFF_TRANSFORM = 'translateY(100%)';
const PLANNER_MEAL_SWIPE_MAX = 132;
const PLANNER_MEAL_SWIPE_DELETE_THRESHOLD = 96;
const PICKER_FILTER_MIN_MINUTES = 5;
const PICKER_FILTER_MAX_MINUTES = 120;
function recipesForSlot(slotId) {
return Object.values(RECIPES).filter((r) => r.allowedSlots.includes(slotId));
@@ -115,44 +125,61 @@ export function getMealPlannerHTML() {
</button>
<div id="planner-meal-slots" class="space-y-3 pb-2 bg-[#2d2e2b]"></div>
</div>
</div>
<div id="planner-picker-backdrop" class="absolute left-0 right-0 top-0 z-[45] bg-black/45 hidden opacity-0 transition-opacity duration-200" style="bottom: ${PLANNER_SHEET_BOTTOM_INSET}" aria-hidden="true"></div>
<div id="planner-picker-sheet" class="absolute left-0 right-0 z-[50] rounded-t-3xl shadow-[0_-10px_40px_rgba(0,0,0,0.12)] flex flex-col will-change-transform" style="visibility: hidden; bottom: ${PLANNER_SHEET_BOTTOM_INSET}; height: auto; max-height: ${PLANNER_SHEET_MAX_HEIGHT}; transform: ${PLANNER_SHEET_OFF_TRANSFORM}; transition: transform 300ms cubic-bezier(0.32, 0.72, 0, 1); background:#2d2e2b !important; background-image:none !important;" role="dialog" aria-labelledby="planner-picker-title" aria-modal="true">
<div class="shrink-0 px-4 pt-3 pb-2 border-b border-[#444442] touch-none cursor-grab active:cursor-grabbing select-none" data-planner-sheet-drag-zone aria-label="Przeciągnij w dół, by zamknąć">
<div class="w-10 h-1 bg-[#6d6c67]/75 rounded-full mx-auto mb-2.5" aria-hidden="true"></div>
<h2 id="planner-picker-title" class="text-[15px] font-bold text-[#ddd6ca] leading-tight pr-2">Wybierz przepis</h2>
<p id="planner-picker-sub" class="text-[11px] text-[#9b978f] mt-1"></p>
<div id="planner-picker-backdrop" class="absolute inset-0 z-[55] bg-black/45 hidden opacity-0 transition-opacity duration-200" aria-hidden="true"></div>
<div id="planner-picker-sheet" data-off-transform="${PLANNER_PICKER_OFF_TRANSFORM}" class="absolute inset-x-0 bottom-0 z-[60] flex flex-col will-change-transform rounded-t-[1.85rem] overflow-hidden" style="top:calc(env(safe-area-inset-top) + 0.35rem); visibility: hidden; transform: ${PLANNER_PICKER_OFF_TRANSFORM}; transition: transform 300ms cubic-bezier(0.32, 0.72, 0, 1); background:#2d2e2b !important; background-image:none !important;" role="dialog" aria-label="Wybierz przepis" aria-modal="true">
<div class="pointer-events-none absolute inset-x-0 top-0 z-[2] px-4 pt-3">
<div class="pointer-events-auto pb-4 touch-none cursor-grab active:cursor-grabbing select-none" data-planner-sheet-drag-zone aria-label="Przeciągnij w dół, by zamknąć">
<div class="w-10 h-1 bg-[#6d6c67]/75 rounded-full mx-auto" aria-hidden="true"></div>
</div>
<div class="shrink-0 px-4 pt-2 pb-2">
<input type="text" id="planner-picker-search" class="w-full rounded-xl border border-[#444442] bg-[#2f2f2d] px-3 py-2 text-sm text-[#ddd6ca] outline-none focus:border-[#6d6c67] placeholder:text-[#7d7a74]" placeholder="Szukaj przepisu…" />
</div>
<div id="planner-picker-list" class="min-h-0 flex-1 overflow-y-auto no-scrollbar px-4 py-2.5 pb-8 space-y-2"></div>
</div>
<div id="planner-ing-backdrop" class="absolute left-0 right-0 top-0 z-[45] bg-black/45 hidden opacity-0 transition-opacity duration-200" style="bottom: ${PLANNER_SHEET_BOTTOM_INSET}" aria-hidden="true"></div>
<div id="planner-ing-sheet" class="absolute left-0 right-0 z-[50] rounded-t-3xl shadow-[0_-10px_40px_rgba(0,0,0,0.12)] flex flex-col will-change-transform" style="visibility: hidden; bottom: ${PLANNER_SHEET_BOTTOM_INSET}; height: auto; max-height: ${PLANNER_SHEET_MAX_HEIGHT}; transform: ${PLANNER_SHEET_OFF_TRANSFORM}; transition: transform 300ms cubic-bezier(0.32, 0.72, 0, 1); background:#2d2e2b !important; background-image:none !important;" role="dialog" aria-labelledby="planner-ing-title" aria-modal="true">
<div class="shrink-0 px-4 pt-3 pb-2 border-b border-[#444442] touch-none cursor-grab active:cursor-grabbing select-none" data-planner-sheet-drag-zone aria-label="Przeciągnij w dół, by zamknąć">
<div class="w-10 h-1 bg-[#6d6c67]/75 rounded-full mx-auto mb-2.5" aria-hidden="true"></div>
<h2 id="planner-ing-title" class="text-[15px] font-bold text-[#ddd6ca] leading-tight pr-2">Składniki i spiżarnia</h2>
<p id="planner-ing-sub" class="text-[11px] text-[#9b978f] mt-1">Porównanie potrzeb z zapasami.</p>
</div>
<div id="planner-ing-body" class="min-h-0 flex-1 overflow-y-auto no-scrollbar px-4 py-2 pb-2"></div>
<div id="planner-ing-footer" class="shrink-0 p-4 pt-2 pb-5 border-t border-[#444442] bg-[#2d2e2b] space-y-2">
<button type="button" id="planner-ing-add-all" class="w-full bg-gray-900 hover:bg-black text-white py-3 rounded-xl font-semibold shadow-sm transition-colors text-[13px] flex items-center justify-center gap-2">
<i class="fas fa-cart-plus text-xs" aria-hidden="true"></i>
Dodaj braki na dziś do listy
</button>
<button type="button" id="planner-ing-add-btn" class="hidden w-full border border-[#444442] bg-[#2d2e2b] text-[#d7d2c8] hover:bg-[#3a3a37] py-2.5 rounded-xl font-semibold text-[13px] flex items-center justify-center gap-2 transition-colors">
<i class="fas fa-calendar-week text-[#9b978f] text-[11px]" aria-hidden="true"></i>
Dodaj braki na cały tydzień
</button>
<div class="pointer-events-auto">
${getRecipeSearchFieldHTML({
shellId: 'planner-picker-search-shell',
inputId: 'planner-picker-search',
placeholder: 'Wybierz przepis',
inputAriaLabel: 'Wybierz przepis',
filterButtonId: 'planner-picker-filter-btn',
filterButtonAction: 'openFilters(\'plannerPicker\')',
filterButtonLabel: 'Otwórz filtry',
})}
</div>
</div>
<div id="planner-toast" class="pointer-events-none absolute left-4 right-4 bottom-28 z-[55] opacity-0 translate-y-2 transition-all duration-300" role="status">
<div class="rounded-xl bg-gray-900 text-white text-sm font-medium px-4 py-3 shadow-lg text-center" id="planner-toast-text"></div>
<div id="planner-picker-scroll" class="relative min-h-0 flex-1 overflow-y-auto px-4 pt-28 pb-8 bg-[#2d2e2b]" style="background:#2d2e2b !important;">
<div id="planner-picker-grid" class="grid grid-cols-2 gap-3 bg-[#2d2e2b]" style="background:#2d2e2b !important;"></div>
<div id="planner-picker-empty-state" class="hidden 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-search text-2xl text-gray-300" aria-hidden="true"></i>
</div>
<p class="text-sm font-semibold text-gray-700">Brak wyników</p>
<p class="text-xs text-gray-500 mt-1 max-w-[220px] leading-relaxed">Spróbuj innej nazwy przepisu</p>
</div>
</div>
</div>
<div id="planner-ing-backdrop" class="absolute inset-0 z-[55] bg-black/45 hidden opacity-0 transition-opacity duration-200" aria-hidden="true"></div>
<div id="planner-ing-sheet" class="absolute left-0 right-0 bottom-0 z-[60] rounded-t-3xl shadow-[0_-10px_40px_rgba(0,0,0,0.12)] flex flex-col will-change-transform" style="visibility: hidden; height: auto; max-height: calc(100vh - env(safe-area-inset-top) - 1rem); transform: ${PLANNER_SHEET_OFF_TRANSFORM}; transition: transform 300ms cubic-bezier(0.32, 0.72, 0, 1); background:#2d2e2b !important; background-image:none !important;" role="dialog" aria-labelledby="planner-ing-title" aria-modal="true">
<div class="shrink-0 px-4 pt-3 pb-2 border-b border-[#444442] touch-none cursor-grab active:cursor-grabbing select-none" data-planner-sheet-drag-zone aria-label="Przeciągnij w dół, by zamknąć">
<div class="w-10 h-1 bg-[#6d6c67]/75 rounded-full mx-auto mb-2.5" aria-hidden="true"></div>
<h2 id="planner-ing-title" class="text-[15px] font-bold text-[#ddd6ca] leading-tight pr-2">Składniki i spiżarnia</h2>
<p id="planner-ing-sub" class="text-[11px] text-[#9b978f] mt-1">Porównanie potrzeb z zapasami.</p>
</div>
<div id="planner-ing-body" class="min-h-0 flex-1 overflow-y-auto no-scrollbar px-4 py-2 pb-2"></div>
<div id="planner-ing-footer" class="shrink-0 p-4 pt-2 pb-5 border-t border-[#444442] bg-[#2d2e2b] space-y-2">
<button type="button" id="planner-ing-add-all" class="w-full bg-gray-900 hover:bg-black text-white py-3 rounded-xl font-semibold shadow-sm transition-colors text-[13px] flex items-center justify-center gap-2">
<i class="fas fa-cart-plus text-xs" aria-hidden="true"></i>
Dodaj braki na dziś do listy
</button>
<button type="button" id="planner-ing-add-btn" class="hidden w-full border border-[#444442] bg-[#2d2e2b] text-[#d7d2c8] hover:bg-[#3a3a37] py-2.5 rounded-xl font-semibold text-[13px] flex items-center justify-center gap-2 transition-colors">
<i class="fas fa-calendar-week text-[#9b978f] text-[11px]" aria-hidden="true"></i>
Dodaj braki na cały tydzień
</button>
</div>
</div>
<div id="planner-toast" class="pointer-events-none absolute left-4 right-4 bottom-28 z-[65] opacity-0 translate-y-2 transition-all duration-300" role="status">
<div class="rounded-xl bg-gray-900 text-white text-sm font-medium px-4 py-3 shadow-lg text-center" id="planner-toast-text"></div>
</div>
`;
}
@@ -254,10 +281,14 @@ function openSheet(backdrop, sheet) {
});
}
function getSheetOffTransform(sheet) {
return sheet?.dataset.offTransform || PLANNER_SHEET_OFF_TRANSFORM;
}
function closeSheet(backdrop, sheet) {
if (!backdrop || !sheet) return;
sheet.style.transition = 'transform 300ms cubic-bezier(0.32, 0.72, 0, 1)';
sheet.style.transform = PLANNER_SHEET_OFF_TRANSFORM;
sheet.style.transform = getSheetOffTransform(sheet);
backdrop.classList.add('opacity-0');
setTimeout(() => {
backdrop.classList.add('hidden');
@@ -265,60 +296,117 @@ function closeSheet(backdrop, sheet) {
}, 300);
}
/** Zamykanie panelu: przeciągnięcie nagłówka w dół (pointer). */
function bindPlannerSheetDragClose(sheet, closeFn) {
const zone = sheet.querySelector('[data-planner-sheet-drag-zone]');
/** Zamykanie panelu: przeciągnięcie w dół (pointer). */
function bindPlannerSheetDragClose(sheet, closeFn, options = {}) {
const zone = options.dragSurface || sheet.querySelector('[data-planner-sheet-drag-zone]');
const scrollEl = options.scrollEl || null;
if (!zone || !sheet) return;
let startX = 0;
let startY = 0;
let pulling = false;
let ptrId = null;
let engaged = false;
let startFromHandle = false;
let suppressClickUntil = 0;
const resetVisual = () => {
sheet.style.transition = 'transform 300ms cubic-bezier(0.32, 0.72, 0, 1)';
sheet.style.transform = 'translateY(0)';
};
const stopTracking = () => {
window.removeEventListener('pointermove', onPointerMove);
window.removeEventListener('pointerup', onPointerUp);
window.removeEventListener('pointercancel', onPointerCancel);
ptrId = null;
engaged = false;
startFromHandle = false;
};
const shouldIgnoreTarget = (target) => target instanceof Element
&& Boolean(target.closest('input, textarea, select, [contenteditable="true"]'));
zone.addEventListener('pointerdown', (e) => {
if (e.pointerType === 'mouse' && e.button !== 0) return;
pulling = true;
ptrId = e.pointerId;
startY = e.clientY;
sheet.style.transition = 'none';
zone.setPointerCapture(e.pointerId);
});
if (shouldIgnoreTarget(e.target)) return;
zone.addEventListener('pointermove', (e) => {
if (!pulling || e.pointerId !== ptrId) return;
const dy = Math.max(0, e.clientY - startY);
sheet.style.transform = `translateY(${dy}px)`;
});
zone.addEventListener('pointerup', (e) => {
if (!pulling || e.pointerId !== ptrId) return;
const dy = e.clientY - startY;
pulling = false;
ptrId = null;
try {
zone.releasePointerCapture(e.pointerId);
} catch {
/* ignore */
if (scrollEl && scrollEl.scrollTop > 0 && !(e.target instanceof Element && e.target.closest('[data-planner-sheet-drag-zone]'))) {
return;
}
if (dy > 56) {
ptrId = e.pointerId;
startX = e.clientX;
startY = e.clientY;
engaged = false;
startFromHandle = e.target instanceof Element && Boolean(e.target.closest('[data-planner-sheet-drag-zone]'));
window.addEventListener('pointermove', onPointerMove);
window.addEventListener('pointerup', onPointerUp);
window.addEventListener('pointercancel', onPointerCancel);
});
const onPointerMove = (e) => {
if (e.pointerId !== ptrId) return;
const dx = e.clientX - startX;
const rawDy = e.clientY - startY;
if (!engaged) {
if (Math.abs(dx) < 8 && Math.abs(rawDy) < 8) return;
if (rawDy <= 0 || Math.abs(dx) > Math.abs(rawDy)) {
stopTracking();
resetVisual();
return;
}
if (!startFromHandle && scrollEl && scrollEl.scrollTop > 0) {
stopTracking();
resetVisual();
return;
}
engaged = true;
suppressClickUntil = Date.now() + 250;
sheet.style.transition = 'none';
}
const dy = Math.max(0, rawDy);
sheet.style.transform = `translateY(${dy}px)`;
if (e.cancelable) e.preventDefault();
};
const onPointerUp = (e) => {
if (e.pointerId !== ptrId) return;
const dy = e.clientY - startY;
const shouldClose = engaged && dy > 56;
stopTracking();
if (shouldClose) {
closeFn();
return;
}
resetVisual();
});
};
zone.addEventListener('pointercancel', () => {
pulling = false;
ptrId = null;
const onPointerCancel = (e) => {
if (ptrId !== null && e.pointerId !== ptrId) return;
stopTracking();
resetVisual();
};
zone.addEventListener('click', (e) => {
if (Date.now() < suppressClickUntil) {
e.preventDefault();
e.stopPropagation();
}
}, true);
zone.addEventListener('pointercancel', (e) => {
onPointerCancel(e);
});
}
function renderDayContent(state) {
function renderDayContent(state, onMealRemoved = null) {
const sel = state.selected;
const dayPlan = getDayPlan(state.plans, sel);
const totals = sumDayNutrition(dayPlan);
@@ -385,34 +473,39 @@ function renderDayContent(state) {
(entry.amountOverrides && Object.keys(entry.amountOverrides).length > 0) ||
(entry.addedIngredients?.length > 0) ||
(entry.substitutions && Object.keys(entry.substitutions).length > 0);
const customDot = hasCustom ? '<span class="w-1.5 h-1.5 rounded-full bg-amber-400 inline-block shrink-0 ml-1"></span>' : '';
const customDot = hasCustom ? '<span class="w-1.5 h-1.5 rounded-full bg-amber-400 inline-block shrink-0 ml-1"></span>' : '';
const servLabel = servings > 1 ? `<span class="mx-1.5 text-[#6d6c67]">·</span>×${servings}` : '';
return `
<div class="rounded-lg bg-[#2d2e2b] p-2" style="box-shadow:inset 0 1px 3px rgba(0,0,0,0.3);" data-slot-id="${slot.id}" data-entry-id="${eid}">
<div class="flex items-start justify-between gap-2">
<div class="flex items-center gap-2 min-w-0 cursor-pointer planner-open-recipe" data-recipe-id="${escapeHtml(recipe.id)}">
<div class="w-8 h-8 rounded-lg bg-[#3a3a37] overflow-hidden shrink-0">
${recipe.image
? `<img src="${escapeHtml(recipe.image)}" alt="" class="w-full h-full object-cover">`
: `<span class="w-full h-full flex items-center justify-center text-white text-[8px] font-medium">${escapeHtml(recipe.thumbLabel)}</span>`}
<div class="relative overflow-hidden rounded-lg" data-planner-swipe-row style="--planner-swipe-progress:0;" data-slot-id="${slot.id}" data-entry-id="${eid}">
<div class="pointer-events-none absolute inset-0 flex items-center justify-end px-4" style="background:rgba(203,74,72, calc(0.18 + var(--planner-swipe-progress) * 0.5));">
<span class="inline-flex items-center gap-1.5 text-[11px] font-semibold tracking-wide uppercase" style="color:rgba(250,234,234, calc(0.55 + var(--planner-swipe-progress) * 0.45));">
<i class="fas fa-trash text-[10px]" aria-hidden="true"></i>
Usuń
</span>
</div>
<div class="relative z-[1] rounded-lg bg-[#2d2e2b] p-2" style="box-shadow:inset 0 1px 3px rgba(0,0,0,0.3); transform:translateX(0); transition:transform 180ms cubic-bezier(0.22, 1, 0.36, 1), opacity 180ms ease; touch-action:pan-y;" data-planner-swipe-card data-slot-id="${slot.id}" data-entry-id="${eid}">
<div class="flex items-start justify-between gap-2">
<div class="flex items-center gap-2 min-w-0 cursor-pointer planner-open-recipe" data-recipe-id="${escapeHtml(recipe.id)}">
<div class="w-8 h-8 rounded-lg bg-[#3a3a37] overflow-hidden shrink-0">
${recipe.image
? `<img src="${escapeHtml(recipe.image)}" alt="" class="w-full h-full object-cover">`
: `<span class="w-full h-full flex items-center justify-center text-white text-[8px] font-medium">${escapeHtml(recipe.thumbLabel)}</span>`}
</div>
<div class="min-w-0">
<div class="flex items-center"><p class="text-[13px] font-bold text-[#ddd6ca] truncate underline decoration-1 underline-offset-2">${escapeHtml(recipe.title)}</p>${customDot}</div>
<p class="text-[11px] text-[#9b978f] mt-0.5 tabular-nums">
<i class="fas fa-clock text-[#7d7a74] mr-0.5" aria-hidden="true"></i>${recipe.minutes} min
<span class="mx-1.5 text-[#6d6c67]">·</span>
<i class="fas fa-fire text-[#7d7a74] mr-0.5" aria-hidden="true"></i>${entryN.kcal} kcal${servLabel}
</p>
</div>
</div>
<div class="min-w-0">
<div class="flex items-center"><p class="text-[13px] font-bold text-[#ddd6ca] truncate underline decoration-1 underline-offset-2">${escapeHtml(recipe.title)}</p>${customDot}</div>
<p class="text-[11px] text-[#9b978f] mt-0.5 tabular-nums">
<i class="fas fa-clock text-[#7d7a74] mr-0.5" aria-hidden="true"></i>${recipe.minutes} min
<span class="mx-1.5 text-[#6d6c67]">·</span>
<i class="fas fa-fire text-[#7d7a74] mr-0.5" aria-hidden="true"></i>${entryN.kcal} kcal${servLabel}
</p>
<div class="flex items-center gap-1 shrink-0 self-center">
<button type="button" class="planner-edit-meal w-6 h-6 rounded-full border border-[#444442] text-[#9b978f] hover:text-[#ddd6ca] hover:border-[#6d6c67] hover:bg-[#3a3a37] flex items-center justify-center transition-colors" data-slot-id="${slot.id}" data-entry-id="${eid}" aria-label="Edytuj ten przepis">
<i class="fas fa-pencil text-[9px]" aria-hidden="true"></i>
</button>
</div>
</div>
<div class="flex items-center gap-1 shrink-0">
<button type="button" class="planner-edit-meal w-6 h-6 rounded-full border border-[#444442] text-[#9b978f] hover:text-[#ddd6ca] hover:border-[#6d6c67] hover:bg-[#3a3a37] flex items-center justify-center transition-colors" data-slot-id="${slot.id}" data-entry-id="${eid}" aria-label="Edytuj ten przepis">
<i class="fas fa-pencil text-[9px]" aria-hidden="true"></i>
</button>
<button type="button" class="planner-clear-meal w-6 h-6 rounded-full border border-[#444442] text-[#9b978f] hover:text-red-400 hover:border-red-300/60 hover:bg-[#3a2326] transition-colors flex items-center justify-center" data-slot-id="${slot.id}" data-entry-id="${eid}" aria-label="Usuń ten przepis">
<i class="fas fa-times text-[9px]" aria-hidden="true"></i>
</button>
</div>
</div>
</div>`;
}).join('');
@@ -450,6 +543,147 @@ function renderDayContent(state) {
</div>
</div>`;
}).join('');
bindMealEntrySwipe(state, onMealRemoved);
}
function removeMealEntry(state, slotId, entryId) {
const key = dateKey(state.selected);
const day = state.plans[key];
const arr = day?.[slotId];
if (!Array.isArray(arr) || !entryId) return false;
const next = arr.filter((x) => x && x.id !== entryId);
if (next.length === arr.length) return false;
if (next.length === 0) delete day[slotId];
else day[slotId] = next;
if (Object.keys(day).length === 0) delete state.plans[key];
return true;
}
function bindMealEntrySwipe(state, onMealRemoved = null) {
const cards = document.querySelectorAll('[data-planner-swipe-card]');
cards.forEach((card) => {
const row = card.closest('[data-planner-swipe-row]');
if (!row) return;
let pointerId = null;
let startX = 0;
let startY = 0;
let dx = 0;
let engaged = false;
let suppressClickUntil = 0;
const resetVisual = () => {
row.classList.remove('planner-meal-row-swiping');
row.style.setProperty('--planner-swipe-progress', '0');
row.style.pointerEvents = '';
card.style.willChange = '';
card.style.transition = 'transform 180ms cubic-bezier(0.22, 1, 0.36, 1), opacity 180ms ease';
card.style.transform = 'translateX(0)';
card.style.opacity = '1';
};
const stopTracking = () => {
window.removeEventListener('pointermove', onPointerMove);
window.removeEventListener('pointerup', onPointerUp);
window.removeEventListener('pointercancel', onPointerCancel);
pointerId = null;
};
const onPointerMove = (e) => {
if (e.pointerId !== pointerId) return;
dx = e.clientX - startX;
const dy = e.clientY - startY;
if (!engaged) {
if (Math.abs(dx) < 8 && Math.abs(dy) < 8) return;
suppressClickUntil = Date.now() + 250;
if (Math.abs(dy) > Math.abs(dx) || dx >= 0) {
stopTracking();
resetVisual();
return;
}
engaged = true;
row.classList.add('planner-meal-row-swiping');
card.style.transition = 'none';
card.style.willChange = 'transform';
}
const translateX = Math.max(-PLANNER_MEAL_SWIPE_MAX, Math.min(0, dx));
const progress = Math.min(1, Math.abs(translateX) / PLANNER_MEAL_SWIPE_DELETE_THRESHOLD);
row.style.setProperty('--planner-swipe-progress', String(progress));
card.style.transform = `translateX(${translateX}px)`;
if (e.cancelable) e.preventDefault();
};
const onPointerUp = (e) => {
if (e.pointerId !== pointerId) return;
const shouldDelete = engaged && dx <= -PLANNER_MEAL_SWIPE_DELETE_THRESHOLD;
stopTracking();
if (shouldDelete) {
const slotId = card.getAttribute('data-slot-id');
const entryId = card.getAttribute('data-entry-id');
row.style.pointerEvents = 'none';
row.style.setProperty('--planner-swipe-progress', '1');
card.style.willChange = '';
card.style.transition = 'transform 160ms cubic-bezier(0.32, 0.72, 0, 1), opacity 160ms ease';
card.style.transform = 'translateX(-120%)';
card.style.opacity = '0';
window.setTimeout(() => {
if (removeMealEntry(state, slotId, entryId)) {
if (typeof onMealRemoved === 'function') onMealRemoved();
else {
savePlans(state.plans);
renderDayContent(state);
}
showPlannerToast('Usunięto posiłek z planu dnia');
}
}, 160);
return;
}
engaged = false;
dx = 0;
resetVisual();
};
const onPointerCancel = (e) => {
if (pointerId !== null && e.pointerId !== pointerId) return;
stopTracking();
engaged = false;
dx = 0;
resetVisual();
};
card.addEventListener('click', (e) => {
if (Date.now() < suppressClickUntil) {
e.preventDefault();
e.stopPropagation();
}
}, true);
card.addEventListener('pointerdown', (e) => {
if (pointerId !== null) return;
if (e.pointerType === 'mouse' && e.button !== 0) return;
pointerId = e.pointerId;
startX = e.clientX;
startY = e.clientY;
dx = 0;
engaged = false;
window.addEventListener('pointermove', onPointerMove);
window.addEventListener('pointerup', onPointerUp);
window.addEventListener('pointercancel', onPointerCancel);
});
});
}
function escapeHtml(s) {
@@ -481,71 +715,65 @@ function getRecentRecipeIds(plans, limit = 5) {
return [...seen.keys()];
}
function recipeCardHtml(r) {
return `
<button type="button" class="planner-pick-recipe w-full flex gap-2.5 p-2.5 rounded-xl border border-[#444442] bg-[#2d2e2b] hover:border-[#6d6c67] hover:bg-[#3a3a37] text-left transition-all" data-recipe-id="${r.id}">
<div class="w-11 h-11 rounded-lg bg-[#3a3a37] overflow-hidden shrink-0">
${r.image
? `<img src="${escapeHtml(r.image)}" alt="" class="w-full h-full object-cover">`
: `<span class="w-full h-full flex items-center justify-center text-white text-[9px] font-medium">${escapeHtml(r.thumbLabel)}</span>`}
</div>
<div class="min-w-0 flex-1 py-0.5">
<p class="text-[13px] font-bold text-[#ddd6ca] line-clamp-2">${escapeHtml(r.title)}</p>
<p class="text-[11px] text-[#9b978f] mt-1 tabular-nums">
<i class="fas fa-fire text-[#7d7a74] mr-0.5" aria-hidden="true"></i>${r.nutritionPerServing.kcal} kcal
<span class="mx-1 text-[#6d6c67]">·</span>
<i class="fas fa-clock text-[#7d7a74] mr-0.5" aria-hidden="true"></i>${r.minutes} min
</p>
</div>
</button>`;
function sortPickerRecipes(recipes, plans) {
const recentIndex = new Map(
getRecentRecipeIds(plans, 12).map((recipeId, index) => [recipeId, index]),
);
return [...recipes].sort((a, b) => {
const aRecent = recentIndex.has(a.id) ? recentIndex.get(a.id) : Number.POSITIVE_INFINITY;
const bRecent = recentIndex.has(b.id) ? recentIndex.get(b.id) : Number.POSITIVE_INFINITY;
if (aRecent !== bRecent) return aRecent - bRecent;
return a.title.localeCompare(b.title, 'pl');
});
}
let _pickerSlotRecipes = [];
let _pickerPlans = {};
function matchesPickerFilters(recipe, filterState) {
const slots = Array.isArray(filterState?.slots) ? filterState.slots : [];
const tags = Array.isArray(filterState?.tags) ? filterState.tags : [];
const minMinutes = Number.isFinite(filterState?.minMinutes)
? filterState.minMinutes
: PICKER_FILTER_MIN_MINUTES;
const maxMinutes = Number.isFinite(filterState?.maxMinutes)
? filterState.maxMinutes
: PICKER_FILTER_MAX_MINUTES;
function renderPickerList(slotId, plans, query = '') {
const slot = MEAL_SLOTS.find((s) => s.id === slotId);
const list = document.getElementById('planner-picker-list');
const title = document.getElementById('planner-picker-title');
const sub = document.getElementById('planner-picker-sub');
if (!list || !title || !sub) return;
if (slots.length > 0 && !recipe.allowedSlots.some((slotId) => slots.includes(slotId))) return false;
title.textContent = 'Wybierz przepis';
sub.textContent = slot ? `Dla: ${slot.label}` : '';
const allRecipes = recipesForSlot(slotId);
_pickerSlotRecipes = allRecipes;
_pickerPlans = plans;
const q = query.trim().toLowerCase();
const filtered = q
? allRecipes.filter((r) => r.title.toLowerCase().includes(q) || (r.tags || []).some((t) => t.toLowerCase().includes(q)))
: allRecipes;
if (filtered.length === 0 && q) {
list.innerHTML = '<p class="text-sm text-[#9b978f] text-center py-6">Brak wyników.</p>';
return;
}
if (filtered.length === 0) {
list.innerHTML = '<p class="text-sm text-[#9b978f] text-center py-6">Brak dopasowanych przepisów.</p>';
return;
if (tags.length > 0) {
const recipeTags = (recipe.tags || []).map((tag) => tag.toLowerCase());
if (!tags.some((tag) => recipeTags.includes(tag.toLowerCase()))) return false;
}
let html = '';
if (minMinutes > PICKER_FILTER_MIN_MINUTES && recipe.minutes < minMinutes) return false;
if (maxMinutes < PICKER_FILTER_MAX_MINUTES && recipe.minutes > maxMinutes) return false;
if (!q) {
const recentIds = getRecentRecipeIds(plans);
const recentInSlot = recentIds.map((id) => RECIPES[id]).filter((r) => r && r.allowedSlots.includes(slotId));
if (recentInSlot.length > 0) {
html += `<p class="text-[10px] font-bold text-gray-400 uppercase tracking-wider px-0.5 pt-1 pb-1"><i class="fas fa-history text-[9px] mr-1"></i>Ostatnio używane</p>`;
html += recentInSlot.map(recipeCardHtml).join('');
html += `<div class="border-t border-[#444442] my-2"></div>`;
html += `<p class="text-[10px] font-bold text-gray-400 uppercase tracking-wider px-0.5 pt-1 pb-1">Wszystkie</p>`;
}
}
return true;
}
html += filtered.map(recipeCardHtml).join('');
list.innerHTML = html;
function renderPickerGrid(slotId, plans, query = '') {
const grid = document.getElementById('planner-picker-grid');
const emptyState = document.getElementById('planner-picker-empty-state');
if (!grid || !emptyState) return;
const pickerFilters = window.getPlannerPickerFilterState?.() || {
slots: [],
tags: [],
minMinutes: PICKER_FILTER_MIN_MINUTES,
maxMinutes: PICKER_FILTER_MAX_MINUTES,
};
const filtered = filterRecipesByQuery(recipesForSlot(slotId), query)
.filter((recipe) => matchesPickerFilters(recipe, pickerFilters));
const sorted = sortPickerRecipes(filtered, plans);
renderRecipeGrid({
gridEl: grid,
emptyStateEl: emptyState,
recipes: sorted,
showSlotLabels: false,
cardClassName: 'planner-picker-recipe-card',
});
}
function plIngredientWord(n) {
@@ -755,8 +983,15 @@ export function setupMealPlanner() {
const pickerBackdrop = document.getElementById('planner-picker-backdrop');
const pickerSheet = document.getElementById('planner-picker-sheet');
const pickerScroll = document.getElementById('planner-picker-scroll');
const ingBackdrop = document.getElementById('planner-ing-backdrop');
const ingSheet = document.getElementById('planner-ing-sheet');
const pickerFilterState = {
slots: [],
tags: [],
minMinutes: PICKER_FILTER_MIN_MINUTES,
maxMinutes: PICKER_FILTER_MAX_MINUTES,
};
const rerender = () => {
syncModeToggle(state.mode);
@@ -775,7 +1010,7 @@ export function setupMealPlanner() {
: dayHasAnyMeal(state.plans, day),
}),
});
renderDayContent(state);
renderDayContent(state, persist);
};
const persist = () => {
@@ -881,8 +1116,13 @@ export function setupMealPlanner() {
state.pickerSlot = slotId;
const searchInput = document.getElementById('planner-picker-search');
if (searchInput) searchInput.value = '';
renderPickerList(slotId, state.plans);
const pickerScroll = document.getElementById('planner-picker-scroll');
if (pickerScroll) pickerScroll.scrollTop = 0;
renderPickerGrid(slotId, state.plans);
openSheet(pickerBackdrop, pickerSheet);
window.setTimeout(() => {
if (pickerScroll) pickerScroll.scrollTop = 0;
}, 40);
return;
}
const editBtn = e.target.closest('.planner-edit-meal');
@@ -903,54 +1143,66 @@ export function setupMealPlanner() {
});
return;
}
const clearBtn = e.target.closest('.planner-clear-meal');
if (clearBtn) {
const slotId = clearBtn.getAttribute('data-slot-id');
const entryId = clearBtn.getAttribute('data-entry-id');
const key = dateKey(state.selected);
const arr = state.plans[key]?.[slotId];
if (!Array.isArray(arr) || !entryId) return;
const next = arr.filter((x) => x && x.id !== entryId);
if (!state.plans[key]) state.plans[key] = {};
if (next.length === 0) delete state.plans[key][slotId];
else state.plans[key][slotId] = next;
if (Object.keys(state.plans[key]).length === 0) delete state.plans[key];
persist();
return;
}
});
const closePicker = () => {
state.pickerSlot = null;
const searchInput = document.getElementById('planner-picker-search');
if (searchInput) searchInput.value = '';
if (document.activeElement instanceof HTMLElement) document.activeElement.blur();
closeSheet(pickerBackdrop, pickerSheet);
};
window.getPlannerPickerFilterState = () => ({
...pickerFilterState,
slots: [...pickerFilterState.slots],
tags: [...pickerFilterState.tags],
});
window.applyPlannerPickerFilters = (nextState = {}) => {
Object.assign(pickerFilterState, nextState);
pickerFilterState.slots = Array.isArray(nextState.slots)
? [...nextState.slots]
: [...pickerFilterState.slots];
pickerFilterState.tags = Array.isArray(nextState.tags)
? [...nextState.tags]
: [...pickerFilterState.tags];
if (state.pickerSlot) {
const searchValue = document.getElementById('planner-picker-search')?.value || '';
renderPickerGrid(state.pickerSlot, state.plans, searchValue);
}
};
document.getElementById('planner-picker-search')?.addEventListener('input', (e) => {
if (state.pickerSlot) {
renderPickerList(state.pickerSlot, state.plans, e.target.value);
renderPickerGrid(state.pickerSlot, state.plans, e.target.value);
}
});
bindPlannerSheetDragClose(pickerSheet, closePicker);
bindPlannerSheetDragClose(pickerSheet, closePicker, {
dragSurface: pickerSheet,
scrollEl: pickerScroll,
});
bindPlannerSheetDragClose(ingSheet, () => closeSheet(ingBackdrop, ingSheet));
pickerBackdrop?.addEventListener('click', closePicker);
document.getElementById('planner-picker-list')?.addEventListener('click', (e) => {
const pick = e.target.closest('.planner-pick-recipe');
document.getElementById('planner-picker-grid')?.addEventListener('click', (e) => {
const pick = e.target.closest('.recipe-browser-card');
if (!pick || !state.pickerSlot) return;
const recipeId = pick.getAttribute('data-recipe-id');
if (!recipeId || !RECIPES[recipeId]) return;
const slotId = state.pickerSlot;
closePicker();
setTimeout(() => {
window.requestAnimationFrame(() => {
window.openMealPlanEditor?.({
mode: 'add',
recipeId,
date: state.selected,
slotId,
});
}, 320);
});
});
document.getElementById('planner-open-ingredients')?.addEventListener('click', () => {