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:
@@ -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 = () => {
|
||||
|
||||
@@ -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