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
+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)
* -------------------------------------------------------- */