Compare commits

..

2 Commits

Author SHA1 Message Date
6bf50f67ad Restructure recipe grid
All checks were successful
Build and Deploy / build-and-push (push) Successful in 1m11s
2026-04-08 22:53:17 +02:00
165f39d0b7 Update planner search and planner editor 2026-04-08 22:16:20 +02:00
7 changed files with 767 additions and 304 deletions

View File

@@ -11,7 +11,7 @@
<meta http-equiv="Pragma" content="no-cache"> <meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0"> <meta http-equiv="Expires" content="0">
<title>Recipe App - Modular</title> <title>Recipe App - Modular</title>
<link rel="manifest" href="./manifest.webmanifest?v=20260407-66"> <link rel="manifest" href="./manifest.webmanifest?v=20260408-97">
<link rel="icon" type="image/png" sizes="192x192" href="./icons/icon-192.png"> <link rel="icon" type="image/png" sizes="192x192" href="./icons/icon-192.png">
<link rel="apple-touch-icon" href="./icons/apple-touch-icon.png"> <link rel="apple-touch-icon" href="./icons/apple-touch-icon.png">
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
@@ -233,9 +233,16 @@
} }
#main-view, #main-view,
#main-view > div:last-child, #main-view > div:last-child,
#recipe-grid { #recipe-grid,
#planner-picker-grid {
background: #2d2e2b !important; background: #2d2e2b !important;
} }
#recipe-grid,
#planner-picker-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.5rem !important;
align-items: stretch;
}
#planner-view, #planner-view,
#planner-view > div:first-child, #planner-view > div:first-child,
#planner-scroll, #planner-scroll,
@@ -255,24 +262,82 @@
} }
/* Cards and sheets */ /* Cards and sheets */
#recipe-grid > div { #recipe-grid > *,
#planner-picker-grid > * {
background: #393937 !important; background: #393937 !important;
border: none !important; border: none !important;
border-radius: 1.75rem !important; border-radius: 1.75rem !important;
box-shadow: none !important; box-shadow: none !important;
transition: transform 180ms ease, box-shadow 180ms ease !important; transition: transform 180ms ease, box-shadow 180ms ease !important;
} }
#recipe-grid > div:hover { #recipe-grid > .recipe-list-card,
#planner-picker-grid > .recipe-list-card {
border-radius: 1.25rem !important;
height: 11.9rem;
}
#recipe-grid > .recipe-list-card .recipe-browser-card-media,
#planner-picker-grid > .recipe-list-card .recipe-browser-card-media {
height: 5.25rem;
}
#recipe-grid > .recipe-list-card .recipe-browser-card-body,
#planner-picker-grid > .recipe-list-card .recipe-browser-card-body {
padding: 0.58rem;
}
#recipe-grid > .recipe-list-card .recipe-browser-card-title,
#planner-picker-grid > .recipe-list-card .recipe-browser-card-title {
display: -webkit-box;
min-height: 2.76rem;
margin-bottom: 0.55rem;
font-size: 0.66rem;
font-weight: 400 !important;
line-height: 0.92rem;
overflow: hidden;
text-decoration: none !important;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
}
#recipe-grid > .recipe-list-card .recipe-browser-card-meta,
#planner-picker-grid > .recipe-list-card .recipe-browser-card-meta {
margin-bottom: 0.45rem;
gap: 0.2rem;
font-size: 0.58rem;
line-height: 0.78rem;
flex-wrap: nowrap;
}
#recipe-grid > .recipe-list-card .recipe-browser-card-meta > div,
#planner-picker-grid > .recipe-list-card .recipe-browser-card-meta > div {
min-width: 0;
white-space: nowrap;
}
#recipe-grid > .recipe-list-card .recipe-browser-card-meta i,
#planner-picker-grid > .recipe-list-card .recipe-browser-card-meta i {
font-size: 0.52rem;
}
#recipe-grid > .recipe-list-card .recipe-browser-card-labels,
#planner-picker-grid > .recipe-list-card .recipe-browser-card-labels {
gap: 0.25rem;
}
#recipe-grid > .recipe-list-card .recipe-browser-card-label,
#planner-picker-grid > .recipe-list-card .recipe-browser-card-label {
padding: 0.14rem 0.36rem;
font-size: 0.54rem;
line-height: 0.72rem;
}
#recipe-grid > *:hover,
#planner-picker-grid > *:hover {
transform: translateY(-2px); transform: translateY(-2px);
box-shadow: none !important; box-shadow: none !important;
} }
#recipe-grid > div img { #recipe-grid > * img,
#planner-picker-grid > * img {
transition: transform 240ms ease; transition: transform 240ms ease;
} }
#recipe-grid > div:hover img { #recipe-grid > *:hover img,
#planner-picker-grid > *:hover img {
transform: scale(1.04); transform: scale(1.04);
} }
#recipe-grid > div > div:first-child::after, #recipe-grid > * > div:first-child::after,
#planner-picker-grid > * > div:first-child::after,
#rd-hero::after { #rd-hero::after {
content: ''; content: '';
position: absolute; position: absolute;
@@ -280,7 +345,8 @@
background: linear-gradient(180deg, rgba(0, 0, 0, 0.04), rgba(var(--app-bg-rgb), 0.5) 92%); background: linear-gradient(180deg, rgba(0, 0, 0, 0.04), rgba(var(--app-bg-rgb), 0.5) 92%);
pointer-events: none; pointer-events: none;
} }
#recipe-search-shell { #recipe-search-shell,
#planner-picker-search-shell {
min-height: 3rem; min-height: 3rem;
width: min(calc(100% - 0.5rem), 22.4rem); width: min(calc(100% - 0.5rem), 22.4rem);
margin-inline: auto; margin-inline: auto;
@@ -294,11 +360,14 @@
0 5px 10px rgba(0, 0, 0, 0.16), 0 5px 10px rgba(0, 0, 0, 0.16),
0 14px 22px rgba(0, 0, 0, 0.24), 0 14px 22px rgba(0, 0, 0, 0.24),
0 22px 34px rgba(0, 0, 0, 0.18), 0 22px 34px rgba(0, 0, 0, 0.18),
inset 0 1px 0 rgba(255, 255, 255, 0.04) !important; inset 0 1px 0 rgba(255, 255, 255, 0.04),
inset 0 2px 6px rgba(0, 0, 0, 0.16),
inset 0 -1px 2px rgba(255, 255, 255, 0.02) !important;
backdrop-filter: blur(24px); backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px); -webkit-backdrop-filter: blur(24px);
} }
#recipe-search-shell::after { #recipe-search-shell::after,
#planner-picker-search-shell::after {
content: ''; content: '';
position: absolute; position: absolute;
left: 11%; left: 11%;
@@ -312,16 +381,20 @@
z-index: -1; z-index: -1;
pointer-events: none; pointer-events: none;
} }
#recipe-search-shell:focus-within { #recipe-search-shell:focus-within,
#planner-picker-search-shell:focus-within {
background: #393937 !important; background: #393937 !important;
border: 1px solid #4a4b47 !important; border: 1px solid #4a4b47 !important;
box-shadow: box-shadow:
0 6px 12px rgba(0, 0, 0, 0.18), 0 6px 12px rgba(0, 0, 0, 0.18),
0 16px 24px rgba(0, 0, 0, 0.24), 0 16px 24px rgba(0, 0, 0, 0.24),
0 24px 36px rgba(0, 0, 0, 0.18), 0 24px 36px rgba(0, 0, 0, 0.18),
inset 0 1px 0 rgba(255, 255, 255, 0.05) !important; inset 0 1px 0 rgba(255, 255, 255, 0.05),
inset 0 2px 7px rgba(0, 0, 0, 0.18),
inset 0 -1px 2px rgba(255, 255, 255, 0.03) !important;
} }
#recipe-search-input { #recipe-search-input,
#planner-picker-search {
appearance: none; appearance: none;
-webkit-appearance: none; -webkit-appearance: none;
background: transparent !important; background: transparent !important;
@@ -334,17 +407,20 @@
font-weight: 400; font-weight: 400;
letter-spacing: -0.02em; letter-spacing: -0.02em;
} }
#recipe-search-input::placeholder { #recipe-search-input::placeholder,
#planner-picker-search::placeholder {
color: #beb8ae !important; color: #beb8ae !important;
opacity: 1; opacity: 1;
} }
#recipe-filter-btn { #recipe-filter-btn,
#planner-picker-filter-btn {
border-radius: 999px; border-radius: 999px;
background: transparent !important; background: transparent !important;
border: none !important; border: none !important;
box-shadow: none !important; box-shadow: none !important;
} }
#recipe-filter-btn:hover { #recipe-filter-btn:hover,
#planner-picker-filter-btn:hover {
background: rgba(255, 255, 255, 0.03) !important; background: rgba(255, 255, 255, 0.03) !important;
} }
#planner-picker-sheet, #planner-picker-sheet,
@@ -524,7 +600,7 @@
</div> </div>
<script> <script>
const APP_ASSET_VERSION = '20260407-66'; const APP_ASSET_VERSION = '20260408-97';
const APP_VERSION_STORAGE_KEY = 'recipe-app-asset-version'; const APP_VERSION_STORAGE_KEY = 'recipe-app-asset-version';
const APP_VERSION_QUERY_KEY = 'appv'; const APP_VERSION_QUERY_KEY = 'appv';
@@ -558,7 +634,7 @@
})(); })();
</script> </script>
<script type="module"> <script type="module">
const appVersion = window.__APP_ASSET_VERSION__ || '20260407-66'; const appVersion = window.__APP_ASSET_VERSION__ || '20260408-97';
const recoveryKey = `recipe-app-recovery-${appVersion}`; const recoveryKey = `recipe-app-recovery-${appVersion}`;
function renderBootstrapError(message) { function renderBootstrapError(message) {

View File

@@ -271,15 +271,16 @@ export function getMealPlanEditorHTML() {
<div id="mpe-servings-row" class="mt-3"></div> <div id="mpe-servings-row" class="mt-3"></div>
<div id="mpe-top-shadow" class="pointer-events-none absolute inset-x-0 -bottom-3 h-3 opacity-0 transition-opacity duration-200" style="background:linear-gradient(to bottom, rgba(0,0,0,0.12), rgba(0,0,0,0.03), rgba(0,0,0,0));"></div> <div id="mpe-top-shadow" class="pointer-events-none absolute inset-x-0 -bottom-3 h-3 opacity-0 transition-opacity duration-200" style="background:linear-gradient(to bottom, rgba(0,0,0,0.12), rgba(0,0,0,0.03), rgba(0,0,0,0));"></div>
</div> </div>
<div id="mpe-ing-scroll" class="flex-1 min-h-0 overflow-y-auto no-scrollbar px-5 pb-16 bg-[#2d2e2b]" style="background:#2d2e2b !important; background-image:none !important;"> <div id="mpe-ing-scroll" class="flex-1 min-h-0 overflow-y-auto no-scrollbar px-5 pb-24 bg-[#2d2e2b]" style="background:#2d2e2b !important; background-image:none !important;">
<div id="mpe-ing-section" class="mb-4"> <div id="mpe-ing-section" class="mb-4">
<p class="text-[10px] font-bold text-gray-400 uppercase tracking-wider mb-2">Składniki</p> <p class="text-[10px] font-bold text-gray-400 uppercase tracking-wider mb-2">Składniki</p>
<div id="mpe-ing-list" class="space-y-1.5"></div> <div id="mpe-ing-list" class="space-y-1.5"></div>
<div id="mpe-add-area" class="mt-2"></div> <div id="mpe-add-area" class="mt-2"></div>
</div> </div>
</div> </div>
<div id="mpe-footer-wrap" class="absolute bottom-0 left-0 right-0 z-[2] px-5 pb-3 flex justify-center" style="pointer-events:none; padding-bottom:calc(1.1rem + env(safe-area-inset-bottom));"> <div id="mpe-footer-wrap" class="absolute bottom-0 left-0 right-0 z-[2] px-5 pb-4 flex justify-center" style="pointer-events:none; padding-bottom:calc(1.7rem + env(safe-area-inset-bottom));">
<button id="mpe-confirm-btn" type="button" class="border text-white px-6 py-3 rounded-full font-semibold text-[13px] transition-colors inline-flex items-center justify-center gap-2" style="pointer-events:auto; background:#2d2e2b !important; background-image:none !important; border-color:#444442 !important; box-shadow:0 4px 16px rgba(0,0,0,0.4), 0 1px 4px rgba(0,0,0,0.25);"> <button id="mpe-confirm-btn" type="button" class="border h-10 px-9 rounded-full font-semibold text-[13px] transition-colors inline-flex items-center justify-center gap-2" style="pointer-events:auto; background:#dcd6cb !important; color:#2d2e2b !important; background-image:none !important; border-color:#dcd6cb !important; box-shadow:0 4px 16px rgba(0,0,0,0.28), 0 1px 4px rgba(0,0,0,0.2);">
<i id="mpe-confirm-icon" class="fas fa-calendar-plus text-[11px]" aria-hidden="true"></i>
<span id="mpe-confirm-label">Dodaj do planu</span> <span id="mpe-confirm-label">Dodaj do planu</span>
</button> </button>
</div> </div>
@@ -696,6 +697,12 @@ export function setupMealPlanEditor() {
document.getElementById('mpe-title').textContent = S.mode === 'edit' ? 'Edytuj posiłek' : 'Zaplanuj posiłek'; document.getElementById('mpe-title').textContent = S.mode === 'edit' ? 'Edytuj posiłek' : 'Zaplanuj posiłek';
document.getElementById('mpe-subtitle').textContent = recipe.title; document.getElementById('mpe-subtitle').textContent = recipe.title;
document.getElementById('mpe-confirm-label').textContent = S.mode === 'edit' ? 'Zapisz zmiany' : 'Dodaj do planu'; document.getElementById('mpe-confirm-label').textContent = S.mode === 'edit' ? 'Zapisz zmiany' : 'Dodaj do planu';
const confirmIcon = document.getElementById('mpe-confirm-icon');
if (confirmIcon) {
confirmIcon.className = S.mode === 'edit'
? 'fas fa-check text-[11px]'
: 'fas fa-calendar-plus text-[11px]';
}
renderAll(); renderAll();
const body = document.getElementById('mpe-ing-scroll'); const body = document.getElementById('mpe-ing-scroll');

108
js/ui/recipeGrid.js Normal file
View File

@@ -0,0 +1,108 @@
import { MEAL_SLOTS } from '../planner/mealSlots.js';
function escapeHtml(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
const slotLabelMap = Object.fromEntries(MEAL_SLOTS.map((slot) => [slot.id, slot.label]));
function slotLabelsFor(recipe) {
return (recipe.allowedSlots || [])
.map((id) => slotLabelMap[id])
.filter(Boolean);
}
function getEmptyStateHTML({ emptyStateId, title, message }) {
return `
<div id="${escapeHtml(emptyStateId)}" 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">${escapeHtml(title)}</p>
<p class="text-xs text-gray-500 mt-1 max-w-[220px] leading-relaxed">${escapeHtml(message)}</p>
</div>
`;
}
function renderRecipeCard(recipe, { showSlotLabels = true, cardClassName = '' } = {}) {
const labels = showSlotLabels ? slotLabelsFor(recipe) : [];
const className = ['recipe-browser-card', cardClassName].filter(Boolean).join(' ');
return `
<button type="button" data-recipe-id="${escapeHtml(recipe.id)}" class="${className} rounded-xl overflow-hidden flex flex-col bg-[#393937] cursor-pointer text-left transition-shadow" style="background:#393937 !important; border:none !important; box-shadow:0 2px 8px rgba(0,0,0,0.28) !important;">
<div class="recipe-browser-card-media 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="recipe-browser-card-body p-3 flex flex-col flex-1">
<h3 class="recipe-browser-card-title text-sm font-medium underline decoration-1 underline-offset-2 text-[#f1ede4] mb-3 line-clamp-2">${escapeHtml(recipe.title)}</h3>
<div class="recipe-browser-card-footer mt-auto">
<div class="recipe-browser-card-meta 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]" aria-hidden="true"></i><span>${recipe.minutes} min</span></div>
<div class="flex items-center gap-1"><i class="fas fa-fire text-[#8f8b84]" aria-hidden="true"></i><span>${recipe.nutritionPerServing.kcal} kcal</span></div>
</div>
${labels.length > 0
? `<div class="recipe-browser-card-labels flex flex-wrap gap-1">
${labels.map((label) => `<span class="recipe-browser-card-label px-2 py-0.5 bg-[#2f2f2d] text-[#d7d2c8] text-[10px] rounded-md font-medium">${escapeHtml(label)}</span>`).join('')}
</div>`
: ''}
</div>
</div>
</button>
`;
}
export function filterRecipesByQuery(recipes, query = '') {
const q = query.trim().toLowerCase();
if (!q) return [...recipes];
return recipes.filter((recipe) => {
const haystack = `${recipe.title} ${(recipe.tags || []).join(' ')}`.toLowerCase();
return haystack.includes(q);
});
}
export function getRecipeGridSectionHTML({
scrollId,
gridId,
emptyStateId,
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',
} = {}) {
return `
<div id="${escapeHtml(scrollId)}" class="${scrollClassName}" style="background:#2d2e2b !important;">
<div id="${escapeHtml(gridId)}" class="${gridClassName}" style="background:#2d2e2b !important;"></div>
${getEmptyStateHTML({
emptyStateId,
title: emptyTitle,
message: emptyMessage,
})}
</div>
`;
}
export function renderRecipeGrid({
gridEl,
emptyStateEl,
recipes,
showSlotLabels = true,
cardClassName = '',
} = {}) {
if (!gridEl || !emptyStateEl) return;
const items = Array.isArray(recipes) ? recipes : [];
gridEl.innerHTML = items
.map((recipe) => renderRecipeCard(recipe, { showSlotLabels, cardClassName }))
.join('');
const hasItems = items.length > 0;
gridEl.classList.toggle('hidden', !hasItems);
emptyStateEl.classList.toggle('hidden', hasItems);
}

View File

@@ -0,0 +1,45 @@
export const RECIPE_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), inset 0 2px 6px rgba(0,0,0,0.16), inset 0 -1px 2px rgba(255,255,255,0.02)';
function escapeHtml(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
export function getRecipeSearchFieldHTML({
shellId,
inputId,
placeholder = 'Szukaj przepisów...',
inputAriaLabel = '',
inputValue = '',
filterButtonId = '',
filterButtonAction = '',
filterButtonLabel = 'Otwórz filtry',
} = {}) {
const hasFilterButton = Boolean(filterButtonId);
const actionAttr = hasFilterButton && filterButtonAction
? ` onclick="${escapeHtml(filterButtonAction)}"`
: '';
const inputPadding = hasFilterButton ? 'pl-8 pr-14' : 'pl-8 pr-8';
const ariaLabel = inputAriaLabel || placeholder;
return `
<div id="${escapeHtml(shellId)}" class="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:${RECIPE_SEARCH_SHELL_BASE_SHADOW} !important; transition:box-shadow 180ms ease;">
<input type="text" id="${escapeHtml(inputId)}" value="${escapeHtml(inputValue)}" placeholder="${escapeHtml(placeholder)}" aria-label="${escapeHtml(ariaLabel)}" class="w-full bg-transparent outline-none text-[15px] text-center py-[12px] ${inputPadding}" style="background:transparent !important; border:none !important; box-shadow:none !important; backdrop-filter:none !important;">
${hasFilterButton
? `
<button id="${escapeHtml(filterButtonId)}"${actionAttr} 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="${escapeHtml(filterButtonLabel)}">
<i class="fas fa-sliders-h" aria-hidden="true"></i>
</button>`
: ''}
</div>
`;
}
export function syncRecipeSearchShellShadow(searchShell) {
if (!searchShell) return;
searchShell.style.boxShadow = RECIPE_SEARCH_SHELL_BASE_SHADOW;
}

View File

@@ -20,6 +20,27 @@ const PREP_TIME_MAX = 120;
const PREP_TIME_STEP = 5; const PREP_TIME_STEP = 5;
const PREP_TIME_MIN_GAP = PREP_TIME_STEP; const PREP_TIME_MIN_GAP = PREP_TIME_STEP;
const FILTER_RECIPE_BLUR = 'blur(3px) saturate(0.94)'; 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) { function escapeHtml(s) {
return String(s) return String(s)
@@ -61,7 +82,7 @@ export function getFilterHTML() {
outline: none; outline: none;
} }
</style> </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 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="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;"> <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>
<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;"> <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> <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> <div id="filter-slot-chips" class="flex flex-wrap gap-2"></div>
</section> </section>
@@ -132,6 +153,11 @@ let localTags = [];
let localMinMinutes = PREP_TIME_MIN; let localMinMinutes = PREP_TIME_MIN;
let localMaxMinutes = PREP_TIME_MAX; let localMaxMinutes = PREP_TIME_MAX;
let closeTimer = null; let closeTimer = null;
let activeFilterContext = 'recipes';
function getActiveFilterConfig() {
return FILTER_CONTEXTS[activeFilterContext] || FILTER_CONTEXTS.recipes;
}
function normalizeTimeRange(minMinutes, maxMinutes) { function normalizeTimeRange(minMinutes, maxMinutes) {
let nextMin = snapTimeValue(minMinutes); let nextMin = snapTimeValue(minMinutes);
@@ -216,9 +242,10 @@ function positionFilterPanel() {
const view = document.getElementById('filter-view'); const view = document.getElementById('filter-view');
const panel = document.getElementById('filter-panel'); const panel = document.getElementById('filter-panel');
const body = document.getElementById('filter-panel-body'); const body = document.getElementById('filter-panel-body');
const searchShell = document.getElementById('recipe-search-shell'); const { anchorShellId, buttonId } = getActiveFilterConfig();
const button = document.getElementById('recipe-filter-btn'); const searchShell = document.getElementById(anchorShellId);
if (!view || !panel || !button) return; const button = document.getElementById(buttonId);
if (!view || !panel || (!searchShell && !button)) return;
const viewRect = view.getBoundingClientRect(); const viewRect = view.getBoundingClientRect();
const anchorRect = (searchShell || button).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() { function syncLiveFilters() {
applyFilters({ const config = getActiveFilterConfig();
slots: localSlots, config.applyState?.({
slots: config.showSlots ? localSlots : [],
tags: localTags, tags: localTags,
minMinutes: localMinMinutes, minMinutes: localMinMinutes,
maxMinutes: localMaxMinutes, maxMinutes: localMaxMinutes,
@@ -465,15 +499,17 @@ export function setupFilter() {
syncLiveFilters(); syncLiveFilters();
}); });
window.openFilters = () => { window.openFilters = (contextName = 'recipes') => {
if (isFilterPanelOpen()) { if (isFilterPanelOpen() && activeFilterContext === contextName) {
window.closeFilters(); window.closeFilters();
return; return;
} }
const state = getFilterState(); activeFilterContext = FILTER_CONTEXTS[contextName] ? contextName : 'recipes';
localSlots = [...state.slots]; const config = getActiveFilterConfig();
localTags = [...state.tags]; const state = config.getState?.() || {};
localSlots = [...(state.slots || [])];
localTags = [...(state.tags || [])];
const normalized = normalizeTimeRange( const normalized = normalizeTimeRange(
Number.isFinite(state.minMinutes) ? state.minMinutes : PREP_TIME_MIN, Number.isFinite(state.minMinutes) ? state.minMinutes : PREP_TIME_MIN,
Number.isFinite(state.maxMinutes) ? state.maxMinutes : PREP_TIME_MAX, Number.isFinite(state.maxMinutes) ? state.maxMinutes : PREP_TIME_MAX,
@@ -485,6 +521,7 @@ export function setupFilter() {
renderSlotChips(); renderSlotChips();
renderTagChips(); renderTagChips();
syncFilterSections();
showFilterPanel(); showFilterPanel();
}; };

View File

@@ -37,6 +37,11 @@ import {
syncCalendarTodayButton, syncCalendarTodayButton,
syncCollapsibleCalendarMode, syncCollapsibleCalendarMode,
} from '../ui/mealCalendar.js?v=1'; } from '../ui/mealCalendar.js?v=1';
import {
filterRecipesByQuery,
renderRecipeGrid,
} from '../ui/recipeGrid.js';
import { getRecipeSearchFieldHTML } from '../ui/recipeSearchField.js';
const WEEKDAYS_LONG = [ const WEEKDAYS_LONG = [
'Niedziela', 'Poniedziałek', 'Wtorek', 'Środa', 'Czwartek', 'Piątek', 'Sobota', '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_BOTTOM_INSET = '5.25rem';
const PLANNER_SHEET_MAX_HEIGHT = '70vh'; const PLANNER_SHEET_MAX_HEIGHT = '70vh';
const PLANNER_SHEET_OFF_TRANSFORM = `translateY(calc(100% + ${PLANNER_SHEET_BOTTOM_INSET}))`; 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) { function recipesForSlot(slotId) {
return Object.values(RECIPES).filter((r) => r.allowedSlots.includes(slotId)); return Object.values(RECIPES).filter((r) => r.allowedSlots.includes(slotId));
@@ -115,44 +125,61 @@ export function getMealPlannerHTML() {
</button> </button>
<div id="planner-meal-slots" class="space-y-3 pb-2 bg-[#2d2e2b]"></div> <div id="planner-meal-slots" class="space-y-3 pb-2 bg-[#2d2e2b]"></div>
</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-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" 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 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="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="pointer-events-none absolute inset-x-0 top-0 z-[2] px-4 pt-3">
<div class="w-10 h-1 bg-[#6d6c67]/75 rounded-full mx-auto mb-2.5" aria-hidden="true"></div> <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ąć">
<h2 id="planner-picker-title" class="text-[15px] font-bold text-[#ddd6ca] leading-tight pr-2">Wybierz przepis</h2> <div class="w-10 h-1 bg-[#6d6c67]/75 rounded-full mx-auto" aria-hidden="true"></div>
<p id="planner-picker-sub" class="text-[11px] text-[#9b978f] mt-1"></p>
</div> </div>
<div class="shrink-0 px-4 pt-2 pb-2"> <div class="pointer-events-auto">
<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…" /> ${getRecipeSearchFieldHTML({
</div> shellId: 'planner-picker-search-shell',
<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> inputId: 'planner-picker-search',
</div> placeholder: 'Wybierz przepis',
inputAriaLabel: 'Wybierz przepis',
<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> filterButtonId: 'planner-picker-filter-btn',
<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"> filterButtonAction: 'openFilters(\'plannerPicker\')',
<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ąć"> filterButtonLabel: 'Otwórz filtry',
<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> </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-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 id="planner-picker-grid" class="grid grid-cols-3 gap-2 bg-[#2d2e2b]" style="background:#2d2e2b !important;"></div>
<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-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> </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) { function closeSheet(backdrop, sheet) {
if (!backdrop || !sheet) return; if (!backdrop || !sheet) return;
sheet.style.transition = 'transform 300ms cubic-bezier(0.32, 0.72, 0, 1)'; 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'); backdrop.classList.add('opacity-0');
setTimeout(() => { setTimeout(() => {
backdrop.classList.add('hidden'); backdrop.classList.add('hidden');
@@ -265,60 +296,117 @@ function closeSheet(backdrop, sheet) {
}, 300); }, 300);
} }
/** Zamykanie panelu: przeciągnięcie nagłówka w dół (pointer). */ /** Zamykanie panelu: przeciągnięcie w dół (pointer). */
function bindPlannerSheetDragClose(sheet, closeFn) { function bindPlannerSheetDragClose(sheet, closeFn, options = {}) {
const zone = sheet.querySelector('[data-planner-sheet-drag-zone]'); const zone = options.dragSurface || sheet.querySelector('[data-planner-sheet-drag-zone]');
const scrollEl = options.scrollEl || null;
if (!zone || !sheet) return; if (!zone || !sheet) return;
let startX = 0;
let startY = 0; let startY = 0;
let pulling = false;
let ptrId = null; let ptrId = null;
let engaged = false;
let startFromHandle = false;
let suppressClickUntil = 0;
const resetVisual = () => { const resetVisual = () => {
sheet.style.transition = 'transform 300ms cubic-bezier(0.32, 0.72, 0, 1)'; sheet.style.transition = 'transform 300ms cubic-bezier(0.32, 0.72, 0, 1)';
sheet.style.transform = 'translateY(0)'; 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) => { zone.addEventListener('pointerdown', (e) => {
if (e.pointerType === 'mouse' && e.button !== 0) return; if (e.pointerType === 'mouse' && e.button !== 0) return;
pulling = true; if (shouldIgnoreTarget(e.target)) return;
ptrId = e.pointerId;
startY = e.clientY;
sheet.style.transition = 'none';
zone.setPointerCapture(e.pointerId);
});
zone.addEventListener('pointermove', (e) => { if (scrollEl && scrollEl.scrollTop > 0 && !(e.target instanceof Element && e.target.closest('[data-planner-sheet-drag-zone]'))) {
if (!pulling || e.pointerId !== ptrId) return; 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 (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(); closeFn();
return; return;
} }
resetVisual(); resetVisual();
}); };
zone.addEventListener('pointercancel', () => { const onPointerCancel = (e) => {
pulling = false; if (ptrId !== null && e.pointerId !== ptrId) return;
ptrId = null; stopTracking();
resetVisual(); 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 sel = state.selected;
const dayPlan = getDayPlan(state.plans, sel); const dayPlan = getDayPlan(state.plans, sel);
const totals = sumDayNutrition(dayPlan); const totals = sumDayNutrition(dayPlan);
@@ -385,34 +473,39 @@ function renderDayContent(state) {
(entry.amountOverrides && Object.keys(entry.amountOverrides).length > 0) || (entry.amountOverrides && Object.keys(entry.amountOverrides).length > 0) ||
(entry.addedIngredients?.length > 0) || (entry.addedIngredients?.length > 0) ||
(entry.substitutions && Object.keys(entry.substitutions).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}` : ''; const servLabel = servings > 1 ? `<span class="mx-1.5 text-[#6d6c67]">·</span>×${servings}` : '';
return ` 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="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="flex items-start justify-between gap-2"> <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));">
<div class="flex items-center gap-2 min-w-0 cursor-pointer planner-open-recipe" data-recipe-id="${escapeHtml(recipe.id)}"> <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));">
<div class="w-8 h-8 rounded-lg bg-[#3a3a37] overflow-hidden shrink-0"> <i class="fas fa-trash text-[10px]" aria-hidden="true"></i>
${recipe.image Usuń
? `<img src="${escapeHtml(recipe.image)}" alt="" class="w-full h-full object-cover">` </span>
: `<span class="w-full h-full flex items-center justify-center text-white text-[8px] font-medium">${escapeHtml(recipe.thumbLabel)}</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-normal text-[#ddd6ca] truncate">${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>
<div class="min-w-0"> <div class="flex items-center gap-1 shrink-0 self-center">
<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> <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">
<p class="text-[11px] text-[#9b978f] mt-0.5 tabular-nums"> <i class="fas fa-pencil text-[9px]" aria-hidden="true"></i>
<i class="fas fa-clock text-[#7d7a74] mr-0.5" aria-hidden="true"></i>${recipe.minutes} min </button>
<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> </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>
</div>`; </div>`;
}).join(''); }).join('');
@@ -450,6 +543,147 @@ function renderDayContent(state) {
</div> </div>
</div>`; </div>`;
}).join(''); }).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) { function escapeHtml(s) {
@@ -481,71 +715,65 @@ function getRecentRecipeIds(plans, limit = 5) {
return [...seen.keys()]; return [...seen.keys()];
} }
function recipeCardHtml(r) { function sortPickerRecipes(recipes, plans) {
return ` const recentIndex = new Map(
<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}"> getRecentRecipeIds(plans, 12).map((recipeId, index) => [recipeId, index]),
<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">` return [...recipes].sort((a, b) => {
: `<span class="w-full h-full flex items-center justify-center text-white text-[9px] font-medium">${escapeHtml(r.thumbLabel)}</span>`} const aRecent = recentIndex.has(a.id) ? recentIndex.get(a.id) : Number.POSITIVE_INFINITY;
</div> const bRecent = recentIndex.has(b.id) ? recentIndex.get(b.id) : Number.POSITIVE_INFINITY;
<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> if (aRecent !== bRecent) return aRecent - bRecent;
<p class="text-[11px] text-[#9b978f] mt-1 tabular-nums"> return a.title.localeCompare(b.title, 'pl');
<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>`;
} }
let _pickerSlotRecipes = []; function matchesPickerFilters(recipe, filterState) {
let _pickerPlans = {}; 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 = '') { if (slots.length > 0 && !recipe.allowedSlots.some((slotId) => slots.includes(slotId))) return false;
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;
title.textContent = 'Wybierz przepis'; if (tags.length > 0) {
sub.textContent = slot ? `Dla: ${slot.label}` : ''; const recipeTags = (recipe.tags || []).map((tag) => tag.toLowerCase());
if (!tags.some((tag) => recipeTags.includes(tag.toLowerCase()))) return false;
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;
} }
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) { return true;
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>`;
}
}
html += filtered.map(recipeCardHtml).join(''); function renderPickerGrid(slotId, plans, query = '') {
list.innerHTML = html; 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: 'recipe-list-card',
});
} }
function plIngredientWord(n) { function plIngredientWord(n) {
@@ -755,8 +983,15 @@ export function setupMealPlanner() {
const pickerBackdrop = document.getElementById('planner-picker-backdrop'); const pickerBackdrop = document.getElementById('planner-picker-backdrop');
const pickerSheet = document.getElementById('planner-picker-sheet'); const pickerSheet = document.getElementById('planner-picker-sheet');
const pickerScroll = document.getElementById('planner-picker-scroll');
const ingBackdrop = document.getElementById('planner-ing-backdrop'); const ingBackdrop = document.getElementById('planner-ing-backdrop');
const ingSheet = document.getElementById('planner-ing-sheet'); const ingSheet = document.getElementById('planner-ing-sheet');
const pickerFilterState = {
slots: [],
tags: [],
minMinutes: PICKER_FILTER_MIN_MINUTES,
maxMinutes: PICKER_FILTER_MAX_MINUTES,
};
const rerender = () => { const rerender = () => {
syncModeToggle(state.mode); syncModeToggle(state.mode);
@@ -775,7 +1010,7 @@ export function setupMealPlanner() {
: dayHasAnyMeal(state.plans, day), : dayHasAnyMeal(state.plans, day),
}), }),
}); });
renderDayContent(state); renderDayContent(state, persist);
}; };
const persist = () => { const persist = () => {
@@ -881,8 +1116,13 @@ export function setupMealPlanner() {
state.pickerSlot = slotId; state.pickerSlot = slotId;
const searchInput = document.getElementById('planner-picker-search'); const searchInput = document.getElementById('planner-picker-search');
if (searchInput) searchInput.value = ''; 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); openSheet(pickerBackdrop, pickerSheet);
window.setTimeout(() => {
if (pickerScroll) pickerScroll.scrollTop = 0;
}, 40);
return; return;
} }
const editBtn = e.target.closest('.planner-edit-meal'); const editBtn = e.target.closest('.planner-edit-meal');
@@ -903,54 +1143,66 @@ export function setupMealPlanner() {
}); });
return; 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 = () => { const closePicker = () => {
state.pickerSlot = null; 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); 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) => { document.getElementById('planner-picker-search')?.addEventListener('input', (e) => {
if (state.pickerSlot) { 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)); bindPlannerSheetDragClose(ingSheet, () => closeSheet(ingBackdrop, ingSheet));
pickerBackdrop?.addEventListener('click', closePicker); pickerBackdrop?.addEventListener('click', closePicker);
document.getElementById('planner-picker-list')?.addEventListener('click', (e) => { document.getElementById('planner-picker-grid')?.addEventListener('click', (e) => {
const pick = e.target.closest('.planner-pick-recipe'); const pick = e.target.closest('.recipe-browser-card');
if (!pick || !state.pickerSlot) return; if (!pick || !state.pickerSlot) return;
const recipeId = pick.getAttribute('data-recipe-id'); const recipeId = pick.getAttribute('data-recipe-id');
if (!recipeId || !RECIPES[recipeId]) return; if (!recipeId || !RECIPES[recipeId]) return;
const slotId = state.pickerSlot; const slotId = state.pickerSlot;
closePicker(); closePicker();
setTimeout(() => { window.requestAnimationFrame(() => {
window.openMealPlanEditor?.({ window.openMealPlanEditor?.({
mode: 'add', mode: 'add',
recipeId, recipeId,
date: state.selected, date: state.selected,
slotId, slotId,
}); });
}, 320); });
}); });
document.getElementById('planner-open-ingredients')?.addEventListener('click', () => { document.getElementById('planner-open-ingredients')?.addEventListener('click', () => {

View File

@@ -1,25 +1,12 @@
import { RECIPES } from '../data/catalog.js?v=8'; 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
const slotLabelMap = Object.fromEntries(MEAL_SLOTS.map((s) => [s.id, s.label]));
const DEFAULT_MIN_MINUTES = 5; const DEFAULT_MIN_MINUTES = 5;
const DEFAULT_MAX_MINUTES = 120; 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 = { let filterState = {
query: '', query: '',
@@ -57,107 +44,51 @@ function getFilteredRecipes() {
return Object.values(RECIPES).filter(matchesFilters); 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() { function syncRecipeScrollShadow() {
const scroll = document.getElementById('recipe-scroll');
const searchShell = document.getElementById('recipe-search-shell'); const searchShell = document.getElementById('recipe-search-shell');
if (!searchShell) return; syncRecipeSearchShellShadow(searchShell);
if (!scroll) {
searchShell.style.boxShadow = SEARCH_SHELL_BASE_SHADOW;
return;
}
searchShell.style.boxShadow = SEARCH_SHELL_BASE_SHADOW;
} }
function renderAllRecipeCards() { function renderGrid() {
const grid = document.getElementById('recipe-grid');
if (!grid) return;
grid.innerHTML = Object.values(RECIPES).map(renderRecipeCard).join('');
}
function syncVisibleRecipeCards() {
const grid = document.getElementById('recipe-grid'); const grid = document.getElementById('recipe-grid');
const emptyState = document.getElementById('recipe-empty-state'); 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 (!grid) return;
if (rebuild || !grid.querySelector('[data-recipe-id]')) { renderRecipeGrid({
renderAllRecipeCards(); gridEl: grid,
} emptyStateEl: emptyState,
recipes: getFilteredRecipes(),
syncVisibleRecipeCards(); showSlotLabels: false,
cardClassName: 'recipe-list-card',
});
requestAnimationFrame(syncRecipeScrollShadow);
} }
export function getRecipeListHTML() { export function getRecipeListHTML() {
return ` return `
<div id="main-view" class="flex flex-col h-full absolute inset-0 bg-[#2d2e2b] z-10" style="background:#2d2e2b !important;"> <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-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;"> <div class="pointer-events-auto">
<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;"> ${getRecipeSearchFieldHTML({
<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"> shellId: 'recipe-search-shell',
<i class="fas fa-sliders-h"></i> inputId: 'recipe-search-input',
</button> placeholder: 'Szukaj przepisów...',
filterButtonId: 'recipe-filter-btn',
filterButtonAction: 'openFilters()',
filterButtonLabel: 'Otwórz filtry',
})}
</div> </div>
</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;"> ${getRecipeGridSectionHTML({
<div id="recipe-grid" class="grid grid-cols-2 gap-3 bg-[#2d2e2b]" style="background:#2d2e2b !important;"></div> scrollId: 'recipe-scroll',
${getEmptyStateHTML()} gridId: 'recipe-grid',
</div> emptyStateId: 'recipe-empty-state',
scrollClassName: 'relative flex-1 overflow-y-auto px-4 pt-24 pb-24 bg-[#2d2e2b]',
gridClassName: 'grid grid-cols-3 gap-2 bg-[#2d2e2b]',
emptyTitle: 'Brak wyników',
emptyMessage: 'Zmień kryteria wyszukiwania lub filtry',
})}
</div> </div>
`; `;
} }
@@ -176,17 +107,24 @@ export function getFilteredCount() {
} }
export function refreshRecipeList() { export function refreshRecipeList() {
renderGrid({ rebuild: true }); renderGrid();
} }
export function setupRecipeList() { export function setupRecipeList() {
renderGrid({ rebuild: true }); renderGrid();
document.getElementById('recipe-search-input')?.addEventListener('input', (e) => { document.getElementById('recipe-search-input')?.addEventListener('input', (e) => {
filterState.query = e.target.value.trim(); filterState.query = e.target.value.trim();
renderGrid(); 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); document.getElementById('recipe-scroll')?.addEventListener('scroll', syncRecipeScrollShadow);
syncRecipeScrollShadow(); syncRecipeScrollShadow();
} }