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:
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
@@ -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));
|
||||
|
||||
@@ -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)
|
||||
* -------------------------------------------------------- */
|
||||
|
||||
@@ -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;
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user