Update planner search and planner editor

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

View File

@@ -271,15 +271,16 @@ export function getMealPlanEditorHTML() {
<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>
<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">
<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-add-area" class="mt-2"></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));">
<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);">
<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 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>
</button>
</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-subtitle').textContent = recipe.title;
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();
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="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]" 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="flex flex-wrap gap-1">
${labels.map((label) => `<span class="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)';
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;
}