Implement planner recipe detail interactions and refine dock styling
All checks were successful
Build and Deploy / build-and-push (push) Successful in 1m14s

This commit is contained in:
2026-04-10 22:05:25 +02:00
parent 8a8a4ad3fd
commit dff88b1c98
4 changed files with 210 additions and 45 deletions

View File

@@ -408,7 +408,8 @@
letter-spacing: -0.02em;
}
#recipe-search-input::placeholder,
#planner-picker-search::placeholder {
#planner-picker-search::placeholder,
#pantry-search::placeholder {
color: #beb8ae !important;
opacity: 1;
}
@@ -458,7 +459,7 @@
z-index: 30;
display: flex;
justify-content: center;
padding: 0 0.85rem calc(1.12rem + env(safe-area-inset-bottom));
padding: 0 0.85rem calc(1.58rem + env(safe-area-inset-bottom));
pointer-events: none;
}
#app-bottom-nav .bottom-dock {

View File

@@ -335,6 +335,9 @@ export function createIngredientCardController({ idBase, defaultSourceNote = 'Ze
if (hasProducts && !product) {
const stockedCount = getPantryProducts(state.ingredientId, pantry).filter((i) => i.qty > 0).length;
const helperChip = state.allowProductSelection
? '<span class="inline-flex items-center rounded-full px-2 py-1 text-[10px] font-semibold shrink-0" style="background:#2f2f2d; color:#d7d2c8;">Wybierz produkt</span>'
: '';
wrap.innerHTML = `
<p class="text-[9px] font-semibold uppercase tracking-wide mb-1.5" style="color:#9b978f;">Zapas</p>
<div class="rounded-2xl border px-3 py-3" style="background:#393937; border-color:#444442;">
@@ -344,7 +347,7 @@ export function createIngredientCardController({ idBase, defaultSourceNote = 'Ze
<p class="text-[16px] font-bold tabular-nums mt-1" style="color:#6ee7b7;">${esc(formatQty(totalQty))} ${esc(unit)}</p>
<p class="text-[11px] mt-1 leading-snug" style="color:#9b978f;">${stockedCount} z ${getProductsForIngredient(state.ingredientId).length} produktów ma zapas</p>
</div>
<span class="inline-flex items-center rounded-full px-2 py-1 text-[10px] font-semibold shrink-0" style="background:#2f2f2d; color:#d7d2c8;">Wybierz produkt</span>
${helperChip}
</div>
</div>`;
return;

View File

@@ -71,7 +71,7 @@ function syncTodayButton(mode, weekStart, monthAnchor, selected) {
export function getMealPlannerHTML() {
return `
<div id="planner-view" class="hidden flex flex-col h-full absolute inset-0 overflow-hidden bg-[#2d2e2b] z-10 pb-24">
<div id="planner-view" class="hidden flex flex-col h-full absolute inset-0 overflow-hidden bg-[#2d2e2b] z-10">
<div id="planner-cal-bar" class="shrink-0 bg-[#2d2e2b] border-b border-[#444442] mt-3 relative z-10">
${createCalendarTopbarHTML({
titleId: 'cal-period-label',
@@ -95,7 +95,7 @@ export function getMealPlannerHTML() {
</div>
</div>
<div id="planner-scroll" class="flex-1 overflow-y-auto px-4 pt-3 pb-4 bg-[#2d2e2b]">
<div id="planner-scroll" class="flex-1 overflow-y-auto px-4 pt-3 pb-24 bg-[#2d2e2b]">
<div id="planner-summary-card" class="mb-3">
<div class="h-full flex flex-col" style="background:#2d2e2b !important; background-image:none !important; box-shadow:none !important;">
<p class="text-[10px] font-bold text-gray-400 uppercase tracking-wider mb-2">Wartości odżywcze</p>
@@ -523,9 +523,9 @@ function renderDayContent(state, onMealRemoved = null) {
<div class="pointer-events-none absolute inset-0 flex items-center justify-end px-4" style="${backgroundStyle}">
${backgroundLabel}
</div>
<div class="relative z-[1] rounded-lg p-2" style="background:${isPendingDelete ? 'rgba(45,45,43,0.76)' : '#2d2e2b'}; box-shadow:inset 0 1px 3px rgba(0,0,0,0.3); transform:${isPendingDelete ? 'translateX(0) scale(0.988)' : 'translateX(0)'}; transition:transform 180ms cubic-bezier(0.22, 1, 0.36, 1), opacity 180ms ease, background-color 180ms ease; touch-action:pan-y; opacity:1;" data-planner-swipe-card data-slot-id="${slot.id}" data-entry-id="${eid}">
<div class="relative z-[1] rounded-lg p-2 planner-open-recipe cursor-pointer" style="background:${isPendingDelete ? 'rgba(45,45,43,0.76)' : '#2d2e2b'}; box-shadow:inset 0 1px 3px rgba(0,0,0,0.3); transform:${isPendingDelete ? 'translateX(0) scale(0.988)' : 'translateX(0)'}; transition:transform 180ms cubic-bezier(0.22, 1, 0.36, 1), opacity 180ms ease, background-color 180ms ease; touch-action:pan-y; opacity:1;" data-planner-swipe-card data-slot-id="${slot.id}" data-entry-id="${eid}" data-recipe-id="${escapeHtml(recipe.id)}">
<div class="relative flex items-start justify-between gap-2">
<div class="flex items-center gap-2 min-w-0 cursor-pointer planner-open-recipe" style="${contentToneStyle}" data-recipe-id="${escapeHtml(recipe.id)}">
<div class="flex items-center gap-2 min-w-0" style="${contentToneStyle}">
<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">`
@@ -1258,14 +1258,6 @@ export function setupMealPlanner() {
}
return;
}
const openRecipe = e.target.closest('.planner-open-recipe');
if (openRecipe) {
const recipeId = openRecipe.getAttribute('data-recipe-id');
if (recipeId && window.openRecipeDetail) {
window.openRecipeDetail(recipeId);
}
return;
}
const addBtn = e.target.closest('.planner-add-meal');
if (addBtn) {
const slotId = addBtn.getAttribute('data-slot-id');
@@ -1299,6 +1291,19 @@ export function setupMealPlanner() {
});
return;
}
const openRecipe = e.target.closest('.planner-open-recipe');
if (openRecipe) {
const recipeId = openRecipe.getAttribute('data-recipe-id');
if (recipeId && window.openRecipeDetail) {
const slotId = openRecipe.closest('[data-slot-id]')?.getAttribute('data-slot-id');
const entryId = openRecipe.closest('[data-entry-id]')?.getAttribute('data-entry-id');
const key = dateKey(state.selected);
const entries = slotId ? state.plans[key]?.[slotId] : null;
const entry = Array.isArray(entries) ? entries.find((item) => item && item.id === entryId) : null;
window.openRecipeDetail(recipeId, entry ? { plannedEntry: entry } : {});
}
return;
}
});
const closePicker = () => {

View File

@@ -1,5 +1,6 @@
import { RECIPES, INGREDIENTS } from '../data/catalog.js?v=8';
import { RECIPES, INGREDIENTS, PRODUCTS } from '../data/catalog.js?v=8';
import { MEAL_SLOTS } from '../planner/mealSlots.js';
import { createIngredientCardController, getIngredientCardHTML } from '../ui/ingredientCard.js?v=20260410-107';
function escapeHtml(s) {
return String(s)
@@ -99,30 +100,117 @@ export function getRecipeDetailHTML() {
</div>
</div>
</div>
${getIngredientCardHTML({ idBase: 'rd-ing-card' })}
`;
}
/* ── state ─────────────────────────────────────────────── */
let currentRecipeId = null;
let currentMode = 'catalog';
let currentServings = 1;
let currentSubstitutions = {};
let currentExcludedIngredients = new Set();
let currentAmountOverrides = {};
let currentAddedIngredients = [];
let currentProductSelections = {};
let expandedAlternatives = new Set();
let ingredientCard = null;
function isPlannedMode() {
return currentMode === 'planned';
}
function getEffectiveIngredientId(originalId) {
return currentSubstitutions[originalId] || originalId;
}
function clampServings(value) {
return Math.max(1, Math.min(12, Number(value) || 1));
}
function cloneAddedIngredients(items) {
if (!Array.isArray(items)) return [];
return items
.filter((item) => item && typeof item.ingredientId === 'string' && typeof item.amount === 'number' && typeof item.unit === 'string')
.map((item) => ({ ingredientId: item.ingredientId, amount: item.amount, unit: item.unit }));
}
function getSelectedProduct(productId) {
return productId && PRODUCTS[productId] ? PRODUCTS[productId] : null;
}
function getProductSelectionForIngredient(...ingredientIds) {
for (const ingredientId of ingredientIds) {
if (!ingredientId) continue;
const productId = currentProductSelections[ingredientId];
if (getSelectedProduct(productId)) return productId;
}
return null;
}
function buildVisibleIngredients(recipe) {
if (!recipe) return [];
const rows = [];
for (const ing of recipe.ingredients) {
const originalId = ing.ingredientId;
if (currentExcludedIngredients.has(originalId)) continue;
const effectiveId = getEffectiveIngredientId(originalId);
const effectiveDef = INGREDIENTS[effectiveId];
const productId = getProductSelectionForIngredient(effectiveId, originalId);
rows.push({
key: `recipe:${originalId}`,
originalId,
ingredientId: effectiveId,
name: effectiveDef?.name || effectiveId,
amount: (currentAmountOverrides[originalId] ?? ing.amount) * currentServings,
unit: ing.unit,
productId,
productName: getSelectedProduct(productId)?.name || '',
added: false,
alternatives: Array.isArray(ing.alternatives) ? ing.alternatives : [],
});
}
currentAddedIngredients.forEach((item, index) => {
const def = INGREDIENTS[item.ingredientId];
const productId = getProductSelectionForIngredient(item.ingredientId);
rows.push({
key: `added:${index}:${item.ingredientId}`,
originalId: item.ingredientId,
ingredientId: item.ingredientId,
name: def?.name || item.ingredientId,
amount: item.amount * currentServings,
unit: item.unit,
productId,
productName: getSelectedProduct(productId)?.name || '',
added: true,
alternatives: [],
});
});
return rows;
}
/* ── populate ──────────────────────────────────────────── */
function populateDetail(recipeId) {
function populateDetail(recipeId, options = {}) {
const recipe = RECIPES[recipeId];
if (!recipe) return;
currentRecipeId = recipeId;
currentServings = 1;
currentSubstitutions = {};
currentMode = options.plannedEntry ? 'planned' : 'catalog';
currentServings = clampServings(options.servings ?? options.plannedEntry?.servings ?? 1);
currentSubstitutions = { ...(options.substitutions || options.plannedEntry?.substitutions || {}) };
currentExcludedIngredients = new Set(options.excludedIngredients || options.plannedEntry?.excludedIngredients || []);
currentAmountOverrides = { ...(options.amountOverrides || options.plannedEntry?.amountOverrides || {}) };
currentAddedIngredients = cloneAddedIngredients(options.addedIngredients || options.plannedEntry?.addedIngredients);
currentProductSelections = { ...(options.productSelections || options.plannedEntry?.productSelections || {}) };
expandedAlternatives.clear();
ingredientCard?.close();
const heroImg = document.getElementById('rd-hero-img');
const heroLabel = document.getElementById('rd-hero-label');
@@ -138,6 +226,7 @@ function populateDetail(recipeId) {
}
document.getElementById('rd-title').textContent = recipe.title;
document.getElementById('rd-time').textContent = `${recipe.minutes} min`;
document.getElementById('rd-add-to-planner-btn')?.classList.toggle('hidden', isPlannedMode());
const tagsHtml = [];
for (const slotId of recipe.allowedSlots) {
@@ -165,19 +254,22 @@ function populateDetail(recipeId) {
/* ── helpers ───────────────────────────────────────────── */
function nutritionForAmount(ingredientId, amount, unit) {
function nutritionForAmount(ingredientId, amount, unit, productIdOverride = null) {
const def = INGREDIENTS[ingredientId];
if (!def?.nutritionPer100g) return null;
const productId = productIdOverride || getProductSelectionForIngredient(ingredientId);
const product = getSelectedProduct(productId);
const nutrition = product?.nutritionPer100g || def?.nutritionPer100g;
if (!def || !nutrition) return null;
let grams = amount;
if ((unit === 'szt.' || unit === 'szt') && def.weightPerPiece) {
grams = amount * def.weightPerPiece;
}
const f = grams / 100;
return {
kcal: Math.round(def.nutritionPer100g.kcal * f),
protein: Math.round(def.nutritionPer100g.protein * f * 10) / 10,
fat: Math.round(def.nutritionPer100g.fat * f * 10) / 10,
carbs: Math.round(def.nutritionPer100g.carbs * f * 10) / 10,
kcal: Math.round(nutrition.kcal * f),
protein: Math.round(nutrition.protein * f * 10) / 10,
fat: Math.round(nutrition.fat * f * 10) / 10,
carbs: Math.round(nutrition.carbs * f * 10) / 10,
};
}
@@ -189,9 +281,8 @@ function fmtAmt(n) {
function computeEffectiveNutritionTotals(recipe) {
let kcal = 0, protein = 0, fat = 0, carbs = 0;
for (const ing of recipe.ingredients) {
const effectiveId = getEffectiveIngredientId(ing.ingredientId);
const n = nutritionForAmount(effectiveId, ing.amount * currentServings, ing.unit);
for (const ing of buildVisibleIngredients(recipe)) {
const n = nutritionForAmount(ing.ingredientId, ing.amount, ing.unit, ing.productId);
if (n) {
kcal += n.kcal;
protein += n.protein;
@@ -209,6 +300,25 @@ function computeEffectiveNutritionTotals(recipe) {
function renderNutritionSummary(recipe) {
const total = computeEffectiveNutritionTotals(recipe);
const servingsHtml = isPlannedMode()
? `
<div class="mt-3 flex items-center justify-between gap-3">
<p class="text-[10px] font-bold text-gray-400 uppercase tracking-wider">Porcje</p>
<p class="pr-1 text-[13px] font-semibold leading-none text-[#d7d2c8] tabular-nums">${currentServings}</p>
</div>`
: `
<div class="mt-3 flex items-center justify-between gap-3">
<p class="text-[10px] font-bold text-gray-400 uppercase tracking-wider">Porcje</p>
<div class="flex h-[2rem] w-[5.25rem] shrink-0 items-center gap-0.5 rounded-full border px-0.5" style="background:#2f2f2d;border-color:#444442;box-shadow:0 2px 8px rgba(0,0,0,0.25);">
<button type="button" id="rd-serv-minus" class="shrink-0 w-7 h-full flex items-center justify-center rounded-full border-0 bg-transparent text-[#d7d2c8] transition-colors" aria-label="Zmniejsz liczbę porcji">
<i class="fas fa-minus text-[10px]"></i>
</button>
<span id="rd-servings" class="flex-1 h-full inline-flex items-center justify-center px-0.5 text-[12px] font-semibold leading-none text-[#d7d2c8] tabular-nums">${currentServings}</span>
<button type="button" id="rd-serv-plus" class="shrink-0 w-7 h-full flex items-center justify-center rounded-full border-0 bg-transparent text-[#d7d2c8] transition-colors" aria-label="Zwiększ liczbę porcji">
<i class="fas fa-plus text-[10px]"></i>
</button>
</div>
</div>`;
return `
<div class="mb-4">
@@ -235,18 +345,7 @@ function renderNutritionSummary(recipe) {
</div>
</div>
</div>
<div class="mt-3 flex items-center justify-between gap-3">
<p class="text-[10px] font-bold text-gray-400 uppercase tracking-wider">Porcje</p>
<div class="flex h-[2rem] w-[5.25rem] shrink-0 items-center gap-0.5 rounded-full border px-0.5" style="background:#2f2f2d;border-color:#444442;box-shadow:0 2px 8px rgba(0,0,0,0.25);">
<button type="button" id="rd-serv-minus" class="shrink-0 w-7 h-full flex items-center justify-center rounded-full border-0 bg-transparent text-[#d7d2c8] transition-colors" aria-label="Zmniejsz liczbę porcji">
<i class="fas fa-minus text-[10px]"></i>
</button>
<span id="rd-servings" class="flex-1 h-full inline-flex items-center justify-center px-0.5 text-[12px] font-semibold leading-none text-[#d7d2c8] tabular-nums">${currentServings}</span>
<button type="button" id="rd-serv-plus" class="shrink-0 w-7 h-full flex items-center justify-center rounded-full border-0 bg-transparent text-[#d7d2c8] transition-colors" aria-label="Zwiększ liczbę porcji">
<i class="fas fa-plus text-[10px]"></i>
</button>
</div>
</div>
${servingsHtml}
</div>`;
}
@@ -254,6 +353,59 @@ function renderIngredients(recipe) {
const container = document.getElementById('rd-tab-ingredients');
if (!container) return;
if (isPlannedMode()) {
const items = buildVisibleIngredients(recipe);
const rows = items.map((item) => {
const rowClass = 'rd-ing-row rounded-xl px-3 py-3 w-full text-left cursor-pointer transition-colors active:scale-[0.99]';
const rowStyle = 'background:#393937 !important; background-image:none !important; box-shadow:0 2px 8px rgba(0,0,0,0.25) !important; border:none !important;';
const productBadge = item.productName
? `<div class="flex items-center gap-1 mt-0.5"><span class="text-[10px] text-emerald-400 truncate">${escapeHtml(item.productName)}</span></div>`
: '';
const addedMark = item.added
? '<span class="shrink-0 inline-flex items-center justify-center text-[#8f8b84]" title="Dodany składnik" aria-label="Dodany składnik"><i class="fas fa-plus text-[8px]"></i></span>'
: '';
return `<li>
<button type="button" class="${rowClass}" style="${rowStyle}" data-rd-open-ingredient data-rd-ingredient-id="${escapeHtml(item.ingredientId)}" data-rd-product-id="${escapeHtml(item.productId || '')}">
<div class="flex items-center gap-2">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-1.5">
<span class="text-[12px] font-semibold text-gray-900 truncate block">${escapeHtml(item.name)}</span>
${addedMark}
</div>
${productBadge}
</div>
<div class="shrink-0 flex items-center gap-1 px-2 py-1 rounded-lg">
<span class="text-[12px] font-semibold text-gray-900 tabular-nums">${fmtAmt(item.amount)}</span>
<span class="text-[11px] text-gray-500">${escapeHtml(item.unit)}</span>
</div>
</div>
</button>
</li>`;
}).join('');
container.innerHTML = `
${renderNutritionSummary(recipe)}
${rows
? `<ul class="space-y-1.5" id="rd-ingredient-list">${rows}</ul>`
: `<p class="text-sm text-center py-8" style="color:${RD_THEME.textMuted};">Brak składników w tej wersji przepisu.</p>`}`;
container.querySelectorAll('[data-rd-open-ingredient]').forEach((btn) => {
btn.addEventListener('click', () => {
const ingredientId = btn.dataset.rdIngredientId;
if (!ingredientId || !ingredientCard) return;
const productId = btn.dataset.rdProductId || null;
ingredientCard.open({
ingredientId,
productId,
selectedProductId: productId,
allowProductSelection: false,
sourceNote: 'Z planera',
});
});
});
return;
}
const rows = recipe.ingredients.map((ing) => {
const origId = ing.ingredientId;
const hasAlts = ing.alternatives && ing.alternatives.length > 0;
@@ -369,9 +521,9 @@ function renderSteps(recipe) {
}
container.innerHTML = `
<div class="space-y-2 pb-5">
<div class="space-y-0.5 pb-5">
${steps.map((step, i) => `
<div class="rounded-xl p-3 flex gap-3" style="background:transparent !important; background-image:none !important; box-shadow:none !important; border:none !important;">
<div class="rounded-xl px-3 py-2 flex gap-3" style="background:transparent !important; background-image:none !important; box-shadow:none !important; border:none !important;">
<div class="w-6 h-6 rounded-full flex items-center justify-center text-[11px] font-bold shrink-0" style="background:transparent !important; border:none !important; box-shadow:none !important; color:${RD_THEME.textSecondary} !important;">${i + 1}</div>
<div class="pt-0.5"><p class="text-[13px] leading-relaxed" style="color:${RD_THEME.textSecondary};">${escapeHtml(step)}</p></div>
</div>`).join('')}
@@ -381,6 +533,9 @@ function renderSteps(recipe) {
/* ── setup ─────────────────────────────────────────────── */
export function setupRecipeDetail() {
ingredientCard = createIngredientCardController({ idBase: 'rd-ing-card', defaultSourceNote: 'Z planera' });
ingredientCard.bind();
document.querySelectorAll('.rd-tab-btn').forEach((btn) => {
btn.addEventListener('click', () => {
const tabId = btn.dataset.rdTab;
@@ -415,7 +570,7 @@ export function setupRecipeDetail() {
/* ── planner — delegate to MealPlanEditor ─────── */
document.getElementById('rd-add-to-planner-btn')?.addEventListener('click', () => {
if (!currentRecipeId) return;
if (!currentRecipeId || isPlannedMode()) return;
window.openMealPlanEditor?.({
mode: 'add',
recipeId: currentRecipeId,
@@ -424,15 +579,16 @@ export function setupRecipeDetail() {
});
});
window.openRecipeDetail = (recipeId) => {
window.openRecipeDetail = (recipeId, options = {}) => {
if (!recipeId || !RECIPES[recipeId]) return;
populateDetail(recipeId);
populateDetail(recipeId, options);
const view = document.getElementById('recipe-detail-view');
view.classList.remove('translate-x-full', 'opacity-0', 'pointer-events-none');
view.classList.add('translate-x-0', 'opacity-100', 'pointer-events-auto');
};
window.closeRecipeDetail = () => {
ingredientCard?.close();
window.closeMealPlanEditor?.();
const view = document.getElementById('recipe-detail-view');
view.classList.remove('translate-x-0', 'opacity-100', 'pointer-events-auto');