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]
|
## [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
|
## [0.12.0] - 2026-04-05
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -234,7 +234,10 @@
|
|||||||
"ingredientCount": "{{count}} Zutat",
|
"ingredientCount": "{{count}} Zutat",
|
||||||
"ingredientCountPlural": "{{count}} Zutaten",
|
"ingredientCountPlural": "{{count}} Zutaten",
|
||||||
"titleRequired": "Titel ist erforderlich",
|
"titleRequired": "Titel ist erforderlich",
|
||||||
"loadingIndicator": "Lade…"
|
"loadingIndicator": "Lade…",
|
||||||
|
"recipeUrlLabel": "Rezept-Link (optional)",
|
||||||
|
"recipeUrlPlaceholder": "https://…",
|
||||||
|
"openRecipe": "Rezept öffnen"
|
||||||
},
|
},
|
||||||
|
|
||||||
"calendar": {
|
"calendar": {
|
||||||
|
|||||||
@@ -234,7 +234,10 @@
|
|||||||
"ingredientCount": "{{count}} ingredient",
|
"ingredientCount": "{{count}} ingredient",
|
||||||
"ingredientCountPlural": "{{count}} ingredients",
|
"ingredientCountPlural": "{{count}} ingredients",
|
||||||
"titleRequired": "Title is required",
|
"titleRequired": "Title is required",
|
||||||
"loadingIndicator": "Loading…"
|
"loadingIndicator": "Loading…",
|
||||||
|
"recipeUrlLabel": "Recipe link (optional)",
|
||||||
|
"recipeUrlPlaceholder": "https://…",
|
||||||
|
"openRecipe": "Open recipe"
|
||||||
},
|
},
|
||||||
|
|
||||||
"calendar": {
|
"calendar": {
|
||||||
|
|||||||
@@ -234,7 +234,10 @@
|
|||||||
"ingredientCount": "{{count}} ingrediente",
|
"ingredientCount": "{{count}} ingrediente",
|
||||||
"ingredientCountPlural": "{{count}} ingredienti",
|
"ingredientCountPlural": "{{count}} ingredienti",
|
||||||
"titleRequired": "Il titolo è obbligatorio",
|
"titleRequired": "Il titolo è obbligatorio",
|
||||||
"loadingIndicator": "Caricamento…"
|
"loadingIndicator": "Caricamento…",
|
||||||
|
"recipeUrlLabel": "Link ricetta (opzionale)",
|
||||||
|
"recipeUrlPlaceholder": "https://…",
|
||||||
|
"openRecipe": "Apri ricetta"
|
||||||
},
|
},
|
||||||
|
|
||||||
"calendar": {
|
"calendar": {
|
||||||
|
|||||||
@@ -234,7 +234,10 @@
|
|||||||
"ingredientCount": "{{count}} ingrediens",
|
"ingredientCount": "{{count}} ingrediens",
|
||||||
"ingredientCountPlural": "{{count}} ingredienser",
|
"ingredientCountPlural": "{{count}} ingredienser",
|
||||||
"titleRequired": "Titel krävs",
|
"titleRequired": "Titel krävs",
|
||||||
"loadingIndicator": "Laddar…"
|
"loadingIndicator": "Laddar…",
|
||||||
|
"recipeUrlLabel": "Receptlänk (valfri)",
|
||||||
|
"recipeUrlPlaceholder": "https://…",
|
||||||
|
"openRecipe": "Öppna recept"
|
||||||
},
|
},
|
||||||
|
|
||||||
"calendar": {
|
"calendar": {
|
||||||
|
|||||||
+24
-3
@@ -219,6 +219,13 @@ function renderSlot(date, type, mealsForDay) {
|
|||||||
<span class="meal-card__ingredients-count">${ingLabel}${esc(ingDoneLabel)}</span>
|
<span class="meal-card__ingredients-count">${ingLabel}${esc(ingDoneLabel)}</span>
|
||||||
</div>` : ''}
|
</div>` : ''}
|
||||||
<div class="meal-card__actions">
|
<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"
|
${canTransfer ? `<button class="meal-card__action-btn meal-card__action-btn--shopping"
|
||||||
data-action="transfer-meal"
|
data-action="transfer-meal"
|
||||||
data-meal-id="${meal.id}"
|
data-meal-id="${meal.id}"
|
||||||
@@ -270,6 +277,12 @@ function wireGrid(grid) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (action === 'open-recipe') {
|
||||||
|
// Link öffnet sich nativ - nur Bubbling stoppen damit kein Edit-Modal aufgeht
|
||||||
|
e.stopPropagation();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (action === 'edit-meal') {
|
if (action === 'edit-meal') {
|
||||||
const mealId = parseInt(btn.dataset.mealId, 10);
|
const mealId = parseInt(btn.dataset.mealId, 10);
|
||||||
const meal = state.meals.find((m) => m.id === mealId);
|
const meal = state.meals.find((m) => m.id === mealId);
|
||||||
@@ -310,7 +323,7 @@ function wireDragDrop(grid) {
|
|||||||
grid.addEventListener('pointerdown', (e) => {
|
grid.addEventListener('pointerdown', (e) => {
|
||||||
const card = e.target.closest('.meal-card');
|
const card = e.target.closest('.meal-card');
|
||||||
if (!card) return;
|
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');
|
const slot = card.closest('.meal-slot');
|
||||||
if (!slot) return;
|
if (!slot) return;
|
||||||
@@ -573,6 +586,13 @@ function buildModalContent({ mode, date, mealType, meal }) {
|
|||||||
placeholder="${t('meals.notesPlaceholder')}">${esc(isEdit && meal.notes ? meal.notes : '')}</textarea>
|
placeholder="${t('meals.notesPlaceholder')}">${esc(isEdit && meal.notes ? meal.notes : '')}</textarea>
|
||||||
</div>
|
</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">
|
<div class="form-group">
|
||||||
<label class="form-label">${t('meals.ingredientsLabel')}</label>
|
<label class="form-label">${t('meals.ingredientsLabel')}</label>
|
||||||
<div class="ingredient-list" id="ingredient-list">${ingRows}</div>
|
<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 meal_type = overlay.querySelector('#modal-type').value;
|
||||||
const title = overlay.querySelector('#modal-title').value.trim();
|
const title = overlay.querySelector('#modal-title').value.trim();
|
||||||
const notes = overlay.querySelector('#modal-notes').value.trim() || null;
|
const notes = overlay.querySelector('#modal-notes').value.trim() || null;
|
||||||
|
const recipe_url = overlay.querySelector('#modal-recipe-url').value.trim() || null;
|
||||||
|
|
||||||
if (!title) {
|
if (!title) {
|
||||||
window.oikos?.showToast(t('meals.titleRequired'), 'error');
|
window.oikos?.showToast(t('meals.titleRequired'), 'error');
|
||||||
@@ -643,11 +664,11 @@ async function saveModal(overlay) {
|
|||||||
const { mode, meal } = state.modal;
|
const { mode, meal } = state.modal;
|
||||||
|
|
||||||
if (mode === 'create') {
|
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);
|
state.meals.push(res.data);
|
||||||
} else {
|
} else {
|
||||||
// Update meal meta
|
// 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
|
// Sync ingredients
|
||||||
const existingIds = new Set((meal.ingredients ?? []).map((i) => i.id));
|
const existingIds = new Set((meal.ingredients ?? []).map((i) => i.id));
|
||||||
|
|||||||
@@ -266,6 +266,14 @@
|
|||||||
color: var(--color-success);
|
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)
|
* Meals-Modal Content-Styles (Overlay/Panel via shared modal.js)
|
||||||
* -------------------------------------------------------- */
|
* -------------------------------------------------------- */
|
||||||
|
|||||||
@@ -355,6 +355,13 @@ const MIGRATIONS = [
|
|||||||
('Sonstiges', 'shopping-basket', 8);
|
('Sonstiges', 'shopping-basket', 8);
|
||||||
`,
|
`,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
version: 6,
|
||||||
|
description: 'Rezept-URL für Mahlzeiten',
|
||||||
|
up: `
|
||||||
|
ALTER TABLE meals ADD COLUMN recipe_url TEXT;
|
||||||
|
`,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+21
-17
@@ -151,19 +151,20 @@ router.get('/', (req, res) => {
|
|||||||
router.post('/', (req, res) => {
|
router.post('/', (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { ingredients = [] } = req.body;
|
const { ingredients = [] } = req.body;
|
||||||
const vDate = date(req.body.date, 'Datum', true);
|
const vDate = date(req.body.date, 'Datum', true);
|
||||||
const vType = oneOf(req.body.meal_type, VALID_MEAL_TYPES, 'Mahlzeit-Typ');
|
const vType = oneOf(req.body.meal_type, VALID_MEAL_TYPES, 'Mahlzeit-Typ');
|
||||||
const vTitle = str(req.body.title, 'Titel', { max: MAX_TITLE });
|
const vTitle = str(req.body.title, 'Titel', { max: MAX_TITLE });
|
||||||
const vNotes = str(req.body.notes, 'Notizen', { max: MAX_TEXT, required: false });
|
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 (!req.body.meal_type) errors.push('Mahlzeit-Typ ist erforderlich.');
|
||||||
if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 });
|
if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 });
|
||||||
|
|
||||||
const meal = db.transaction(() => {
|
const meal = db.transaction(() => {
|
||||||
const result = db.get().prepare(`
|
const result = db.get().prepare(`
|
||||||
INSERT INTO meals (date, meal_type, title, notes, created_by)
|
INSERT INTO meals (date, meal_type, title, notes, recipe_url, created_by)
|
||||||
VALUES (?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
`).run(vDate.value, vType.value, vTitle.value, vNotes.value, req.session.userId);
|
`).run(vDate.value, vType.value, vTitle.value, vNotes.value, vRecipeUrl.value, req.session.userId);
|
||||||
|
|
||||||
const mealId = result.lastInsertRowid;
|
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 });
|
if (!meal) return res.status(404).json({ error: 'Mahlzeit nicht gefunden', code: 404 });
|
||||||
|
|
||||||
const checks = [];
|
const checks = [];
|
||||||
if (req.body.date !== undefined) checks.push(date(req.body.date, 'Datum'));
|
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.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.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.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);
|
const errors = collectErrors(checks);
|
||||||
if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 });
|
if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 });
|
||||||
|
|
||||||
db.get().prepare(`
|
db.get().prepare(`
|
||||||
UPDATE meals
|
UPDATE meals
|
||||||
SET date = COALESCE(?, date),
|
SET date = COALESCE(?, date),
|
||||||
meal_type = COALESCE(?, meal_type),
|
meal_type = COALESCE(?, meal_type),
|
||||||
title = COALESCE(?, title),
|
title = COALESCE(?, title),
|
||||||
notes = ?
|
notes = ?,
|
||||||
|
recipe_url = ?
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`).run(
|
`).run(
|
||||||
req.body.date ?? null,
|
req.body.date ?? null,
|
||||||
req.body.meal_type ?? null,
|
req.body.meal_type ?? null,
|
||||||
req.body.title?.trim() ?? 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
|
id
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user