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

@@ -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');