feat(meals): add optional recipe link to meal cards (#18)

- New optional recipe_url field in the meal modal (below Notes)
- Link icon appears on meal cards when a URL is set, opens in new tab
- DB migration v6: ALTER TABLE meals ADD COLUMN recipe_url TEXT
- API: recipe_url supported in POST /meals and PUT /meals/:id
- i18n: new keys recipeUrlLabel, recipeUrlPlaceholder, openRecipe (de, en, sv, it)
This commit is contained in:
Ulas
2026-04-05 18:03:05 +02:00
parent 2dc8984c3e
commit 3799a7f952
9 changed files with 82 additions and 24 deletions
+6
View File
@@ -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
+4 -1
View File
@@ -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": {
+4 -1
View File
@@ -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": {
+4 -1
View File
@@ -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": {
+4 -1
View File
@@ -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": {
+24 -3
View File
@@ -219,6 +219,13 @@ function renderSlot(date, type, mealsForDay) {
<span class="meal-card__ingredients-count">${ingLabel}${esc(ingDoneLabel)}</span>
</div>` : ''}
<div class="meal-card__actions">
${meal.recipe_url ? `<a class="meal-card__action-btn meal-card__action-btn--recipe"
data-action="open-recipe"
href="${esc(meal.recipe_url)}"
target="_blank"
rel="noopener noreferrer"
aria-label="${t('meals.openRecipe')}"
><i data-lucide="link" style="width:14px;height:14px;" aria-hidden="true"></i></a>` : ''}
${canTransfer ? `<button class="meal-card__action-btn meal-card__action-btn--shopping"
data-action="transfer-meal"
data-meal-id="${meal.id}"
@@ -270,6 +277,12 @@ function wireGrid(grid) {
return;
}
if (action === 'open-recipe') {
// Link öffnet sich nativ - nur Bubbling stoppen damit kein Edit-Modal aufgeht
e.stopPropagation();
return;
}
if (action === 'edit-meal') {
const mealId = parseInt(btn.dataset.mealId, 10);
const meal = state.meals.find((m) => m.id === mealId);
@@ -310,7 +323,7 @@ function wireDragDrop(grid) {
grid.addEventListener('pointerdown', (e) => {
const card = e.target.closest('.meal-card');
if (!card) return;
if (e.target.closest('[data-action="delete-meal"], [data-action="transfer-meal"]')) return;
if (e.target.closest('[data-action="delete-meal"], [data-action="transfer-meal"], [data-action="open-recipe"]')) return;
const slot = card.closest('.meal-slot');
if (!slot) return;
@@ -573,6 +586,13 @@ function buildModalContent({ mode, date, mealType, meal }) {
placeholder="${t('meals.notesPlaceholder')}">${esc(isEdit && meal.notes ? meal.notes : '')}</textarea>
</div>
<div class="form-group">
<label class="form-label" for="modal-recipe-url">${t('meals.recipeUrlLabel')}</label>
<input type="url" class="form-input" id="modal-recipe-url"
placeholder="${t('meals.recipeUrlPlaceholder')}"
value="${esc(isEdit && meal.recipe_url ? meal.recipe_url : '')}">
</div>
<div class="form-group">
<label class="form-label">${t('meals.ingredientsLabel')}</label>
<div class="ingredient-list" id="ingredient-list">${ingRows}</div>
@@ -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));
+8
View File
@@ -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)
* -------------------------------------------------------- */
+7
View File
@@ -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;
`,
},
];
/**
+9 -5
View File
@@ -155,15 +155,16 @@ router.post('/', (req, res) => {
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 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;
@@ -214,6 +215,7 @@ router.put('/:id', (req, res) => {
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 });
@@ -222,13 +224,15 @@ router.put('/:id', (req, res) => {
SET date = COALESCE(?, date),
meal_type = COALESCE(?, meal_type),
title = COALESCE(?, title),
notes = ?
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.recipe_url !== undefined ? (req.body.recipe_url || null) : meal.recipe_url,
id
);