diff --git a/CHANGELOG.md b/CHANGELOG.md index 950f210..b8a6c82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.22.0] - 2026-04-21 + +### Added +- Recipes module: create, edit, duplicate, and delete reusable recipes with title, notes, a recipe link, and a per-ingredient category. Accessible via the new `/recipes` route and nav entry. +- "Add to meal plan" action on recipe cards navigates to Meals and pre-fills the modal with the selected recipe. +- Meals modal: select a saved recipe to auto-fill title, notes, URL, and ingredients; scale ingredient quantities by a numeric factor; save the current meal as a new recipe in one click. +- `GET/POST /api/v1/recipes`, `PUT/DELETE /api/v1/recipes/:id` REST endpoints with full validation and ingredient sync. +- Migration 13: `recipes` and `recipe_ingredients` tables; `recipe_id` FK column on `meals`. + ## [0.21.1] - 2026-04-21 ### Fixed diff --git a/package-lock.json b/package-lock.json index f287a80..f64d934 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "oikos", - "version": "0.21.1", + "version": "0.22.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "oikos", - "version": "0.21.1", + "version": "0.22.0", "license": "MIT", "dependencies": { "bcrypt": "^6.0.0", diff --git a/package.json b/package.json index be73faf..4a47d5c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "oikos", - "version": "0.21.1", + "version": "0.22.0", "description": "Self-hosted family planner - calendar, tasks, shopping, meal planning, budget and more. Private, open-source, no subscription.", "main": "server/index.js", "type": "module", diff --git a/public/locales/de.json b/public/locales/de.json index c4ff9bc..69aa1dc 100644 --- a/public/locales/de.json +++ b/public/locales/de.json @@ -44,7 +44,7 @@ "navigation": "Navigation", "quickActions": "Schnellaktionen", "more": "Mehr", - "recipes": "Recipes" + "recipes": "Rezepte" }, "search": { "title": "Suche", @@ -258,10 +258,10 @@ "recipeUrlLabel": "Rezept-Link (optional)", "recipeUrlPlaceholder": "https://…", "openRecipe": "Rezept öffnen", - "savedRecipeLabel": "Saved recipe", - "savedRecipePlaceholder": "Select recipe", - "saveAsRecipe": "Save as recipe", - "recipeScaleLabel": "Scale ingredients" + "savedRecipeLabel": "Gespeichertes Rezept", + "savedRecipePlaceholder": "Rezept auswählen", + "saveAsRecipe": "Als Rezept speichern", + "recipeScaleLabel": "Zutaten skalieren" }, "calendar": { "title": "Kalender", @@ -683,27 +683,27 @@ "pendingBadgeTitlePlural": "{{count}} fällige Erinnerungen" }, "recipes": { - "title": "Recipes", - "addRecipe": "Add recipe", - "editRecipe": "Edit recipe", - "emptyTitle": "No recipes yet", - "emptyDescription": "Save your favorite recipes and reuse them in meal planning.", - "titleLabel": "Title *", - "titlePlaceholder": "e.g. Pasta Carbonara", - "notesLabel": "Notes", - "notesPlaceholder": "Optional...", - "urlLabel": "Recipe link", - "urlPlaceholder": "https://...", - "ingredientsLabel": "Ingredients", - "addToMeals": "Add to meal plan", - "openLink": "Open recipe link", - "deleteConfirm": "Delete recipe \"{{title}}\"?", - "created": "Recipe saved.", - "updated": "Recipe updated.", - "deleted": "Recipe deleted.", - "titleRequired": "Title is required", - "duplicate": "Duplicate", - "duplicated": "Recipe duplicated.", - "copySuffix": "copy" + "title": "Rezepte", + "addRecipe": "Rezept hinzufügen", + "editRecipe": "Rezept bearbeiten", + "emptyTitle": "Noch keine Rezepte", + "emptyDescription": "Speichere deine Lieblingsrezepte und nutze sie für die Essensplanung.", + "titleLabel": "Titel *", + "titlePlaceholder": "z. B. Pasta Carbonara", + "notesLabel": "Notizen", + "notesPlaceholder": "Optional…", + "urlLabel": "Rezept-Link", + "urlPlaceholder": "https://…", + "ingredientsLabel": "Zutaten", + "addToMeals": "In Essensplan übernehmen", + "openLink": "Rezept-Link öffnen", + "deleteConfirm": "Rezept „{{title}}" löschen?", + "created": "Rezept gespeichert.", + "updated": "Rezept aktualisiert.", + "deleted": "Rezept gelöscht.", + "titleRequired": "Titel ist erforderlich.", + "duplicate": "Duplizieren", + "duplicated": "Rezept dupliziert.", + "copySuffix": "Kopie" } } diff --git a/public/pages/recipes.js b/public/pages/recipes.js index d790a73..48e85cd 100644 --- a/public/pages/recipes.js +++ b/public/pages/recipes.js @@ -5,7 +5,6 @@ import { api } from '/api.js'; import { t } from '/i18n.js'; -import { esc } from '/utils/html.js'; import { openModal as openSharedModal, closeModal as closeSharedModal, confirmModal } from '/components/modal.js'; import { DEFAULT_CATEGORY_NAME, categoryLabel } from '/utils/shopping-categories.js'; @@ -211,32 +210,63 @@ function renderRecipeList() { } } -function recipeIngredientRowHTML(name, qty, category = DEFAULT_CATEGORY_NAME) { +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 catOptions = categories.length - ? categories.map((c) => ``).join('') - : ``; - return ` -
- - - - -
- `; + 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'; - const ingredientRows = isEdit && recipe.ingredients?.length - ? recipe.ingredients.map((i) => recipeIngredientRowHTML(i.name, i.quantity ?? '', i.category ?? DEFAULT_CATEGORY_NAME)).join('') - : ''; openSharedModal({ title: isEdit ? t('recipes.editRecipe') : t('recipes.addRecipe'), @@ -244,19 +274,19 @@ function openRecipeModal(mode, recipe = null) { content: `
- +
- +
- +
-
${ingredientRows}
+
`, onSave(panel) { + panel.querySelector('#recipe-title').value = isEdit ? recipe.title : ''; + panel.querySelector('#recipe-notes').value = isEdit && recipe.notes ? recipe.notes : ''; + panel.querySelector('#recipe-url').value = isEdit && recipe.recipe_url ? recipe.recipe_url : ''; + const ingList = panel.querySelector('#recipe-ingredient-list'); + if (isEdit && recipe.ingredients?.length) { + for (const i of recipe.ingredients) { + ingList.appendChild(buildIngredientRow(i.name, i.quantity ?? '', i.category ?? DEFAULT_CATEGORY_NAME)); + } + } + panel.querySelector('#recipe-add-ingredient')?.addEventListener('click', () => { - const tmp = document.createElement('div'); - tmp.innerHTML = recipeIngredientRowHTML('', '', null); - const row = tmp.firstElementChild; - ingList.appendChild(row); + ingList.appendChild(buildIngredientRow('', '', null)); if (window.lucide) window.lucide.createIcons(); }); @@ -362,8 +399,8 @@ async function duplicateRecipe(recipe) { })); try { - await api.post('/recipes', { title, notes, recipe_url, ingredients }); - await loadRecipes(); + const res = await api.post('/recipes', { title, notes, recipe_url, ingredients }); + state.recipes.push(res.data); renderRecipeList(); window.oikos?.showToast(t('recipes.duplicated'), 'success'); } catch (err) { diff --git a/public/router.js b/public/router.js index d189621..9cf46f1 100644 --- a/public/router.js +++ b/public/router.js @@ -582,7 +582,7 @@ function navItems() { { path: '/tasks', label: t('nav.tasks'), icon: 'check-square' }, { path: '/calendar', label: t('nav.calendar'), icon: 'calendar' }, { path: '/meals', label: t('nav.meals'), icon: 'utensils' }, - { path: '/recipes', label: t('nav.recipes'), icon: 'book-open-text' }, + { path: '/recipes', label: t('nav.recipes'), icon: 'book-text' }, { path: '/shopping', label: t('nav.shopping'), icon: 'shopping-cart' }, { path: '/notes', label: t('nav.notes'), icon: 'sticky-note' }, { path: '/contacts', label: t('nav.contacts'), icon: 'book-user' }, diff --git a/server/db-schema-test.js b/server/db-schema-test.js index c370072..03f9024 100644 --- a/server/db-schema-test.js +++ b/server/db-schema-test.js @@ -233,6 +233,11 @@ const MIGRATIONS_SQL = { CREATE INDEX IF NOT EXISTS idx_calendar_sub ON calendar_events(subscription_id); `, 12: ` + DROP INDEX IF EXISTS idx_calendar_sub_extid; + CREATE UNIQUE INDEX idx_calendar_sub_extid + ON calendar_events (subscription_id, external_calendar_id); + `, + 13: ` CREATE TABLE IF NOT EXISTS recipes ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, diff --git a/server/routes/recipes.js b/server/routes/recipes.js index 462b4f4..e301bd3 100644 --- a/server/routes/recipes.js +++ b/server/routes/recipes.js @@ -110,8 +110,9 @@ router.put('/:id', (req, res) => { const id = parseInt(req.params.id, 10); if (!id) return res.status(400).json({ error: 'Ungueltige Rezept-ID', code: 400 }); - const existing = db.get().prepare('SELECT id FROM recipes WHERE id = ?').get(id); + const existing = db.get().prepare('SELECT id, created_by FROM recipes WHERE id = ?').get(id); if (!existing) return res.status(404).json({ error: 'Rezept nicht gefunden', code: 404 }); + if (existing.created_by !== req.session.userId) return res.status(403).json({ error: 'Nicht autorisiert.', code: 403 }); const { ingredients = [] } = req.body; @@ -154,8 +155,11 @@ router.put('/:id', (req, res) => { router.delete('/:id', (req, res) => { try { const id = parseInt(req.params.id, 10); - const existing = num(id, 'Rezept-ID', { required: true }); - if (existing.error) return res.status(400).json({ error: existing.error, code: 400 }); + if (!id) return res.status(400).json({ error: 'Ungültige Rezept-ID.', code: 400 }); + + const existing = db.get().prepare('SELECT id, created_by FROM recipes WHERE id = ?').get(id); + if (!existing) return res.status(404).json({ error: 'Rezept nicht gefunden.', code: 404 }); + if (existing.created_by !== req.session.userId) return res.status(403).json({ error: 'Nicht autorisiert.', code: 403 }); const result = db.get().prepare('DELETE FROM recipes WHERE id = ?').run(id); if (result.changes === 0) return res.status(404).json({ error: 'Rezept nicht gefunden', code: 404 });