Implement planner recipe detail interactions and refine dock styling
All checks were successful
Build and Deploy / build-and-push (push) Successful in 1m14s
All checks were successful
Build and Deploy / build-and-push (push) Successful in 1m14s
This commit is contained in:
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user