diff --git a/js/app.js b/js/app.js index b99edb2..20a5122 100644 --- a/js/app.js +++ b/js/app.js @@ -1,6 +1,6 @@ import { getRecipeListHTML, setupRecipeList } from './views/RecipeList.js'; import { getFilterHTML, setupFilter } from './views/Filter.js'; -import { getRecipeDetailHTML, setupRecipeDetail } from './views/RecipeDetail.js'; +import { getRecipeDetailHTML, setupRecipeDetail } from './views/RecipeDetailV2.js'; import { getMealPlannerHTML, setupMealPlanner } from './views/MealPlanner.js'; import { getPantryHTML, refreshPantry, setupPantry } from './views/Pantry.js'; import { getShoppingHTML, refreshShopping, setupShopping } from './views/Shopping.js'; diff --git a/js/views/RecipeDetailV2.js b/js/views/RecipeDetailV2.js new file mode 100644 index 0000000..8edca87 --- /dev/null +++ b/js/views/RecipeDetailV2.js @@ -0,0 +1,716 @@ +import { RECIPES, INGREDIENTS } from '../data/catalog.js'; +import { MEAL_SLOTS } from '../planner/mealSlots.js'; +import { addDays, addMonths, sameDay, sameMonth, startOfDay, startOfMonth, startOfWeekMonday } from '../services/dateUtils.js'; +import { dateKey, loadPlans, newPlanEntryId, savePlans } from '../services/planStore.js'; +import { showAppToast } from '../ui/toast.js'; + +function escapeHtml(s) { + return String(s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +const slotLabelMap = Object.fromEntries(MEAL_SLOTS.map((s) => [s.id, s.label])); +const MONTHS_LONG = ['Styczeń', 'Luty', 'Marzec', 'Kwiecień', 'Maj', 'Czerwiec', 'Lipiec', 'Sierpień', 'Wrzesień', 'Październik', 'Listopad', 'Grudzień']; +const WEEKDAYS_SHORT = ['Pn', 'Wt', 'Śr', 'Cz', 'Pt', 'So', 'Nd']; + +export function getRecipeDetailHTML() { + return ` +
+
+ + +
+ + + +
+ +
+ +
+
+
+

+
+
+
+
+
+
+
+ +
+ 1 + +
+ +
+
+
+ +
+ + +
+ +
+
+ +
+
+
+ `; +} + +/* ── state ─────────────────────────────────────────────── */ + +let currentRecipeId = null; +let currentServings = 1; +let currentSubstitutions = {}; +let expandedAlternatives = new Set(); + +function getEffectiveIngredientId(originalId) { + return currentSubstitutions[originalId] || originalId; +} + +/* ── populate ──────────────────────────────────────────── */ + +function populateDetail(recipeId) { + const recipe = RECIPES[recipeId]; + if (!recipe) return; + + currentRecipeId = recipeId; + currentServings = 1; + currentSubstitutions = {}; + expandedAlternatives.clear(); + + document.getElementById('rd-hero-label').textContent = `Zdjęcie: ${recipe.title}`; + document.getElementById('rd-title').textContent = recipe.title; + document.getElementById('rd-time').textContent = `${recipe.minutes} min`; + updateKcalDisplay(); + + const tagsHtml = []; + for (const slotId of recipe.allowedSlots) { + const label = slotLabelMap[slotId]; + if (label) tagsHtml.push(`${escapeHtml(label)}`); + } + for (const tag of (recipe.tags || [])) { + tagsHtml.push(`${escapeHtml(tag)}`); + } + document.getElementById('rd-tags').innerHTML = tagsHtml.join(''); + document.getElementById('rd-servings').textContent = '1'; + + renderIngredients(recipe); + renderSteps(recipe); + + const tabBtns = document.querySelectorAll('.rd-tab-btn'); + const tabs = document.querySelectorAll('.rd-tab-content'); + tabBtns.forEach((b) => { + b.classList.remove('text-gray-900', 'border-gray-900', 'font-semibold'); + b.classList.add('text-gray-500', 'border-transparent', 'font-medium'); + }); + tabBtns[0]?.classList.remove('text-gray-500', 'border-transparent', 'font-medium'); + tabBtns[0]?.classList.add('text-gray-900', 'border-gray-900', 'font-semibold'); + tabs.forEach((t) => { t.classList.add('hidden'); t.classList.remove('block'); }); + document.getElementById('rd-tab-ingredients')?.classList.remove('hidden'); + document.getElementById('rd-tab-ingredients')?.classList.add('block'); +} + +/* ── helpers ───────────────────────────────────────────── */ + +function updateKcalDisplay() { + const el = document.getElementById('rd-kcal'); + if (!el) return; + const recipe = RECIPES[currentRecipeId]; + if (!recipe) return; + const kcal = Math.round(recipe.nutritionPerServing.kcal * currentServings); + el.textContent = `${kcal} kcal`; +} + +function nutritionForAmount(ingredientId, amount) { + const def = INGREDIENTS[ingredientId]; + if (!def?.nutritionPer100g) return null; + const f = amount / 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, + }; +} + +function renderNutritionLine(nutrition) { + if (!nutrition) return ''; + return `
${nutrition.kcal} kcal${nutrition.protein}g białko${nutrition.fat}g tłuszcz${nutrition.carbs}g węgle
`; +} + +/* ── ingredients tab with inline nutrition + summary ───── */ + +function computeIngredientNutritionTotals(recipe) { + let kcal = 0, protein = 0, fat = 0, carbs = 0; + for (const ing of recipe.ingredients) { + const n = nutritionForAmount(ing.ingredientId, ing.amount * currentServings); + if (n) { + kcal += n.kcal; + protein += n.protein; + fat += n.fat; + carbs += n.carbs; + } + } + return { + kcal: Math.round(kcal), + protein: Math.round(protein * 10) / 10, + fat: Math.round(fat * 10) / 10, + carbs: Math.round(carbs * 10) / 10, + }; +} + +function renderNutritionSummary(recipe) { + const n = recipe.nutritionPerServing; + const s = currentServings; + const total = { + kcal: Math.round(n.kcal * s * 10) / 10, + protein: Math.round(n.protein * s * 10) / 10, + fat: Math.round(n.fat * s * 10) / 10, + carbs: Math.round(n.carbs * s * 10) / 10, + }; + + return ` +
+ ${s > 1 ? `

Wartości dla ${s} porcji

` : ''} +
+
+ ${total.kcal} + Kalorie +
+
+ ${total.protein} g + Białko +
+
+ ${total.carbs} g + Węglo. +
+
+ ${total.fat} g + Tłuszcze +
+
+
`; +} + +function renderIngredientCard(name, amount, unit, nutrition, extra) { + const displayAmount = Number.isInteger(amount) ? amount : parseFloat(amount.toFixed(1)); + const macroHtml = nutrition + ? `
+ ${nutrition.protein}g B${nutrition.fat}g T${nutrition.carbs}g W +
` + : ''; + const kcalHtml = nutrition + ? `${nutrition.kcal} kcal` + : ''; + + return ` +
+
+
+ ${extra?.prefix || ''} + ${escapeHtml(name)} + ${extra?.suffix || ''} +
+ ${macroHtml} + ${extra?.badge || ''} +
+
+ ${displayAmount} ${escapeHtml(unit)} + ${kcalHtml} +
+
`; +} + +function renderIngredients(recipe) { + const container = document.getElementById('rd-tab-ingredients'); + if (!container) return; + + const rows = recipe.ingredients.map((ing) => { + const def = INGREDIENTS[ing.ingredientId]; + const name = def?.name || ing.ingredientId; + const scaledAmount = ing.amount * currentServings; + const nutrition = nutritionForAmount(ing.ingredientId, scaledAmount); + + const hasAlts = ing.alternatives && ing.alternatives.length > 0; + const isExpanded = expandedAlternatives.has(ing.ingredientId); + + const toggleBtn = hasAlts + ? `` + : ''; + + const cardHtml = renderIngredientCard(name, scaledAmount, ing.unit, nutrition, { suffix: toggleBtn }); + + let altListHtml = ''; + if (hasAlts) { + const altCards = ing.alternatives.map((altId) => { + const altDef = INGREDIENTS[altId]; + const altName = altDef?.name || altId; + const altNutrition = nutritionForAmount(altId, scaledAmount); + return renderIngredientCard(altName, scaledAmount, ing.unit, altNutrition, { + cls: 'bg-gray-50', + border: 'border-gray-100', + }); + }); + altListHtml = ` +
+

Można zamienić na:

+ ${altCards.join('')} +
`; + } + + return `
  • ${cardHtml}${altListHtml}
  • `; + }).join(''); + + container.innerHTML = ` + ${renderNutritionSummary(recipe)} + `; + + container.querySelectorAll('.rd-alt-toggle').forEach((btn) => { + btn.addEventListener('click', () => { + const origId = btn.dataset.originalId; + if (expandedAlternatives.has(origId)) { + expandedAlternatives.delete(origId); + } else { + expandedAlternatives.add(origId); + } + const list = container.querySelector(`.rd-alt-list[data-original-id="${origId}"]`); + if (list) list.classList.toggle('hidden'); + btn.classList.toggle('bg-gray-100'); + btn.classList.toggle('text-gray-400'); + btn.classList.toggle('bg-amber-50'); + btn.classList.toggle('text-amber-500'); + }); + }); +} + +/* ── steps tab ─────────────────────────────────────────── */ + +function renderSteps(recipe) { + const container = document.getElementById('rd-tab-steps'); + if (!container) return; + + const steps = recipe.steps || []; + if (steps.length === 0) { + container.innerHTML = '

    Brak kroków przygotowania.

    '; + return; + } + + container.innerHTML = ` +
    + ${steps.map((step, i) => ` +
    +
    ${i + 1}
    +

    ${escapeHtml(step)}

    +
    `).join('')} +
    `; +} + +/* ── setup ─────────────────────────────────────────────── */ + +export function setupRecipeDetail() { + document.querySelectorAll('.rd-tab-btn').forEach((btn) => { + btn.addEventListener('click', () => { + const tabId = btn.dataset.rdTab; + document.querySelectorAll('.rd-tab-content').forEach((el) => { + el.classList.add('hidden'); + el.classList.remove('block'); + }); + const target = document.getElementById(`rd-tab-${tabId}`); + if (target) { + target.classList.remove('hidden'); + target.classList.add('block'); + target.parentElement.scrollTop = 0; + } + + document.querySelectorAll('.rd-tab-btn').forEach((b) => { + b.classList.remove('text-gray-900', 'border-gray-900', 'font-semibold'); + b.classList.add('text-gray-500', 'border-transparent', 'font-medium'); + }); + btn.classList.remove('text-gray-500', 'border-transparent', 'font-medium'); + btn.classList.add('text-gray-900', 'border-gray-900', 'font-semibold'); + }); + }); + + document.getElementById('rd-serv-minus')?.addEventListener('click', () => { + if (currentServings <= 1) return; + currentServings--; + document.getElementById('rd-servings').textContent = currentServings; + const recipe = RECIPES[currentRecipeId]; + if (recipe) { + renderIngredients(recipe); + updateKcalDisplay(); + } + }); + + document.getElementById('rd-serv-plus')?.addEventListener('click', () => { + if (currentServings >= 12) return; + currentServings++; + document.getElementById('rd-servings').textContent = currentServings; + const recipe = RECIPES[currentRecipeId]; + if (recipe) { + renderIngredients(recipe); + updateKcalDisplay(); + } + }); + + /* ── planner picker ──────────────────────────────── */ + + let plannerPickerDay = null; + let plannerPickerSlot = null; + let calViewDate = null; + let calExpanded = false; + + const plannerOverlay = document.getElementById('rd-planner-picker'); + const plannerSheet = document.getElementById('rd-planner-sheet'); + + function renderCalendarCell(d, today, activeMonth) { + const isToday = sameDay(d, today); + const isSelected = plannerPickerDay && sameDay(d, plannerPickerDay); + const isOtherMonth = d.getMonth() !== activeMonth; + const isPast = d.getTime() < today.getTime(); + + let cls = 'flex flex-col items-center justify-center rounded-md text-xs font-medium transition-colors w-full min-h-10 py-1 gap-0.5 leading-tight '; + + if (isSelected) { + cls += 'bg-gray-900 text-white '; + } else if (isPast) { + cls += 'text-gray-300 '; + } else if (isOtherMonth) { + cls += 'text-gray-300 '; + } else { + cls += 'text-gray-800 hover:bg-gray-100 '; + } + + if (isToday && !isSelected && !isPast) { + cls += 'ring-1 ring-inset ring-gray-900 '; + } + + const inner = `${d.getDate()}`; + + if (isPast && !isSelected) { + return `
    ${inner}
    `; + } + + return ``; + } + + function renderPlannerCalendar() { + const grid = document.getElementById('rd-cal-grid'); + const titleEl = document.getElementById('rd-cal-title'); + const todayBtn = document.getElementById('rd-cal-today'); + const toggleIcon = document.getElementById('rd-cal-toggle-icon'); + if (!grid || !titleEl) return; + + const today = startOfDay(new Date()); + + if (calExpanded) { + const ms = startOfMonth(calViewDate); + titleEl.textContent = `${MONTHS_LONG[ms.getMonth()]} ${ms.getFullYear()}`; + toggleIcon.className = 'fas fa-chevron-up text-[10px]'; + + const firstCell = startOfWeekMonday(ms); + const cells = []; + let d = new Date(firstCell); + for (let i = 0; i < 42; i++) { + cells.push(new Date(d)); + d = addDays(d, 1); + } + while (cells.length > 7) { + const last7 = cells.slice(-7); + if (last7.every((c) => c.getMonth() !== ms.getMonth())) cells.splice(-7); + else break; + } + + grid.innerHTML = cells.map((c) => renderCalendarCell(c, today, ms.getMonth())).join(''); + todayBtn.classList.toggle('hidden', sameMonth(today, calViewDate)); + } else { + const ws = startOfWeekMonday(calViewDate); + titleEl.textContent = `${MONTHS_LONG[calViewDate.getMonth()]} ${calViewDate.getFullYear()}`; + toggleIcon.className = 'fas fa-chevron-down text-[10px]'; + + const cells = []; + for (let i = 0; i < 7; i++) cells.push(addDays(ws, i)); + grid.innerHTML = cells.map((c) => renderCalendarCell(c, today, calViewDate.getMonth())).join(''); + + const todayWs = startOfWeekMonday(today); + todayBtn.classList.toggle('hidden', sameDay(ws, todayWs)); + } + + grid.querySelectorAll('.rd-cal-day').forEach((btn) => { + btn.addEventListener('click', () => { + plannerPickerDay = new Date(Number(btn.dataset.ts)); + calViewDate = new Date(plannerPickerDay); + renderPlannerCalendar(); + }); + }); + } + + document.getElementById('rd-cal-prev')?.addEventListener('click', () => { + calViewDate = calExpanded ? addMonths(calViewDate, -1) : addDays(calViewDate, -7); + renderPlannerCalendar(); + }); + document.getElementById('rd-cal-next')?.addEventListener('click', () => { + calViewDate = calExpanded ? addMonths(calViewDate, 1) : addDays(calViewDate, 7); + renderPlannerCalendar(); + }); + document.getElementById('rd-cal-today')?.addEventListener('click', () => { + const today = startOfDay(new Date()); + plannerPickerDay = today; + calViewDate = today; + renderPlannerCalendar(); + }); + document.getElementById('rd-cal-toggle')?.addEventListener('click', () => { + calExpanded = !calExpanded; + renderPlannerCalendar(); + }); + + /* ── planner variant selection ────────────────────── */ + + const expandedVariantGroups = new Set(); + + function renderPlannerVariants(recipe) { + const section = document.getElementById('rd-planner-variant'); + const container = document.getElementById('rd-planner-variant-options'); + if (!section || !container) return; + + const altsIngredients = recipe.ingredients.filter((i) => i.alternatives?.length > 0); + if (altsIngredients.length === 0) { + section.classList.add('hidden'); + return; + } + section.classList.remove('hidden'); + + let html = ''; + for (const ing of altsIngredients) { + const origId = ing.ingredientId; + const scaledAmount = ing.amount * currentServings; + const displayAmount = Number.isInteger(scaledAmount) ? scaledAmount : parseFloat(scaledAmount.toFixed(1)); + const effectiveId = getEffectiveIngredientId(origId); + const effectiveDef = INGREDIENTS[effectiveId]; + const effectiveName = effectiveDef?.name || effectiveId; + const isExpanded = expandedVariantGroups.has(origId); + const isSwapped = effectiveId !== origId; + + html += `
    + `; + + if (isExpanded) { + const allOptions = [origId, ...ing.alternatives]; + html += '
    '; + for (const altId of allOptions) { + const def = INGREDIENTS[altId]; + const altName = def?.name || altId; + const isSelected = effectiveId === altId; + const isOriginal = altId === origId; + const nutrition = nutritionForAmount(altId, scaledAmount); + + const selectedCls = isSelected + ? 'border-gray-900 bg-gray-50 ring-1 ring-gray-900' + : 'border-gray-200 bg-white hover:border-gray-300'; + + let tag = ''; + if (isOriginal) tag = `Domyślny`; + + html += ` + `; + } + html += '
    '; + } + + html += '
    '; + } + + container.innerHTML = html; + } + + document.getElementById('rd-planner-variant-options')?.addEventListener('click', (e) => { + const toggle = e.target.closest('.rd-variant-toggle'); + if (toggle) { + const origId = toggle.dataset.originalId; + if (expandedVariantGroups.has(origId)) expandedVariantGroups.delete(origId); + else expandedVariantGroups.add(origId); + const recipe = RECIPES[currentRecipeId]; + if (recipe) renderPlannerVariants(recipe); + return; + } + + const btn = e.target.closest('.rd-variant-option'); + if (!btn) return; + const originalId = btn.dataset.originalId; + const altId = btn.dataset.altId; + if (altId === originalId) { + delete currentSubstitutions[originalId]; + } else { + currentSubstitutions[originalId] = altId; + } + expandedVariantGroups.delete(originalId); + const recipe = RECIPES[currentRecipeId]; + if (recipe) renderPlannerVariants(recipe); + }); + + function openPlannerPicker() { + const recipe = RECIPES[currentRecipeId]; + if (!recipe) return; + + document.getElementById('rd-planner-recipe-name').textContent = recipe.title; + currentSubstitutions = {}; + expandedVariantGroups.clear(); + + const today = startOfDay(new Date()); + plannerPickerDay = today; + calViewDate = today; + calExpanded = false; + renderPlannerCalendar(); + + renderPlannerVariants(recipe); + + plannerPickerSlot = recipe.allowedSlots[0] || MEAL_SLOTS[0]?.id; + const slotsContainer = document.getElementById('rd-planner-slots'); + slotsContainer.innerHTML = MEAL_SLOTS.filter((s) => recipe.allowedSlots.includes(s.id)).map((s) => { + const sel = s.id === plannerPickerSlot; + return ``; + }).join(''); + + plannerOverlay.classList.remove('hidden'); + plannerOverlay.style.pointerEvents = 'auto'; + requestAnimationFrame(() => { + plannerSheet.style.transform = 'translateY(0)'; + }); + } + + function closePlannerPicker() { + plannerSheet.style.transform = 'translateY(100%)'; + setTimeout(() => { + plannerOverlay.classList.add('hidden'); + plannerOverlay.style.pointerEvents = 'none'; + }, 300); + } + + document.getElementById('rd-add-to-planner-btn')?.addEventListener('click', openPlannerPicker); + + plannerOverlay?.addEventListener('click', (e) => { + if (e.target === plannerOverlay) closePlannerPicker(); + }); + + document.getElementById('rd-planner-slots')?.addEventListener('click', (e) => { + const btn = e.target.closest('.rd-plan-slot-btn'); + if (!btn) return; + plannerPickerSlot = btn.getAttribute('data-slot-id'); + document.querySelectorAll('.rd-plan-slot-btn').forEach((b) => { + b.classList.remove('border-gray-900', 'bg-gray-900', 'text-white'); + b.classList.add('border-gray-200', 'bg-gray-50', 'text-gray-700'); + }); + btn.classList.remove('border-gray-200', 'bg-gray-50', 'text-gray-700'); + btn.classList.add('border-gray-900', 'bg-gray-900', 'text-white'); + }); + + document.getElementById('rd-planner-confirm')?.addEventListener('click', () => { + if (!currentRecipeId || !plannerPickerDay || !plannerPickerSlot) return; + const plans = loadPlans(); + const key = dateKey(plannerPickerDay); + if (!plans[key]) plans[key] = {}; + if (!plans[key][plannerPickerSlot]) plans[key][plannerPickerSlot] = []; + + const entry = { + id: newPlanEntryId(), + recipeId: currentRecipeId, + servings: currentServings, + }; + if (Object.keys(currentSubstitutions).length > 0) { + entry.substitutions = { ...currentSubstitutions }; + } + + plans[key][plannerPickerSlot].push(entry); + savePlans(plans); + closePlannerPicker(); + showAppToast('Dodano do planera!'); + window.refreshPlanner?.(); + }); + + window.openRecipeDetail = (recipeId) => { + if (!recipeId || !RECIPES[recipeId]) return; + populateDetail(recipeId); + 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 = () => { + closePlannerPicker(); + const view = document.getElementById('recipe-detail-view'); + view.classList.remove('translate-x-0', 'opacity-100', 'pointer-events-auto'); + view.classList.add('translate-x-full', 'opacity-0', 'pointer-events-none'); + }; +}