diff --git a/CHANGELOG.md b/CHANGELOG.md index 97ab076..efcaa4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.13.0] - 2026-04-05 + +### Added +- Meals: optional recipe link per meal - add a URL in the meal modal and a link icon appears on the card for one-tap access to the recipe (#18) +- Meals: `recipe_url` field stored in the database (migration v6) + ## [0.12.0] - 2026-04-05 ### Added diff --git a/public/locales/de.json b/public/locales/de.json index 9b917b5..2a0255f 100644 --- a/public/locales/de.json +++ b/public/locales/de.json @@ -234,7 +234,10 @@ "ingredientCount": "{{count}} Zutat", "ingredientCountPlural": "{{count}} Zutaten", "titleRequired": "Titel ist erforderlich", - "loadingIndicator": "Lade…" + "loadingIndicator": "Lade…", + "recipeUrlLabel": "Rezept-Link (optional)", + "recipeUrlPlaceholder": "https://…", + "openRecipe": "Rezept öffnen" }, "calendar": { diff --git a/public/locales/en.json b/public/locales/en.json index 353964e..249993a 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -234,7 +234,10 @@ "ingredientCount": "{{count}} ingredient", "ingredientCountPlural": "{{count}} ingredients", "titleRequired": "Title is required", - "loadingIndicator": "Loading…" + "loadingIndicator": "Loading…", + "recipeUrlLabel": "Recipe link (optional)", + "recipeUrlPlaceholder": "https://…", + "openRecipe": "Open recipe" }, "calendar": { diff --git a/public/locales/it.json b/public/locales/it.json index 51202c1..bc27378 100644 --- a/public/locales/it.json +++ b/public/locales/it.json @@ -234,7 +234,10 @@ "ingredientCount": "{{count}} ingrediente", "ingredientCountPlural": "{{count}} ingredienti", "titleRequired": "Il titolo è obbligatorio", - "loadingIndicator": "Caricamento…" + "loadingIndicator": "Caricamento…", + "recipeUrlLabel": "Link ricetta (opzionale)", + "recipeUrlPlaceholder": "https://…", + "openRecipe": "Apri ricetta" }, "calendar": { diff --git a/public/locales/sv.json b/public/locales/sv.json index 4f87a89..0630a78 100644 --- a/public/locales/sv.json +++ b/public/locales/sv.json @@ -234,7 +234,10 @@ "ingredientCount": "{{count}} ingrediens", "ingredientCountPlural": "{{count}} ingredienser", "titleRequired": "Titel krävs", - "loadingIndicator": "Laddar…" + "loadingIndicator": "Laddar…", + "recipeUrlLabel": "Receptlänk (valfri)", + "recipeUrlPlaceholder": "https://…", + "openRecipe": "Öppna recept" }, "calendar": { diff --git a/public/pages/meals.js b/public/pages/meals.js index 13dfde9..126c918 100644 --- a/public/pages/meals.js +++ b/public/pages/meals.js @@ -219,6 +219,13 @@ function renderSlot(date, type, mealsForDay) { ${ingLabel}${esc(ingDoneLabel)} ` : ''}
+ ${meal.recipe_url ? `` : ''} ${canTransfer ? `
+
+ + +
+
${ingRows}
@@ -623,6 +643,7 @@ async function saveModal(overlay) { const meal_type = overlay.querySelector('#modal-type').value; const title = overlay.querySelector('#modal-title').value.trim(); const notes = overlay.querySelector('#modal-notes').value.trim() || null; + const recipe_url = overlay.querySelector('#modal-recipe-url').value.trim() || null; if (!title) { window.oikos?.showToast(t('meals.titleRequired'), 'error'); @@ -643,11 +664,11 @@ async function saveModal(overlay) { const { mode, meal } = state.modal; if (mode === 'create') { - const res = await api.post('/meals', { date, meal_type, title, notes, ingredients }); + const res = await api.post('/meals', { date, meal_type, title, notes, recipe_url, ingredients }); state.meals.push(res.data); } else { // Update meal meta - await api.put(`/meals/${meal.id}`, { date, meal_type, title, notes }); + await api.put(`/meals/${meal.id}`, { date, meal_type, title, notes, recipe_url }); // Sync ingredients const existingIds = new Set((meal.ingredients ?? []).map((i) => i.id)); diff --git a/public/styles/meals.css b/public/styles/meals.css index d9fc5c5..fde8256 100644 --- a/public/styles/meals.css +++ b/public/styles/meals.css @@ -266,6 +266,14 @@ color: var(--color-success); } +.meal-card__action-btn--recipe { + text-decoration: none; +} + +.meal-card__action-btn--recipe:hover { + color: var(--color-accent); +} + /* -------------------------------------------------------- * Meals-Modal Content-Styles (Overlay/Panel via shared modal.js) * -------------------------------------------------------- */ diff --git a/server/db.js b/server/db.js index dc1ec57..92b6586 100644 --- a/server/db.js +++ b/server/db.js @@ -355,6 +355,13 @@ const MIGRATIONS = [ ('Sonstiges', 'shopping-basket', 8); `, }, + { + version: 6, + description: 'Rezept-URL für Mahlzeiten', + up: ` + ALTER TABLE meals ADD COLUMN recipe_url TEXT; + `, + }, ]; /** diff --git a/server/routes/meals.js b/server/routes/meals.js index 01ee1e3..612c628 100644 --- a/server/routes/meals.js +++ b/server/routes/meals.js @@ -151,19 +151,20 @@ router.get('/', (req, res) => { router.post('/', (req, res) => { try { const { ingredients = [] } = req.body; - const vDate = date(req.body.date, 'Datum', true); - const vType = oneOf(req.body.meal_type, VALID_MEAL_TYPES, 'Mahlzeit-Typ'); - const vTitle = str(req.body.title, 'Titel', { max: MAX_TITLE }); - const vNotes = str(req.body.notes, 'Notizen', { max: MAX_TEXT, required: false }); - const errors = collectErrors([vDate, vType, vTitle, vNotes]); + const vDate = date(req.body.date, 'Datum', true); + const vType = oneOf(req.body.meal_type, VALID_MEAL_TYPES, 'Mahlzeit-Typ'); + const vTitle = str(req.body.title, 'Titel', { max: MAX_TITLE }); + const vNotes = str(req.body.notes, 'Notizen', { max: MAX_TEXT, required: false }); + const vRecipeUrl = str(req.body.recipe_url, 'Rezept-URL', { max: MAX_TEXT, required: false }); + const errors = collectErrors([vDate, vType, vTitle, vNotes, vRecipeUrl]); if (!req.body.meal_type) errors.push('Mahlzeit-Typ ist erforderlich.'); if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 }); const meal = db.transaction(() => { const result = db.get().prepare(` - INSERT INTO meals (date, meal_type, title, notes, created_by) - VALUES (?, ?, ?, ?, ?) - `).run(vDate.value, vType.value, vTitle.value, vNotes.value, req.session.userId); + INSERT INTO meals (date, meal_type, title, notes, recipe_url, created_by) + VALUES (?, ?, ?, ?, ?, ?) + `).run(vDate.value, vType.value, vTitle.value, vNotes.value, vRecipeUrl.value, req.session.userId); const mealId = result.lastInsertRowid; @@ -210,25 +211,28 @@ router.put('/:id', (req, res) => { if (!meal) return res.status(404).json({ error: 'Mahlzeit nicht gefunden', code: 404 }); const checks = []; - if (req.body.date !== undefined) checks.push(date(req.body.date, 'Datum')); - if (req.body.meal_type !== undefined) checks.push(oneOf(req.body.meal_type, VALID_MEAL_TYPES, 'Mahlzeit-Typ')); - if (req.body.title !== undefined) checks.push(str(req.body.title, 'Titel', { max: MAX_TITLE, required: false })); - if (req.body.notes !== undefined) checks.push(str(req.body.notes, 'Notizen', { max: MAX_TEXT, required: false })); + if (req.body.date !== undefined) checks.push(date(req.body.date, 'Datum')); + if (req.body.meal_type !== undefined) checks.push(oneOf(req.body.meal_type, VALID_MEAL_TYPES, 'Mahlzeit-Typ')); + if (req.body.title !== undefined) checks.push(str(req.body.title, 'Titel', { max: MAX_TITLE, required: false })); + if (req.body.notes !== undefined) checks.push(str(req.body.notes, 'Notizen', { max: MAX_TEXT, required: false })); + if (req.body.recipe_url !== undefined) checks.push(str(req.body.recipe_url, 'Rezept-URL', { max: MAX_TEXT, required: false })); const errors = collectErrors(checks); if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 }); db.get().prepare(` UPDATE meals - SET date = COALESCE(?, date), - meal_type = COALESCE(?, meal_type), - title = COALESCE(?, title), - notes = ? + SET date = COALESCE(?, date), + meal_type = COALESCE(?, meal_type), + title = COALESCE(?, title), + notes = ?, + recipe_url = ? WHERE id = ? `).run( req.body.date ?? null, req.body.meal_type ?? null, req.body.title?.trim() ?? null, - req.body.notes !== undefined ? (req.body.notes || null) : meal.notes, + req.body.notes !== undefined ? (req.body.notes || null) : meal.notes, + req.body.recipe_url !== undefined ? (req.body.recipe_url || null) : meal.recipe_url, id );