Update planner search and planner editor
This commit is contained in:
@@ -20,6 +20,27 @@ const PREP_TIME_MAX = 120;
|
||||
const PREP_TIME_STEP = 5;
|
||||
const PREP_TIME_MIN_GAP = PREP_TIME_STEP;
|
||||
const FILTER_RECIPE_BLUR = 'blur(3px) saturate(0.94)';
|
||||
const FILTER_CONTEXTS = {
|
||||
recipes: {
|
||||
anchorShellId: 'recipe-search-shell',
|
||||
buttonId: 'recipe-filter-btn',
|
||||
getState: () => getFilterState(),
|
||||
applyState: (nextState) => applyFilters(nextState),
|
||||
showSlots: true,
|
||||
},
|
||||
plannerPicker: {
|
||||
anchorShellId: 'planner-picker-search-shell',
|
||||
buttonId: 'planner-picker-filter-btn',
|
||||
getState: () => window.getPlannerPickerFilterState?.() || ({
|
||||
slots: [],
|
||||
tags: [],
|
||||
minMinutes: PREP_TIME_MIN,
|
||||
maxMinutes: PREP_TIME_MAX,
|
||||
}),
|
||||
applyState: (nextState) => window.applyPlannerPickerFilters?.(nextState),
|
||||
showSlots: false,
|
||||
},
|
||||
};
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s)
|
||||
@@ -61,7 +82,7 @@ export function getFilterHTML() {
|
||||
outline: none;
|
||||
}
|
||||
</style>
|
||||
<div id="filter-view" class="absolute inset-0 z-[55] hidden opacity-0 transition-opacity duration-150" style="pointer-events:none; background:rgba(0,0,0,0.5) !important; background-image:none !important;" aria-hidden="true">
|
||||
<div id="filter-view" class="absolute inset-0 z-[70] hidden opacity-0 transition-opacity duration-150" style="pointer-events:none; background:rgba(0,0,0,0.5) !important; background-image:none !important;" aria-hidden="true">
|
||||
<div id="filter-panel" class="absolute flex flex-col overflow-hidden rounded-[1.5rem] border" style="background:${FILTER_SURFACE} !important; background-image:none !important; border-color:${FILTER_BORDER} !important; opacity:0; transform:translateY(-0.5rem) scale(0.98); transform-origin:top center; transition:${FILTER_PANEL_TRANSITION}; box-shadow:0 18px 40px rgba(0,0,0,0.34), 0 4px 12px rgba(0,0,0,0.18); width:min(calc(100% - 1.5rem), 22rem);">
|
||||
<div class="pointer-events-none absolute inset-x-0 top-0 h-px" style="background:rgba(242,239,232,0.12);" aria-hidden="true"></div>
|
||||
<div class="shrink-0 px-3.5 pt-3 pb-2 flex justify-end" style="background:${FILTER_SURFACE} !important; background-image:none !important;">
|
||||
@@ -71,7 +92,7 @@ export function getFilterHTML() {
|
||||
</div>
|
||||
|
||||
<div id="filter-panel-body" class="min-h-0 flex-1 overflow-y-auto no-scrollbar px-4 pb-4 space-y-2.5" style="background:${FILTER_SURFACE} !important; background-image:none !important;">
|
||||
<section class="p-3.5" style="background:${FILTER_SURFACE} !important; background-image:none !important;">
|
||||
<section id="filter-slot-section" class="p-3.5" style="background:${FILTER_SURFACE} !important; background-image:none !important;">
|
||||
<p class="text-[10px] font-bold uppercase tracking-wider mb-3" style="color:${FILTER_TEXT_MUTED};">Pora posiłku</p>
|
||||
<div id="filter-slot-chips" class="flex flex-wrap gap-2"></div>
|
||||
</section>
|
||||
@@ -132,6 +153,11 @@ let localTags = [];
|
||||
let localMinMinutes = PREP_TIME_MIN;
|
||||
let localMaxMinutes = PREP_TIME_MAX;
|
||||
let closeTimer = null;
|
||||
let activeFilterContext = 'recipes';
|
||||
|
||||
function getActiveFilterConfig() {
|
||||
return FILTER_CONTEXTS[activeFilterContext] || FILTER_CONTEXTS.recipes;
|
||||
}
|
||||
|
||||
function normalizeTimeRange(minMinutes, maxMinutes) {
|
||||
let nextMin = snapTimeValue(minMinutes);
|
||||
@@ -216,9 +242,10 @@ function positionFilterPanel() {
|
||||
const view = document.getElementById('filter-view');
|
||||
const panel = document.getElementById('filter-panel');
|
||||
const body = document.getElementById('filter-panel-body');
|
||||
const searchShell = document.getElementById('recipe-search-shell');
|
||||
const button = document.getElementById('recipe-filter-btn');
|
||||
if (!view || !panel || !button) return;
|
||||
const { anchorShellId, buttonId } = getActiveFilterConfig();
|
||||
const searchShell = document.getElementById(anchorShellId);
|
||||
const button = document.getElementById(buttonId);
|
||||
if (!view || !panel || (!searchShell && !button)) return;
|
||||
|
||||
const viewRect = view.getBoundingClientRect();
|
||||
const anchorRect = (searchShell || button).getBoundingClientRect();
|
||||
@@ -327,9 +354,16 @@ function renderTagChips() {
|
||||
});
|
||||
}
|
||||
|
||||
function syncFilterSections() {
|
||||
const slotSection = document.getElementById('filter-slot-section');
|
||||
if (!slotSection) return;
|
||||
slotSection.classList.toggle('hidden', !getActiveFilterConfig().showSlots);
|
||||
}
|
||||
|
||||
function syncLiveFilters() {
|
||||
applyFilters({
|
||||
slots: localSlots,
|
||||
const config = getActiveFilterConfig();
|
||||
config.applyState?.({
|
||||
slots: config.showSlots ? localSlots : [],
|
||||
tags: localTags,
|
||||
minMinutes: localMinMinutes,
|
||||
maxMinutes: localMaxMinutes,
|
||||
@@ -465,15 +499,17 @@ export function setupFilter() {
|
||||
syncLiveFilters();
|
||||
});
|
||||
|
||||
window.openFilters = () => {
|
||||
if (isFilterPanelOpen()) {
|
||||
window.openFilters = (contextName = 'recipes') => {
|
||||
if (isFilterPanelOpen() && activeFilterContext === contextName) {
|
||||
window.closeFilters();
|
||||
return;
|
||||
}
|
||||
|
||||
const state = getFilterState();
|
||||
localSlots = [...state.slots];
|
||||
localTags = [...state.tags];
|
||||
activeFilterContext = FILTER_CONTEXTS[contextName] ? contextName : 'recipes';
|
||||
const config = getActiveFilterConfig();
|
||||
const state = config.getState?.() || {};
|
||||
localSlots = [...(state.slots || [])];
|
||||
localTags = [...(state.tags || [])];
|
||||
const normalized = normalizeTimeRange(
|
||||
Number.isFinite(state.minMinutes) ? state.minMinutes : PREP_TIME_MIN,
|
||||
Number.isFinite(state.maxMinutes) ? state.maxMinutes : PREP_TIME_MAX,
|
||||
@@ -485,6 +521,7 @@ export function setupFilter() {
|
||||
|
||||
renderSlotChips();
|
||||
renderTagChips();
|
||||
syncFilterSections();
|
||||
|
||||
showFilterPanel();
|
||||
};
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -1,25 +1,12 @@
|
||||
import { RECIPES } from '../data/catalog.js?v=8';
|
||||
import { MEAL_SLOTS } from '../planner/mealSlots.js';
|
||||
import { getRecipeGridSectionHTML, renderRecipeGrid } from '../ui/recipeGrid.js';
|
||||
import {
|
||||
getRecipeSearchFieldHTML,
|
||||
syncRecipeSearchShellShadow,
|
||||
} from '../ui/recipeSearchField.js';
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
const slotLabelMap = Object.fromEntries(MEAL_SLOTS.map((s) => [s.id, s.label]));
|
||||
const DEFAULT_MIN_MINUTES = 5;
|
||||
const DEFAULT_MAX_MINUTES = 120;
|
||||
const SEARCH_SHELL_BASE_SHADOW =
|
||||
'0 5px 10px rgba(0,0,0,0.16), 0 14px 22px rgba(0,0,0,0.24), 0 22px 34px rgba(0,0,0,0.18), inset 0 1px 0 rgba(255,255,255,0.04)';
|
||||
|
||||
function slotLabelsFor(recipe) {
|
||||
return (recipe.allowedSlots || [])
|
||||
.map((id) => slotLabelMap[id])
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
let filterState = {
|
||||
query: '',
|
||||
@@ -57,107 +44,49 @@ function getFilteredRecipes() {
|
||||
return Object.values(RECIPES).filter(matchesFilters);
|
||||
}
|
||||
|
||||
function renderRecipeCard(recipe) {
|
||||
const labels = slotLabelsFor(recipe);
|
||||
return `
|
||||
<div data-recipe-id="${escapeHtml(recipe.id)}" onclick="openRecipeDetail('${escapeHtml(recipe.id)}')" class="recipe-card rounded-xl overflow-hidden flex flex-col bg-[#393937] cursor-pointer transition-shadow" style="background:#393937 !important; border:none !important; box-shadow:0 2px 8px rgba(0,0,0,0.28) !important;">
|
||||
<div class="h-32 bg-[#d4d4d4] relative overflow-hidden">
|
||||
${recipe.image
|
||||
? `<img src="${escapeHtml(recipe.image)}" alt="${escapeHtml(recipe.title)}" class="w-full h-full object-cover">`
|
||||
: `<span class="absolute inset-0 flex items-center justify-center text-white font-medium text-xs">${escapeHtml(recipe.thumbLabel)}</span>`}
|
||||
</div>
|
||||
<div class="p-3 flex flex-col flex-1">
|
||||
<h3 class="text-sm font-medium underline decoration-1 underline-offset-2 text-[#f1ede4] mb-3 line-clamp-2">${escapeHtml(recipe.title)}</h3>
|
||||
<div class="mt-auto">
|
||||
<div class="flex items-center justify-between text-[11px] text-[#c2bcb2] font-medium mb-2">
|
||||
<div class="flex items-center gap-1"><i class="fas fa-clock text-[#8f8b84]"></i><span>${recipe.minutes} min</span></div>
|
||||
<div class="flex items-center gap-1"><i class="fas fa-fire text-[#8f8b84]"></i><span>${recipe.nutritionPerServing.kcal} kcal</span></div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
${labels.map((l) => `<span class="px-2 py-0.5 bg-[#2f2f2d] text-[#d7d2c8] text-[10px] rounded-md font-medium">${escapeHtml(l)}</span>`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function getEmptyStateHTML() {
|
||||
return `
|
||||
<div id="recipe-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"></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">Zmień kryteria wyszukiwania lub filtry</p>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function syncRecipeScrollShadow() {
|
||||
const scroll = document.getElementById('recipe-scroll');
|
||||
const searchShell = document.getElementById('recipe-search-shell');
|
||||
if (!searchShell) return;
|
||||
|
||||
if (!scroll) {
|
||||
searchShell.style.boxShadow = SEARCH_SHELL_BASE_SHADOW;
|
||||
return;
|
||||
}
|
||||
|
||||
searchShell.style.boxShadow = SEARCH_SHELL_BASE_SHADOW;
|
||||
syncRecipeSearchShellShadow(searchShell);
|
||||
}
|
||||
|
||||
function renderAllRecipeCards() {
|
||||
const grid = document.getElementById('recipe-grid');
|
||||
if (!grid) return;
|
||||
|
||||
grid.innerHTML = Object.values(RECIPES).map(renderRecipeCard).join('');
|
||||
}
|
||||
|
||||
function syncVisibleRecipeCards() {
|
||||
function renderGrid() {
|
||||
const grid = document.getElementById('recipe-grid');
|
||||
const emptyState = document.getElementById('recipe-empty-state');
|
||||
if (!grid || !emptyState) return;
|
||||
|
||||
let visibleCount = 0;
|
||||
grid.querySelectorAll('[data-recipe-id]').forEach((card) => {
|
||||
const recipeId = card.getAttribute('data-recipe-id');
|
||||
const recipe = recipeId ? RECIPES[recipeId] : null;
|
||||
const isVisible = Boolean(recipe && matchesFilters(recipe));
|
||||
card.classList.toggle('hidden', !isVisible);
|
||||
if (isVisible) visibleCount += 1;
|
||||
});
|
||||
|
||||
grid.classList.toggle('hidden', visibleCount === 0);
|
||||
emptyState.classList.toggle('hidden', visibleCount !== 0);
|
||||
requestAnimationFrame(syncRecipeScrollShadow);
|
||||
}
|
||||
|
||||
function renderGrid({ rebuild = false } = {}) {
|
||||
const grid = document.getElementById('recipe-grid');
|
||||
if (!grid) return;
|
||||
|
||||
if (rebuild || !grid.querySelector('[data-recipe-id]')) {
|
||||
renderAllRecipeCards();
|
||||
}
|
||||
|
||||
syncVisibleRecipeCards();
|
||||
renderRecipeGrid({
|
||||
gridEl: grid,
|
||||
emptyStateEl: emptyState,
|
||||
recipes: getFilteredRecipes(),
|
||||
});
|
||||
requestAnimationFrame(syncRecipeScrollShadow);
|
||||
}
|
||||
|
||||
export function getRecipeListHTML() {
|
||||
return `
|
||||
<div id="main-view" class="flex flex-col h-full absolute inset-0 bg-[#2d2e2b] z-10" style="background:#2d2e2b !important;">
|
||||
<div id="recipe-top-bar" class="pointer-events-none absolute inset-x-0 top-0 z-[12] px-4 pt-4" style="background:transparent !important; border:none !important;">
|
||||
<div id="recipe-search-shell" class="pointer-events-auto relative z-[1] mx-auto flex items-center w-full overflow-hidden" style="width:min(calc(100% - 0.5rem), 22.4rem); background:#393937 !important; border:1px solid #41423f !important; border-radius:999px !important; box-shadow:${SEARCH_SHELL_BASE_SHADOW} !important; transition:box-shadow 180ms ease;">
|
||||
<input type="text" id="recipe-search-input" placeholder="Szukaj przepisów..." class="w-full bg-transparent outline-none text-[15px] text-center py-[12px] pl-8 pr-14" style="background:transparent !important; border:none !important; box-shadow:none !important; backdrop-filter:none !important;">
|
||||
<button id="recipe-filter-btn" onclick="openFilters()" class="absolute right-2 top-1/2 -translate-y-1/2 w-9 h-9 text-[#c9c3b8] hover:text-[#f0e8dc] flex items-center justify-center transition-colors" style="background:transparent !important; border:none !important; box-shadow:none !important;" aria-label="Otwórz filtry">
|
||||
<i class="fas fa-sliders-h"></i>
|
||||
</button>
|
||||
<div class="pointer-events-auto">
|
||||
${getRecipeSearchFieldHTML({
|
||||
shellId: 'recipe-search-shell',
|
||||
inputId: 'recipe-search-input',
|
||||
placeholder: 'Szukaj przepisów...',
|
||||
filterButtonId: 'recipe-filter-btn',
|
||||
filterButtonAction: 'openFilters()',
|
||||
filterButtonLabel: 'Otwórz filtry',
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="recipe-scroll" class="relative flex-1 overflow-y-auto px-4 pt-20 pb-24 bg-[#2d2e2b]" style="background:#2d2e2b !important;">
|
||||
<div id="recipe-grid" class="grid grid-cols-2 gap-3 bg-[#2d2e2b]" style="background:#2d2e2b !important;"></div>
|
||||
${getEmptyStateHTML()}
|
||||
</div>
|
||||
${getRecipeGridSectionHTML({
|
||||
scrollId: 'recipe-scroll',
|
||||
gridId: 'recipe-grid',
|
||||
emptyStateId: 'recipe-empty-state',
|
||||
scrollClassName: 'relative flex-1 overflow-y-auto px-4 pt-20 pb-24 bg-[#2d2e2b]',
|
||||
gridClassName: 'grid grid-cols-2 gap-3 bg-[#2d2e2b]',
|
||||
emptyTitle: 'Brak wyników',
|
||||
emptyMessage: 'Zmień kryteria wyszukiwania lub filtry',
|
||||
})}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -176,17 +105,24 @@ export function getFilteredCount() {
|
||||
}
|
||||
|
||||
export function refreshRecipeList() {
|
||||
renderGrid({ rebuild: true });
|
||||
renderGrid();
|
||||
}
|
||||
|
||||
export function setupRecipeList() {
|
||||
renderGrid({ rebuild: true });
|
||||
renderGrid();
|
||||
|
||||
document.getElementById('recipe-search-input')?.addEventListener('input', (e) => {
|
||||
filterState.query = e.target.value.trim();
|
||||
renderGrid();
|
||||
});
|
||||
|
||||
document.getElementById('recipe-grid')?.addEventListener('click', (e) => {
|
||||
const card = e.target.closest('.recipe-browser-card');
|
||||
if (!card) return;
|
||||
const recipeId = card.getAttribute('data-recipe-id');
|
||||
if (recipeId) window.openRecipeDetail?.(recipeId);
|
||||
});
|
||||
|
||||
document.getElementById('recipe-scroll')?.addEventListener('scroll', syncRecipeScrollShadow);
|
||||
syncRecipeScrollShadow();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user