/** * Modul: Rezepte (Recipes) * Zweck: Gespeicherte Rezepte verwalten und in den Essensplan uebernehmen */ import { api } from '/api.js'; import { t } from '/i18n.js'; import { openModal as openSharedModal, closeModal as closeSharedModal } from '/components/modal.js'; import { DEFAULT_CATEGORY_NAME, categoryLabel } from '/utils/shopping-categories.js'; import { renderKitchenTabsBar } from '/utils/kitchen-tabs.js'; import { esc } from '/utils/html.js'; let _container = null; const state = { recipes: [], categories: [], familyMembers: [], recipeSignals: [], }; const MEAL_CATEGORY_OPTIONS = [ ['meat', 'Kød'], ['fish', 'Fisk'], ['pasta', 'Pasta'], ['rice', 'Ris'], ['vegetarian', 'Grønt'], ['soup', 'Suppe'], ['leftovers', 'Rester'], ['cozy', 'Hygge'], ['other', 'Andet'], ]; const PROTEIN_OPTIONS = [['mixed', 'Blandet'], ['chicken', 'Kylling'], ['beef', 'Okse'], ['pork', 'Svin'], ['fish', 'Fisk'], ['vegetarian', 'Vegetar'], ['none', 'Ingen'], ['other', 'Andet']]; const STYLE_OPTIONS = [['family', 'Familie'], ['quick', 'Hurtig'], ['cozy', 'Hygge'], ['grill', 'Grill'], ['vegetarian', 'Vegetar'], ['kids', 'Børnevenlig'], ['leftovers', 'Rester'], ['other', 'Andet']]; function optionHtml(options, selected) { return options.map(([value, label]) => ``).join(''); } function optionLabel(options, value) { return options.find(([optionValue]) => optionValue === value)?.[1] || value || ''; } function taxonomyChip(kind, value, label) { if (!value || !label) return null; const chip = document.createElement('span'); chip.className = `recipe-taxonomy-chip recipe-taxonomy-chip--${kind}`; chip.dataset.recipeTaxonomy = kind; chip.dataset.taxonomyValue = value; chip.textContent = label; return chip; } function mealCategories() { return state.categories.filter((c) => c.name !== 'Haushalt' && c.name !== 'Drogerie'); } async function loadRecipes() { const res = await api.get('/recipes'); state.recipes = res.data; } async function loadCategories() { try { const res = await api.get('/shopping/categories'); state.categories = res.data; } catch { state.categories = []; } } async function loadFamilyMembers() { try { const res = await api.get('/family/members'); state.familyMembers = res.data; } catch { state.familyMembers = []; } } async function loadRecipeSignals() { try { const res = await api.get('/meal-planning/recipe-signals'); state.recipeSignals = res.data; } catch { state.recipeSignals = []; } } function signalsForRecipe(recipeId) { return state.recipeSignals.filter((signal) => Number(signal.recipe_id) === Number(recipeId)); } function signalFor(recipeId, userId) { return state.recipeSignals.find((signal) => Number(signal.recipe_id) === Number(recipeId) && Number(signal.user_id) === Number(userId)); } async function saveRecipeSignal(recipeId, userId, patch) { const current = signalFor(recipeId, userId) || {}; const payload = { user_id: userId, preference: current.preference || 'neutral', can_cook: !!current.can_cook, can_help_cook: !!current.can_help_cook, will_eat_modified: !!current.will_eat_modified, adult_only: !!current.adult_only, ...patch, }; const res = await api.put(`/meal-planning/recipe-signals/${recipeId}`, payload); state.recipeSignals = state.recipeSignals.filter((signal) => !(Number(signal.recipe_id) === Number(recipeId) && Number(signal.user_id) === Number(userId))); state.recipeSignals.push(res.data); return res.data; } export async function render(container) { _container = container; const page = document.createElement('div'); page.className = 'recipes-page'; const header = document.createElement('div'); header.className = 'recipes-header'; const title = document.createElement('h1'); title.className = 'recipes-header__title'; title.textContent = t('recipes.title'); const addBtn = document.createElement('button'); addBtn.className = 'btn btn--primary'; addBtn.type = 'button'; addBtn.id = 'recipes-add'; addBtn.textContent = t('recipes.addRecipe'); header.append(title, addBtn); const list = document.createElement('div'); list.className = 'recipes-list'; list.id = 'recipes-list'; const fab = document.createElement('button'); fab.className = 'page-fab'; fab.type = 'button'; fab.id = 'recipes-fab'; fab.setAttribute('aria-label', t('recipes.addRecipe')); const fabIcon = document.createElement('i'); fabIcon.dataset.lucide = 'plus'; fabIcon.setAttribute('aria-hidden', 'true'); fab.appendChild(fabIcon); page.append(header, list, fab); container.replaceChildren(page); renderKitchenTabsBar(container, '/recipes'); if (window.lucide) window.lucide.createIcons(); await Promise.all([loadRecipes(), loadCategories(), loadFamilyMembers(), loadRecipeSignals()]); renderRecipeList(); addBtn.addEventListener('click', () => openRecipeModal('create')); fab.addEventListener('click', () => openRecipeModal('create')); list.addEventListener('click', async (e) => { const actionBtn = e.target.closest('[data-action]'); if (!actionBtn) return; const recipeId = Number(actionBtn.dataset.id); const recipe = state.recipes.find((r) => r.id === recipeId); if (!recipe) return; if (actionBtn.dataset.action === 'edit') { openRecipeModal('edit', recipe); return; } if (actionBtn.dataset.action === 'delete') { await removeRecipe(recipe); return; } if (actionBtn.dataset.action === 'duplicate') { await duplicateRecipe(recipe); return; } if (actionBtn.dataset.action === 'add-to-meals') { window.oikos?.navigate(`/meals?recipe=${recipe.id}`); } if (actionBtn.dataset.action === 'quick-favorite') { const memberId = Number(actionBtn.dataset.memberId); await saveRecipeSignal(recipe.id, memberId, { preference: 'favorite' }); renderRecipeList(); window.oikos?.showToast('Favorit gemt på profilen', 'success'); } }); } function renderRecipeList() { const list = _container.querySelector('#recipes-list'); if (!list) return; list.replaceChildren(); if (!state.recipes.length) { const empty = document.createElement('div'); empty.className = 'empty-state'; const emptyTitle = document.createElement('div'); emptyTitle.className = 'empty-state__title'; emptyTitle.textContent = t('recipes.emptyTitle'); const emptyDesc = document.createElement('div'); emptyDesc.className = 'empty-state__description'; emptyDesc.textContent = t('recipes.emptyDescription'); const emptyHint = document.createElement('p'); emptyHint.className = 'empty-state__hint'; emptyHint.textContent = t('emptyHint.recipes'); const emptyCta = document.createElement('button'); emptyCta.className = 'btn btn--primary empty-state__cta'; emptyCta.insertAdjacentHTML('afterbegin', ''); emptyCta.append(document.createTextNode(t('recipes.emptyAction'))); emptyCta.addEventListener('click', () => { document.querySelector('.page-fab')?.click(); }); empty.append(emptyTitle, emptyDesc, emptyHint, emptyCta); list.appendChild(empty); if (window.lucide) window.lucide.createIcons({ el: empty }); return; } for (const recipe of state.recipes) { const card = document.createElement('article'); card.className = 'recipe-card'; card.dataset.id = String(recipe.id); const h = document.createElement('h2'); h.className = 'recipe-card__title'; h.textContent = recipe.title; card.appendChild(h); const taxonomy = [ taxonomyChip('category', recipe.meal_category, optionLabel(MEAL_CATEGORY_OPTIONS, recipe.meal_category)), taxonomyChip('protein', recipe.protein, optionLabel(PROTEIN_OPTIONS, recipe.protein)), taxonomyChip('style', recipe.style, optionLabel(STYLE_OPTIONS, recipe.style)), ].filter(Boolean); if (taxonomy.length) { const meta = document.createElement('div'); meta.className = 'recipe-card__taxonomy'; meta.setAttribute('aria-label', 'Recipe classification'); meta.append(...taxonomy); card.appendChild(meta); } const recipeSignals = signalsForRecipe(recipe.id); const favoriteSignals = recipeSignals.filter((signal) => signal.preference === 'favorite'); if (favoriteSignals.length) { const fav = document.createElement('p'); fav.className = 'recipe-card__notes'; fav.textContent = `⭐ Favorit hos ${favoriteSignals.map((signal) => signal.user_name).filter(Boolean).join(', ')}`; card.appendChild(fav); } if (recipe.notes) { const notes = document.createElement('p'); notes.className = 'recipe-card__notes'; notes.textContent = recipe.notes; card.appendChild(notes); } if (recipe.recipe_url) { const link = document.createElement('a'); link.className = 'btn btn--ghost'; link.href = recipe.recipe_url; link.target = '_blank'; link.rel = 'noopener noreferrer'; link.textContent = t('recipes.openLink'); card.appendChild(link); } const ingredients = recipe.ingredients ?? []; if (ingredients.length) { const ul = document.createElement('ul'); ul.className = 'recipe-card__ingredients'; for (const ing of ingredients) { const li = document.createElement('li'); li.className = 'recipe-card__ingredient'; const qty = ing.quantity ? `${ing.quantity} · ` : ''; li.textContent = `${qty}${ing.name}`; ul.appendChild(li); } card.appendChild(ul); } const actions = document.createElement('div'); actions.className = 'recipe-card__actions'; const addToMeals = document.createElement('button'); addToMeals.className = 'btn btn--secondary'; addToMeals.type = 'button'; addToMeals.dataset.action = 'add-to-meals'; addToMeals.dataset.id = String(recipe.id); addToMeals.textContent = t('recipes.addToMeals'); const edit = document.createElement('button'); edit.className = 'btn btn--secondary'; edit.type = 'button'; edit.dataset.action = 'edit'; edit.dataset.id = String(recipe.id); edit.textContent = t('common.edit'); const del = document.createElement('button'); del.className = 'btn btn--danger'; del.type = 'button'; del.dataset.action = 'delete'; del.dataset.id = String(recipe.id); del.textContent = t('common.delete'); const duplicate = document.createElement('button'); duplicate.className = 'btn btn--secondary'; duplicate.type = 'button'; duplicate.dataset.action = 'duplicate'; duplicate.dataset.id = String(recipe.id); duplicate.textContent = t('recipes.duplicate'); actions.append(addToMeals, edit, duplicate, del); card.appendChild(actions); if (state.familyMembers.length) { const pref = document.createElement('div'); pref.className = 'recipe-card__actions'; const label = document.createElement('span'); label.className = 'recipe-card__notes'; label.textContent = 'Gem som favorit for:'; pref.appendChild(label); for (const member of state.familyMembers.slice(0, 6)) { const btn = document.createElement('button'); btn.className = 'btn btn--ghost'; btn.type = 'button'; btn.dataset.action = 'quick-favorite'; btn.dataset.id = String(recipe.id); btn.dataset.memberId = String(member.id); btn.textContent = `⭐ ${member.display_name}`; pref.appendChild(btn); } card.appendChild(pref); } list.appendChild(card); } } function buildIngredientRow(name, qty, category = DEFAULT_CATEGORY_NAME) { const categories = mealCategories(); const resolvedCategory = categories.some((c) => c.name === category) ? category : (categories[0]?.name ?? DEFAULT_CATEGORY_NAME); const row = document.createElement('div'); row.className = 'recipe-ingredient-row'; const nameInput = document.createElement('input'); nameInput.type = 'text'; nameInput.className = 'form-input recipe-ingredient-row__name'; nameInput.placeholder = t('meals.ingredientNamePlaceholder'); nameInput.value = name; const qtyInput = document.createElement('input'); qtyInput.type = 'text'; qtyInput.className = 'form-input recipe-ingredient-row__qty'; qtyInput.placeholder = t('meals.ingredientQtyPlaceholder'); qtyInput.value = qty; const catSelect = document.createElement('select'); catSelect.className = 'form-input recipe-ingredient-row__cat'; catSelect.setAttribute('aria-label', t('meals.ingredientCategoryLabel')); if (categories.length) { for (const c of categories) { const opt = document.createElement('option'); opt.value = c.name; opt.textContent = categoryLabel(c.name); if (c.name === resolvedCategory) opt.selected = true; catSelect.appendChild(opt); } } else { const opt = document.createElement('option'); opt.value = DEFAULT_CATEGORY_NAME; opt.textContent = t('meals.ingredientCategoryDefault'); opt.selected = true; catSelect.appendChild(opt); } const removeBtn = document.createElement('button'); removeBtn.className = 'recipe-ingredient-row__remove'; removeBtn.dataset.action = 'remove-ingredient'; removeBtn.type = 'button'; removeBtn.setAttribute('aria-label', t('meals.removeIngredient')); const icon = document.createElement('i'); icon.dataset.lucide = 'x'; icon.style.cssText = 'width:14px;height:14px;'; icon.setAttribute('aria-hidden', 'true'); removeBtn.appendChild(icon); row.append(nameInput, qtyInput, catSelect, removeBtn); return row; } function openRecipeModal(mode, recipe = null) { const isEdit = mode === 'edit'; openSharedModal({ title: isEdit ? t('recipes.editRecipe') : t('recipes.addRecipe'), size: 'md', content: `