Files
recipe-mockup/js/views/Filter.js
ulfrxdev 6f902098a8
All checks were successful
Build and Deploy / build-and-push (push) Successful in 31s
Adjust filter button
2026-05-07 19:36:30 +02:00

604 lines
24 KiB
JavaScript

import { RECIPES, CATEGORY_LABELS } from '../data/catalog.js?v=9';
import { MEAL_SLOTS } from '../planner/mealSlots.js';
import { applyFilters, getFilterState } from './RecipeList.js';
import { ensureFilterPopoverStyles, filterChipStyle } from '../ui/filterPopover.js?v=1';
const PANTRY_CATEGORY_ORDER = ['pieczywo', 'nabial', 'mieso_ryby', 'warzywa', 'owoce', 'suche', 'przyprawy', 'inne'];
const PANTRY_SECTION_FILTERS = [
{ id: 'shortfalls', label: 'Potrzebne' },
{ id: 'sufficient', label: 'W spiżarni' },
{ id: 'notPlanned', label: 'Poza planem' },
];
const FILTER_PANEL_TRANSITION = 'opacity 180ms ease, transform 180ms ease';
const FILTER_TEXT_MUTED = 'var(--filter-liquid-text-muted)';
const FILTER_TEXT_ACTIVE = 'var(--filter-liquid-text-active)';
const FILTER_TRACK = 'var(--filter-liquid-track-bg)';
const FILTER_TRACK_FILL = 'var(--filter-liquid-accent-bg)';
const PREP_TIME_MIN = 5;
const PREP_TIME_MAX = 120;
const PREP_TIME_STEP = 5;
const PREP_TIME_MIN_GAP = PREP_TIME_STEP;
const FILTER_CONTEXTS = {
recipes: {
anchorShellId: 'recipe-filter-float-btn',
buttonId: 'recipe-filter-float-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)
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function collectAllTags() {
const tags = new Set();
for (const r of Object.values(RECIPES)) {
for (const t of (r.tags || [])) tags.add(t);
}
return [...tags].sort((a, b) => a.localeCompare(b, 'pl'));
}
export function getFilterHTML() {
return `
<style id="filter-view-styles">
#filter-view.filter-open {
pointer-events: auto;
}
#filter-panel::before {
display: none;
}
#filter-panel > * {
position: relative;
z-index: 1;
}
#filter-view .prep-time-range-track,
#filter-view .prep-time-range-fill {
border-radius: 9999px;
height: 0.375rem;
}
#filter-view .prep-time-range-handle {
width: 1rem;
height: 1rem;
border-radius: 9999px;
border: 1px solid rgba(255,255,255,0.34);
background: rgba(var(--surface-rgb),0.42);
box-shadow:
inset 0 1px 0 rgba(255,255,255,0.42),
0 0 0 1px rgba(var(--overlay-rgb),0.1),
0 4px 10px rgba(var(--overlay-rgb),0.18);
touch-action: none;
outline: none;
}
.dark #filter-view .prep-time-range-handle {
border-color: rgba(255,255,255,0.18);
background: rgba(255,255,255,0.26);
box-shadow:
inset 0 1px 0 rgba(255,255,255,0.24),
0 0 0 1px rgba(0,0,0,0.22),
0 4px 12px rgba(0,0,0,0.34);
}
</style>
<div id="filter-view" class="filter-liquid-surface absolute inset-0 z-[70] hidden opacity-0 transition-opacity duration-150" style="pointer-events:none; background:transparent !important; background-image:none !important;" aria-hidden="true">
<div id="filter-panel" class="filter-liquid-panel absolute flex flex-col overflow-hidden rounded-[1.35rem]" style="opacity:0; transform:translateY(0.65rem) scale(0.98); transform-origin:bottom center; transition:${FILTER_PANEL_TRANSITION}; width:min(calc(100% - 1.5rem), 22rem);">
<div class="shrink-0 px-3 pt-3 pb-2 flex items-center justify-between gap-3">
<p class="text-[11px] font-semibold leading-none" style="color:${FILTER_TEXT_ACTIVE};">Filtry</p>
<button id="filter-clear-btn" type="button" class="h-8 px-2 rounded-full text-[11px] font-semibold transition-colors" style="background:transparent; border:none; color:${FILTER_TEXT_MUTED};">Wyczyść</button>
</div>
<div id="filter-panel-body" class="min-h-0 flex-1 overflow-y-auto no-scrollbar px-3 pb-3 space-y-4">
<section id="filter-slot-section">
<p class="text-[10px] font-bold uppercase tracking-wider mb-3 px-0.5" style="color:${FILTER_TEXT_MUTED};">Pora posiłku</p>
<div id="filter-slot-chips" class="flex flex-wrap gap-2 px-1.5"></div>
</section>
<section>
<p class="text-[10px] font-bold uppercase tracking-wider mb-3 px-0.5" style="color:${FILTER_TEXT_MUTED};">Dieta i tagi</p>
<div id="filter-tag-chips" class="flex flex-wrap gap-2 px-1.5"></div>
</section>
<section>
<div class="flex items-center justify-between gap-3 mb-3 px-0.5">
<p class="text-[10px] font-bold uppercase tracking-wider" style="color:${FILTER_TEXT_MUTED};">Czas przygotowania</p>
<span id="time-display-range" class="shrink-0 text-[11px] font-semibold tabular-nums text-right" style="color:${FILTER_TEXT_ACTIVE};">5 min - 120 min</span>
</div>
<div class="mx-1.5">
<div class="relative h-9 mx-2">
<div class="prep-time-range-track absolute inset-x-0 top-1/2 -translate-y-1/2" style="background:${FILTER_TRACK};" aria-hidden="true"></div>
<div id="prep-time-range-fill" class="prep-time-range-fill absolute top-1/2 -translate-y-1/2" style="background:${FILTER_TRACK_FILL}; left:0%; width:100%;" aria-hidden="true"></div>
<button
type="button"
id="prep-time-min-handle"
class="prep-time-range-handle absolute top-1/2 z-[2] -translate-x-1/2 -translate-y-1/2"
style="left:0%;"
data-time-handle="min"
role="slider"
aria-label="Minimalny czas przygotowania"
aria-valuemin="${PREP_TIME_MIN}"
aria-valuemax="${PREP_TIME_MAX}"
aria-valuenow="${PREP_TIME_MIN}"
aria-valuetext="${PREP_TIME_MIN} min"
></button>
<button
type="button"
id="prep-time-max-handle"
class="prep-time-range-handle absolute top-1/2 z-[3] -translate-x-1/2 -translate-y-1/2"
style="left:100%;"
data-time-handle="max"
role="slider"
aria-label="Maksymalny czas przygotowania"
aria-valuemin="${PREP_TIME_MIN}"
aria-valuemax="${PREP_TIME_MAX}"
aria-valuenow="${PREP_TIME_MAX}"
aria-valuetext="120 min"
></button>
</div>
</div>
</section>
</div>
</div>
</div>
`;
}
let localSlots = [];
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);
let nextMax = snapTimeValue(maxMinutes);
nextMin = Math.min(Math.max(nextMin, PREP_TIME_MIN), PREP_TIME_MAX - PREP_TIME_MIN_GAP);
nextMax = Math.max(Math.min(nextMax, PREP_TIME_MAX), PREP_TIME_MIN + PREP_TIME_MIN_GAP);
if (nextMax - nextMin < PREP_TIME_MIN_GAP) {
if (nextMin + PREP_TIME_MIN_GAP <= PREP_TIME_MAX) nextMax = nextMin + PREP_TIME_MIN_GAP;
else nextMin = nextMax - PREP_TIME_MIN_GAP;
}
return { minMinutes: nextMin, maxMinutes: nextMax };
}
function formatTimeValue(minutes) {
return `${minutes} min`;
}
function formatTimeRangeSummary(minMinutes, maxMinutes) {
return `${formatTimeValue(minMinutes)} - ${formatTimeValue(maxMinutes)}`;
}
function clampTimeValue(value) {
return Math.min(Math.max(value, PREP_TIME_MIN), PREP_TIME_MAX);
}
function snapTimeValue(value) {
const steps = Math.round((clampTimeValue(value) - PREP_TIME_MIN) / PREP_TIME_STEP);
return PREP_TIME_MIN + steps * PREP_TIME_STEP;
}
function getSliderPercent(value) {
return ((value - PREP_TIME_MIN) / (PREP_TIME_MAX - PREP_TIME_MIN)) * 100;
}
function getClosedPanelTransform(panel) {
return panel?.dataset.placement === 'below'
? 'translateY(-0.5rem) scale(0.98)'
: 'translateY(0.65rem) scale(0.98)';
}
function setActiveTimeHandle(activeHandle) {
const minHandle = document.getElementById('prep-time-min-handle');
const maxHandle = document.getElementById('prep-time-max-handle');
if (!minHandle || !maxHandle) return;
minHandle.style.zIndex = activeHandle === 'min' ? '4' : '2';
maxHandle.style.zIndex = activeHandle === 'max' ? '4' : '3';
}
function syncTimeRangeUI() {
const minHandle = document.getElementById('prep-time-min-handle');
const maxHandle = document.getElementById('prep-time-max-handle');
const rangeDisplay = document.getElementById('time-display-range');
const rangeFill = document.getElementById('prep-time-range-fill');
const start = getSliderPercent(localMinMinutes);
const end = getSliderPercent(localMaxMinutes);
if (minHandle) {
minHandle.style.left = `${start}%`;
minHandle.setAttribute('aria-valuenow', String(localMinMinutes));
minHandle.setAttribute('aria-valuetext', formatTimeValue(localMinMinutes));
}
if (maxHandle) {
maxHandle.style.left = `${end}%`;
maxHandle.setAttribute('aria-valuenow', String(localMaxMinutes));
maxHandle.setAttribute('aria-valuetext', formatTimeValue(localMaxMinutes));
}
if (rangeDisplay) rangeDisplay.textContent = formatTimeRangeSummary(localMinMinutes, localMaxMinutes);
if (rangeFill) {
rangeFill.style.left = `${start}%`;
rangeFill.style.width = `${Math.max(end - start, 0)}%`;
}
}
function positionFilterPanel() {
const view = document.getElementById('filter-view');
const panel = document.getElementById('filter-panel');
const body = document.getElementById('filter-panel-body');
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();
const isRecipeContext = activeFilterContext === 'recipes';
const gap = isRecipeContext ? 12 : 8;
const margin = 12;
const maxPanelWidth = isRecipeContext ? 352 : anchorRect.width;
const width = Math.min(maxPanelWidth, viewRect.width - margin * 2);
const spaceBelow = viewRect.bottom - anchorRect.bottom - margin;
const preferredMaxHeight = Math.min(420, viewRect.height - margin * 2);
const spaceAbove = anchorRect.top - viewRect.top - gap - margin;
const opensUpward = isRecipeContext || (spaceBelow < 260 && anchorRect.top - viewRect.top > spaceBelow);
const maxHeight = opensUpward
? Math.max(220, Math.min(preferredMaxHeight, spaceAbove))
: Math.max(220, viewRect.height - Math.max(margin, anchorRect.bottom - viewRect.top + gap) - margin);
const leftBase = isRecipeContext
? (viewRect.width - width) / 2
: anchorRect.left - viewRect.left;
const left = Math.max(margin, Math.min(leftBase, viewRect.width - width - margin));
panel.style.width = `${width}px`;
panel.style.left = `${left}px`;
panel.style.transformOrigin = opensUpward ? 'bottom center' : 'top center';
panel.dataset.placement = opensUpward ? 'above' : 'below';
if (opensUpward) {
panel.style.top = 'auto';
panel.style.bottom = `${Math.max(margin, viewRect.bottom - anchorRect.top + gap)}px`;
} else {
panel.style.top = `${Math.max(margin, anchorRect.bottom - viewRect.top + gap)}px`;
panel.style.bottom = 'auto';
}
panel.style.maxHeight = `${maxHeight}px`;
if (body) body.style.maxHeight = `${maxHeight - 56}px`;
}
function setRecipeAreaBlur(visible) {
// Dark overlay on filter-view handles the dimming now
}
function showFilterPanel() {
const view = document.getElementById('filter-view');
const panel = document.getElementById('filter-panel');
if (!view || !panel) return;
clearTimeout(closeTimer);
view.classList.remove('hidden');
view.classList.add('filter-open');
view.style.pointerEvents = 'auto';
view.setAttribute('aria-hidden', 'false');
positionFilterPanel();
panel.style.transform = getClosedPanelTransform(panel);
setRecipeAreaBlur(true);
syncPanelCount();
requestAnimationFrame(() => {
view.classList.add('opacity-100');
panel.style.opacity = '1';
panel.style.transform = 'translateY(0) scale(1)';
});
}
function hideFilterPanel() {
const view = document.getElementById('filter-view');
const panel = document.getElementById('filter-panel');
if (!view || !panel) return;
view.classList.remove('opacity-100', 'filter-open');
view.style.pointerEvents = 'none';
view.setAttribute('aria-hidden', 'true');
panel.style.opacity = '0';
panel.style.transform = getClosedPanelTransform(panel);
setRecipeAreaBlur(false);
syncPanelCount();
closeTimer = setTimeout(() => {
view.classList.add('hidden');
}, 180);
}
function isFilterPanelOpen() {
return document.getElementById('filter-view')?.classList.contains('filter-open');
}
function renderSlotChips() {
const wrap = document.getElementById('filter-slot-chips');
if (!wrap) return;
wrap.innerHTML = MEAL_SLOTS.map((slot) => {
const active = localSlots.includes(slot.id);
return `<button type="button" data-filter-slot="${escapeHtml(slot.id)}" class="px-3 py-2 rounded-full text-[11px] font-semibold transition-colors" style="${filterChipStyle(active)}">${escapeHtml(slot.label)}</button>`;
}).join('');
wrap.querySelectorAll('[data-filter-slot]').forEach((btn) => {
btn.addEventListener('click', () => {
const id = btn.dataset.filterSlot;
const idx = localSlots.indexOf(id);
if (idx >= 0) localSlots.splice(idx, 1);
else localSlots.push(id);
renderSlotChips();
syncLiveFilters();
});
});
}
function renderTagChips() {
const wrap = document.getElementById('filter-tag-chips');
if (!wrap) return;
const allTags = collectAllTags();
wrap.innerHTML = allTags.map((tag) => {
const active = localTags.includes(tag.toLowerCase());
return `<button type="button" data-filter-tag="${escapeHtml(tag)}" class="px-3 py-2 rounded-full text-[11px] font-semibold transition-colors" style="${filterChipStyle(active)}">${escapeHtml(tag)}</button>`;
}).join('');
wrap.querySelectorAll('[data-filter-tag]').forEach((btn) => {
btn.addEventListener('click', () => {
const tag = btn.dataset.filterTag.toLowerCase();
const idx = localTags.indexOf(tag);
if (idx >= 0) localTags.splice(idx, 1);
else localTags.push(tag);
renderTagChips();
syncLiveFilters();
});
});
}
function syncFilterSections() {
const slotSection = document.getElementById('filter-slot-section');
if (!slotSection) return;
slotSection.classList.toggle('hidden', !getActiveFilterConfig().showSlots);
}
function getActiveFilterCount() {
const config = getActiveFilterConfig();
let count = localTags.length;
if (config.showSlots) count += localSlots.length;
if (localMinMinutes > PREP_TIME_MIN || localMaxMinutes < PREP_TIME_MAX) count += 1;
return count;
}
function syncPanelCount() {
const count = getActiveFilterCount();
const { buttonId } = getActiveFilterConfig();
const button = buttonId ? document.getElementById(buttonId) : null;
if (button) {
const highlight = isFilterPanelOpen() || count > 0;
const isRecipeGlassButton = button.classList.contains('recipe-glass-btn');
if (isRecipeGlassButton) {
button.style.removeProperty('background');
button.style.removeProperty('border-color');
button.style.removeProperty('color');
} else {
button.style.setProperty('background', highlight ? 'rgba(var(--overlay-rgb), 0.12)' : 'rgb(var(--card-rgb))', 'important');
button.style.setProperty('border-color', highlight ? 'rgb(var(--border-input-rgb))' : 'rgb(var(--border-card-rgb))', 'important');
button.style.setProperty('color', highlight ? 'rgb(var(--text-emphasis-rgb))' : 'rgb(var(--text-body-rgb))', 'important');
}
}
const badge = button?.querySelector('[id$="-filter-count"]');
if (!badge) return;
badge.textContent = String(count);
badge.classList.toggle('hidden', count === 0);
badge.classList.toggle('flex', count > 0);
}
function syncLiveFilters() {
const config = getActiveFilterConfig();
config.applyState?.({
slots: config.showSlots ? localSlots : [],
tags: localTags,
minMinutes: localMinMinutes,
maxMinutes: localMaxMinutes,
});
syncPanelCount();
}
export function setupFilter() {
ensureFilterPopoverStyles();
const rangeTrack = document.getElementById('prep-time-range-fill')?.parentElement;
const minHandle = document.getElementById('prep-time-min-handle');
const maxHandle = document.getElementById('prep-time-max-handle');
const filterView = document.getElementById('filter-view');
let activeTimeHandle = null;
setRecipeAreaBlur(false);
function valueFromPointer(clientX) {
if (!rangeTrack) return PREP_TIME_MIN;
const rect = rangeTrack.getBoundingClientRect();
if (rect.width <= 0) return PREP_TIME_MIN;
const ratio = Math.min(Math.max((clientX - rect.left) / rect.width, 0), 1);
return snapTimeValue(PREP_TIME_MIN + ratio * (PREP_TIME_MAX - PREP_TIME_MIN));
}
function applyDraggedTimeValue(handleName, nextValue) {
const value = snapTimeValue(nextValue);
if (handleName === 'min') {
localMinMinutes = Math.min(value, localMaxMinutes - PREP_TIME_MIN_GAP);
localMinMinutes = Math.max(localMinMinutes, PREP_TIME_MIN);
} else {
localMaxMinutes = Math.max(value, localMinMinutes + PREP_TIME_MIN_GAP);
localMaxMinutes = Math.min(localMaxMinutes, PREP_TIME_MAX);
}
syncTimeRangeUI();
syncLiveFilters();
}
function stopTimeHandleDrag() {
activeTimeHandle = null;
window.removeEventListener('pointermove', onWindowPointerMove);
window.removeEventListener('pointerup', stopTimeHandleDrag);
window.removeEventListener('pointercancel', stopTimeHandleDrag);
}
function onWindowPointerMove(e) {
if (!activeTimeHandle) return;
e.preventDefault();
applyDraggedTimeValue(activeTimeHandle, valueFromPointer(e.clientX));
}
function startTimeHandleDrag(handleName, e) {
activeTimeHandle = handleName;
setActiveTimeHandle(handleName);
applyDraggedTimeValue(handleName, valueFromPointer(e.clientX));
window.addEventListener('pointermove', onWindowPointerMove);
window.addEventListener('pointerup', stopTimeHandleDrag);
window.addEventListener('pointercancel', stopTimeHandleDrag);
}
function handleTimeHandleKeydown(handleName, e) {
let nextValue = handleName === 'min' ? localMinMinutes : localMaxMinutes;
if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') nextValue -= PREP_TIME_STEP;
else if (e.key === 'ArrowRight' || e.key === 'ArrowUp') nextValue += PREP_TIME_STEP;
else if (e.key === 'Home') nextValue = PREP_TIME_MIN;
else if (e.key === 'End') nextValue = PREP_TIME_MAX;
else return;
e.preventDefault();
setActiveTimeHandle(handleName);
applyDraggedTimeValue(handleName, nextValue);
}
syncTimeRangeUI();
setActiveTimeHandle('max');
if (minHandle) {
minHandle.addEventListener('pointerdown', (e) => {
e.preventDefault();
startTimeHandleDrag('min', e);
});
minHandle.addEventListener('focus', () => {
setActiveTimeHandle('min');
});
minHandle.addEventListener('keydown', (e) => {
handleTimeHandleKeydown('min', e);
});
}
if (maxHandle) {
maxHandle.addEventListener('pointerdown', (e) => {
e.preventDefault();
startTimeHandleDrag('max', e);
});
maxHandle.addEventListener('focus', () => {
setActiveTimeHandle('max');
});
maxHandle.addEventListener('keydown', (e) => {
handleTimeHandleKeydown('max', e);
});
}
rangeTrack?.addEventListener('pointerdown', (e) => {
if (e.target.closest('[data-time-handle]')) return;
const clickedValue = valueFromPointer(e.clientX);
const distToMin = Math.abs(clickedValue - localMinMinutes);
const distToMax = Math.abs(clickedValue - localMaxMinutes);
const handleName = distToMin <= distToMax ? 'min' : 'max';
e.preventDefault();
startTimeHandleDrag(handleName, e);
});
filterView?.addEventListener('click', (e) => {
if (e.composedPath().includes(document.getElementById('filter-panel'))) return;
window.closeFilters();
});
window.addEventListener('resize', () => {
if (isFilterPanelOpen()) positionFilterPanel();
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && isFilterPanelOpen()) window.closeFilters();
});
document.getElementById('filter-clear-btn')?.addEventListener('click', () => {
localSlots = [];
localTags = [];
localMinMinutes = PREP_TIME_MIN;
localMaxMinutes = PREP_TIME_MAX;
syncTimeRangeUI();
renderSlotChips();
renderTagChips();
syncLiveFilters();
});
window.openFilters = (contextName = 'recipes') => {
if (isFilterPanelOpen() && activeFilterContext === contextName) {
window.closeFilters();
return;
}
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,
);
localMinMinutes = normalized.minMinutes;
localMaxMinutes = normalized.maxMinutes;
syncTimeRangeUI();
renderSlotChips();
renderTagChips();
syncFilterSections();
syncPanelCount();
showFilterPanel();
};
window.closeFilters = () => {
stopTimeHandleDrag();
hideFilterPanel();
};
}