feat: add recipes module with CRUD functionality and integrate with meals
- Implemented new recipes page with UI for managing recipes. - Added REST API routes for recipes including create, read, update, and delete operations. - Introduced database schema for recipes and recipe ingredients. - Updated meals to link with recipes, allowing meals to reference specific recipes. - Enhanced validation for recipe-related fields in meals. - Added styles for the recipes page and components.
This commit is contained in:
committed by
Ulas Kalayci
parent
41467a84b6
commit
0b54fe255b
+31
-2
@@ -42,7 +42,8 @@
|
|||||||
"settings": "الإعدادات",
|
"settings": "الإعدادات",
|
||||||
"main": "القائمة الرئيسية",
|
"main": "القائمة الرئيسية",
|
||||||
"navigation": "التنقل",
|
"navigation": "التنقل",
|
||||||
"quickActions": "الإجراءات السريعة"
|
"quickActions": "الإجراءات السريعة",
|
||||||
|
"recipes": "Recipes"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "لوحة التحكم",
|
"title": "لوحة التحكم",
|
||||||
@@ -242,7 +243,11 @@
|
|||||||
"loadingIndicator": "جارٍ التحميل…",
|
"loadingIndicator": "جارٍ التحميل…",
|
||||||
"recipeUrlLabel": "رابط الوصفة (اختياري)",
|
"recipeUrlLabel": "رابط الوصفة (اختياري)",
|
||||||
"recipeUrlPlaceholder": "https://…",
|
"recipeUrlPlaceholder": "https://…",
|
||||||
"openRecipe": "فتح الوصفة"
|
"openRecipe": "فتح الوصفة",
|
||||||
|
"savedRecipeLabel": "Saved recipe",
|
||||||
|
"savedRecipePlaceholder": "Select recipe",
|
||||||
|
"saveAsRecipe": "Save as recipe",
|
||||||
|
"recipeScaleLabel": "Scale ingredients"
|
||||||
},
|
},
|
||||||
"calendar": {
|
"calendar": {
|
||||||
"title": "التقويم",
|
"title": "التقويم",
|
||||||
@@ -604,5 +609,29 @@
|
|||||||
"unitWeeks": "أسابيع",
|
"unitWeeks": "أسابيع",
|
||||||
"unitMonth": "شهر",
|
"unitMonth": "شهر",
|
||||||
"unitMonths": "أشهر"
|
"unitMonths": "أشهر"
|
||||||
|
},
|
||||||
|
"recipes": {
|
||||||
|
"title": "Recipes",
|
||||||
|
"addRecipe": "Add recipe",
|
||||||
|
"editRecipe": "Edit recipe",
|
||||||
|
"emptyTitle": "No recipes yet",
|
||||||
|
"emptyDescription": "Save your favorite recipes and reuse them in meal planning.",
|
||||||
|
"titleLabel": "Title *",
|
||||||
|
"titlePlaceholder": "e.g. Pasta Carbonara",
|
||||||
|
"notesLabel": "Notes",
|
||||||
|
"notesPlaceholder": "Optional...",
|
||||||
|
"urlLabel": "Recipe link",
|
||||||
|
"urlPlaceholder": "https://...",
|
||||||
|
"ingredientsLabel": "Ingredients",
|
||||||
|
"addToMeals": "Add to meal plan",
|
||||||
|
"openLink": "Open recipe link",
|
||||||
|
"deleteConfirm": "Delete recipe \"{{title}}\"?",
|
||||||
|
"created": "Recipe saved.",
|
||||||
|
"updated": "Recipe updated.",
|
||||||
|
"deleted": "Recipe deleted.",
|
||||||
|
"titleRequired": "Title is required",
|
||||||
|
"duplicate": "Duplicate",
|
||||||
|
"duplicated": "Recipe duplicated.",
|
||||||
|
"copySuffix": "copy"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+31
-2
@@ -43,7 +43,8 @@
|
|||||||
"main": "Hauptnavigation",
|
"main": "Hauptnavigation",
|
||||||
"navigation": "Navigation",
|
"navigation": "Navigation",
|
||||||
"quickActions": "Schnellaktionen",
|
"quickActions": "Schnellaktionen",
|
||||||
"more": "Mehr"
|
"more": "Mehr",
|
||||||
|
"recipes": "Recipes"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"title": "Suche",
|
"title": "Suche",
|
||||||
@@ -256,7 +257,11 @@
|
|||||||
"loadingIndicator": "Lade…",
|
"loadingIndicator": "Lade…",
|
||||||
"recipeUrlLabel": "Rezept-Link (optional)",
|
"recipeUrlLabel": "Rezept-Link (optional)",
|
||||||
"recipeUrlPlaceholder": "https://…",
|
"recipeUrlPlaceholder": "https://…",
|
||||||
"openRecipe": "Rezept öffnen"
|
"openRecipe": "Rezept öffnen",
|
||||||
|
"savedRecipeLabel": "Saved recipe",
|
||||||
|
"savedRecipePlaceholder": "Select recipe",
|
||||||
|
"saveAsRecipe": "Save as recipe",
|
||||||
|
"recipeScaleLabel": "Scale ingredients"
|
||||||
},
|
},
|
||||||
"calendar": {
|
"calendar": {
|
||||||
"title": "Kalender",
|
"title": "Kalender",
|
||||||
@@ -676,5 +681,29 @@
|
|||||||
"notificationHint": "Erhalte Benachrichtigungen auch wenn die App geöffnet ist.",
|
"notificationHint": "Erhalte Benachrichtigungen auch wenn die App geöffnet ist.",
|
||||||
"pendingBadgeTitle": "{{count}} fällige Erinnerung",
|
"pendingBadgeTitle": "{{count}} fällige Erinnerung",
|
||||||
"pendingBadgeTitlePlural": "{{count}} fällige Erinnerungen"
|
"pendingBadgeTitlePlural": "{{count}} fällige Erinnerungen"
|
||||||
|
},
|
||||||
|
"recipes": {
|
||||||
|
"title": "Recipes",
|
||||||
|
"addRecipe": "Add recipe",
|
||||||
|
"editRecipe": "Edit recipe",
|
||||||
|
"emptyTitle": "No recipes yet",
|
||||||
|
"emptyDescription": "Save your favorite recipes and reuse them in meal planning.",
|
||||||
|
"titleLabel": "Title *",
|
||||||
|
"titlePlaceholder": "e.g. Pasta Carbonara",
|
||||||
|
"notesLabel": "Notes",
|
||||||
|
"notesPlaceholder": "Optional...",
|
||||||
|
"urlLabel": "Recipe link",
|
||||||
|
"urlPlaceholder": "https://...",
|
||||||
|
"ingredientsLabel": "Ingredients",
|
||||||
|
"addToMeals": "Add to meal plan",
|
||||||
|
"openLink": "Open recipe link",
|
||||||
|
"deleteConfirm": "Delete recipe \"{{title}}\"?",
|
||||||
|
"created": "Recipe saved.",
|
||||||
|
"updated": "Recipe updated.",
|
||||||
|
"deleted": "Recipe deleted.",
|
||||||
|
"titleRequired": "Title is required",
|
||||||
|
"duplicate": "Duplicate",
|
||||||
|
"duplicated": "Recipe duplicated.",
|
||||||
|
"copySuffix": "copy"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+31
-2
@@ -42,7 +42,8 @@
|
|||||||
"settings": "Ρυθμίσεις",
|
"settings": "Ρυθμίσεις",
|
||||||
"main": "Κύρια πλοήγηση",
|
"main": "Κύρια πλοήγηση",
|
||||||
"navigation": "Πλοήγηση",
|
"navigation": "Πλοήγηση",
|
||||||
"quickActions": "Γρήγορες ενέργειες"
|
"quickActions": "Γρήγορες ενέργειες",
|
||||||
|
"recipes": "Recipes"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Επισκόπηση",
|
"title": "Επισκόπηση",
|
||||||
@@ -242,7 +243,11 @@
|
|||||||
"loadingIndicator": "Φόρτωση…",
|
"loadingIndicator": "Φόρτωση…",
|
||||||
"recipeUrlLabel": "Σύνδεσμος συνταγής (προαιρετικό)",
|
"recipeUrlLabel": "Σύνδεσμος συνταγής (προαιρετικό)",
|
||||||
"recipeUrlPlaceholder": "https://…",
|
"recipeUrlPlaceholder": "https://…",
|
||||||
"openRecipe": "Άνοιγμα συνταγής"
|
"openRecipe": "Άνοιγμα συνταγής",
|
||||||
|
"savedRecipeLabel": "Saved recipe",
|
||||||
|
"savedRecipePlaceholder": "Select recipe",
|
||||||
|
"saveAsRecipe": "Save as recipe",
|
||||||
|
"recipeScaleLabel": "Scale ingredients"
|
||||||
},
|
},
|
||||||
"calendar": {
|
"calendar": {
|
||||||
"title": "Ημερολόγιο",
|
"title": "Ημερολόγιο",
|
||||||
@@ -604,5 +609,29 @@
|
|||||||
"unitWeeks": "εβδομάδες",
|
"unitWeeks": "εβδομάδες",
|
||||||
"unitMonth": "μήνα",
|
"unitMonth": "μήνα",
|
||||||
"unitMonths": "μήνες"
|
"unitMonths": "μήνες"
|
||||||
|
},
|
||||||
|
"recipes": {
|
||||||
|
"title": "Recipes",
|
||||||
|
"addRecipe": "Add recipe",
|
||||||
|
"editRecipe": "Edit recipe",
|
||||||
|
"emptyTitle": "No recipes yet",
|
||||||
|
"emptyDescription": "Save your favorite recipes and reuse them in meal planning.",
|
||||||
|
"titleLabel": "Title *",
|
||||||
|
"titlePlaceholder": "e.g. Pasta Carbonara",
|
||||||
|
"notesLabel": "Notes",
|
||||||
|
"notesPlaceholder": "Optional...",
|
||||||
|
"urlLabel": "Recipe link",
|
||||||
|
"urlPlaceholder": "https://...",
|
||||||
|
"ingredientsLabel": "Ingredients",
|
||||||
|
"addToMeals": "Add to meal plan",
|
||||||
|
"openLink": "Open recipe link",
|
||||||
|
"deleteConfirm": "Delete recipe \"{{title}}\"?",
|
||||||
|
"created": "Recipe saved.",
|
||||||
|
"updated": "Recipe updated.",
|
||||||
|
"deleted": "Recipe deleted.",
|
||||||
|
"titleRequired": "Title is required",
|
||||||
|
"duplicate": "Duplicate",
|
||||||
|
"duplicated": "Recipe duplicated.",
|
||||||
|
"copySuffix": "copy"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+31
-2
@@ -42,7 +42,8 @@
|
|||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"main": "Main navigation",
|
"main": "Main navigation",
|
||||||
"navigation": "Navigation",
|
"navigation": "Navigation",
|
||||||
"quickActions": "Quick actions"
|
"quickActions": "Quick actions",
|
||||||
|
"recipes": "Recipes"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Overview",
|
"title": "Overview",
|
||||||
@@ -242,7 +243,11 @@
|
|||||||
"loadingIndicator": "Loading…",
|
"loadingIndicator": "Loading…",
|
||||||
"recipeUrlLabel": "Recipe link (optional)",
|
"recipeUrlLabel": "Recipe link (optional)",
|
||||||
"recipeUrlPlaceholder": "https://…",
|
"recipeUrlPlaceholder": "https://…",
|
||||||
"openRecipe": "Open recipe"
|
"openRecipe": "Open recipe",
|
||||||
|
"savedRecipeLabel": "Saved recipe",
|
||||||
|
"savedRecipePlaceholder": "Select recipe",
|
||||||
|
"saveAsRecipe": "Save as recipe",
|
||||||
|
"recipeScaleLabel": "Scale ingredients"
|
||||||
},
|
},
|
||||||
"calendar": {
|
"calendar": {
|
||||||
"title": "Calendar",
|
"title": "Calendar",
|
||||||
@@ -625,5 +630,29 @@
|
|||||||
"notificationHint": "Receive notifications while the app is open.",
|
"notificationHint": "Receive notifications while the app is open.",
|
||||||
"pendingBadgeTitle": "{{count}} reminder due",
|
"pendingBadgeTitle": "{{count}} reminder due",
|
||||||
"pendingBadgeTitlePlural": "{{count}} reminders due"
|
"pendingBadgeTitlePlural": "{{count}} reminders due"
|
||||||
|
},
|
||||||
|
"recipes": {
|
||||||
|
"title": "Recipes",
|
||||||
|
"addRecipe": "Add recipe",
|
||||||
|
"editRecipe": "Edit recipe",
|
||||||
|
"emptyTitle": "No recipes yet",
|
||||||
|
"emptyDescription": "Save your favorite recipes and reuse them in meal planning.",
|
||||||
|
"titleLabel": "Title *",
|
||||||
|
"titlePlaceholder": "e.g. Pasta Carbonara",
|
||||||
|
"notesLabel": "Notes",
|
||||||
|
"notesPlaceholder": "Optional...",
|
||||||
|
"urlLabel": "Recipe link",
|
||||||
|
"urlPlaceholder": "https://...",
|
||||||
|
"ingredientsLabel": "Ingredients",
|
||||||
|
"addToMeals": "Add to meal plan",
|
||||||
|
"openLink": "Open recipe link",
|
||||||
|
"deleteConfirm": "Delete recipe \"{{title}}\"?",
|
||||||
|
"created": "Recipe saved.",
|
||||||
|
"updated": "Recipe updated.",
|
||||||
|
"deleted": "Recipe deleted.",
|
||||||
|
"titleRequired": "Title is required",
|
||||||
|
"duplicate": "Duplicate",
|
||||||
|
"duplicated": "Recipe duplicated.",
|
||||||
|
"copySuffix": "copy"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+31
-2
@@ -42,7 +42,8 @@
|
|||||||
"settings": "Ajustes",
|
"settings": "Ajustes",
|
||||||
"main": "Navegación principal",
|
"main": "Navegación principal",
|
||||||
"navigation": "Navegación",
|
"navigation": "Navegación",
|
||||||
"quickActions": "Acciones rápidas"
|
"quickActions": "Acciones rápidas",
|
||||||
|
"recipes": "Recipes"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Inicio",
|
"title": "Inicio",
|
||||||
@@ -242,7 +243,11 @@
|
|||||||
"loadingIndicator": "Cargando…",
|
"loadingIndicator": "Cargando…",
|
||||||
"recipeUrlLabel": "Enlace a la receta (opcional)",
|
"recipeUrlLabel": "Enlace a la receta (opcional)",
|
||||||
"recipeUrlPlaceholder": "https://…",
|
"recipeUrlPlaceholder": "https://…",
|
||||||
"openRecipe": "Abrir receta"
|
"openRecipe": "Abrir receta",
|
||||||
|
"savedRecipeLabel": "Saved recipe",
|
||||||
|
"savedRecipePlaceholder": "Select recipe",
|
||||||
|
"saveAsRecipe": "Save as recipe",
|
||||||
|
"recipeScaleLabel": "Scale ingredients"
|
||||||
},
|
},
|
||||||
"calendar": {
|
"calendar": {
|
||||||
"title": "Calendario",
|
"title": "Calendario",
|
||||||
@@ -604,5 +609,29 @@
|
|||||||
"unitWeeks": "semanas",
|
"unitWeeks": "semanas",
|
||||||
"unitMonth": "mes",
|
"unitMonth": "mes",
|
||||||
"unitMonths": "meses"
|
"unitMonths": "meses"
|
||||||
|
},
|
||||||
|
"recipes": {
|
||||||
|
"title": "Recipes",
|
||||||
|
"addRecipe": "Add recipe",
|
||||||
|
"editRecipe": "Edit recipe",
|
||||||
|
"emptyTitle": "No recipes yet",
|
||||||
|
"emptyDescription": "Save your favorite recipes and reuse them in meal planning.",
|
||||||
|
"titleLabel": "Title *",
|
||||||
|
"titlePlaceholder": "e.g. Pasta Carbonara",
|
||||||
|
"notesLabel": "Notes",
|
||||||
|
"notesPlaceholder": "Optional...",
|
||||||
|
"urlLabel": "Recipe link",
|
||||||
|
"urlPlaceholder": "https://...",
|
||||||
|
"ingredientsLabel": "Ingredients",
|
||||||
|
"addToMeals": "Add to meal plan",
|
||||||
|
"openLink": "Open recipe link",
|
||||||
|
"deleteConfirm": "Delete recipe \"{{title}}\"?",
|
||||||
|
"created": "Recipe saved.",
|
||||||
|
"updated": "Recipe updated.",
|
||||||
|
"deleted": "Recipe deleted.",
|
||||||
|
"titleRequired": "Title is required",
|
||||||
|
"duplicate": "Duplicate",
|
||||||
|
"duplicated": "Recipe duplicated.",
|
||||||
|
"copySuffix": "copy"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+31
-2
@@ -42,7 +42,8 @@
|
|||||||
"settings": "Paramètres",
|
"settings": "Paramètres",
|
||||||
"main": "Navigation principale",
|
"main": "Navigation principale",
|
||||||
"navigation": "Navigation",
|
"navigation": "Navigation",
|
||||||
"quickActions": "Actions rapides"
|
"quickActions": "Actions rapides",
|
||||||
|
"recipes": "Recipes"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Accueil",
|
"title": "Accueil",
|
||||||
@@ -242,7 +243,11 @@
|
|||||||
"loadingIndicator": "Chargement…",
|
"loadingIndicator": "Chargement…",
|
||||||
"recipeUrlLabel": "Lien recette (optionnel)",
|
"recipeUrlLabel": "Lien recette (optionnel)",
|
||||||
"recipeUrlPlaceholder": "https://…",
|
"recipeUrlPlaceholder": "https://…",
|
||||||
"openRecipe": "Ouvrir la recette"
|
"openRecipe": "Ouvrir la recette",
|
||||||
|
"savedRecipeLabel": "Saved recipe",
|
||||||
|
"savedRecipePlaceholder": "Select recipe",
|
||||||
|
"saveAsRecipe": "Save as recipe",
|
||||||
|
"recipeScaleLabel": "Scale ingredients"
|
||||||
},
|
},
|
||||||
"calendar": {
|
"calendar": {
|
||||||
"title": "Calendrier",
|
"title": "Calendrier",
|
||||||
@@ -604,5 +609,29 @@
|
|||||||
"unitWeeks": "semaines",
|
"unitWeeks": "semaines",
|
||||||
"unitMonth": "mois",
|
"unitMonth": "mois",
|
||||||
"unitMonths": "mois"
|
"unitMonths": "mois"
|
||||||
|
},
|
||||||
|
"recipes": {
|
||||||
|
"title": "Recipes",
|
||||||
|
"addRecipe": "Add recipe",
|
||||||
|
"editRecipe": "Edit recipe",
|
||||||
|
"emptyTitle": "No recipes yet",
|
||||||
|
"emptyDescription": "Save your favorite recipes and reuse them in meal planning.",
|
||||||
|
"titleLabel": "Title *",
|
||||||
|
"titlePlaceholder": "e.g. Pasta Carbonara",
|
||||||
|
"notesLabel": "Notes",
|
||||||
|
"notesPlaceholder": "Optional...",
|
||||||
|
"urlLabel": "Recipe link",
|
||||||
|
"urlPlaceholder": "https://...",
|
||||||
|
"ingredientsLabel": "Ingredients",
|
||||||
|
"addToMeals": "Add to meal plan",
|
||||||
|
"openLink": "Open recipe link",
|
||||||
|
"deleteConfirm": "Delete recipe \"{{title}}\"?",
|
||||||
|
"created": "Recipe saved.",
|
||||||
|
"updated": "Recipe updated.",
|
||||||
|
"deleted": "Recipe deleted.",
|
||||||
|
"titleRequired": "Title is required",
|
||||||
|
"duplicate": "Duplicate",
|
||||||
|
"duplicated": "Recipe duplicated.",
|
||||||
|
"copySuffix": "copy"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+31
-2
@@ -42,7 +42,8 @@
|
|||||||
"settings": "सेटिंग्स",
|
"settings": "सेटिंग्स",
|
||||||
"main": "मुख्य नेविगेशन",
|
"main": "मुख्य नेविगेशन",
|
||||||
"navigation": "नेविगेशन",
|
"navigation": "नेविगेशन",
|
||||||
"quickActions": "त्वरित क्रियाएं"
|
"quickActions": "त्वरित क्रियाएं",
|
||||||
|
"recipes": "Recipes"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "डैशबोर्ड",
|
"title": "डैशबोर्ड",
|
||||||
@@ -242,7 +243,11 @@
|
|||||||
"loadingIndicator": "लोड हो रहा है…",
|
"loadingIndicator": "लोड हो रहा है…",
|
||||||
"recipeUrlLabel": "रेसिपी लिंक (वैकल्पिक)",
|
"recipeUrlLabel": "रेसिपी लिंक (वैकल्पिक)",
|
||||||
"recipeUrlPlaceholder": "https://…",
|
"recipeUrlPlaceholder": "https://…",
|
||||||
"openRecipe": "रेसिपी खोलें"
|
"openRecipe": "रेसिपी खोलें",
|
||||||
|
"savedRecipeLabel": "Saved recipe",
|
||||||
|
"savedRecipePlaceholder": "Select recipe",
|
||||||
|
"saveAsRecipe": "Save as recipe",
|
||||||
|
"recipeScaleLabel": "Scale ingredients"
|
||||||
},
|
},
|
||||||
"calendar": {
|
"calendar": {
|
||||||
"title": "कैलेंडर",
|
"title": "कैलेंडर",
|
||||||
@@ -604,5 +609,29 @@
|
|||||||
"unitWeeks": "सप्ताह",
|
"unitWeeks": "सप्ताह",
|
||||||
"unitMonth": "माह",
|
"unitMonth": "माह",
|
||||||
"unitMonths": "माह"
|
"unitMonths": "माह"
|
||||||
|
},
|
||||||
|
"recipes": {
|
||||||
|
"title": "Recipes",
|
||||||
|
"addRecipe": "Add recipe",
|
||||||
|
"editRecipe": "Edit recipe",
|
||||||
|
"emptyTitle": "No recipes yet",
|
||||||
|
"emptyDescription": "Save your favorite recipes and reuse them in meal planning.",
|
||||||
|
"titleLabel": "Title *",
|
||||||
|
"titlePlaceholder": "e.g. Pasta Carbonara",
|
||||||
|
"notesLabel": "Notes",
|
||||||
|
"notesPlaceholder": "Optional...",
|
||||||
|
"urlLabel": "Recipe link",
|
||||||
|
"urlPlaceholder": "https://...",
|
||||||
|
"ingredientsLabel": "Ingredients",
|
||||||
|
"addToMeals": "Add to meal plan",
|
||||||
|
"openLink": "Open recipe link",
|
||||||
|
"deleteConfirm": "Delete recipe \"{{title}}\"?",
|
||||||
|
"created": "Recipe saved.",
|
||||||
|
"updated": "Recipe updated.",
|
||||||
|
"deleted": "Recipe deleted.",
|
||||||
|
"titleRequired": "Title is required",
|
||||||
|
"duplicate": "Duplicate",
|
||||||
|
"duplicated": "Recipe duplicated.",
|
||||||
|
"copySuffix": "copy"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+31
-2
@@ -42,7 +42,8 @@
|
|||||||
"settings": "Impostazioni",
|
"settings": "Impostazioni",
|
||||||
"main": "Navigazione principale",
|
"main": "Navigazione principale",
|
||||||
"navigation": "Navigazione",
|
"navigation": "Navigazione",
|
||||||
"quickActions": "Azioni rapide"
|
"quickActions": "Azioni rapide",
|
||||||
|
"recipes": "Recipes"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Panoramica",
|
"title": "Panoramica",
|
||||||
@@ -242,7 +243,11 @@
|
|||||||
"loadingIndicator": "Caricamento…",
|
"loadingIndicator": "Caricamento…",
|
||||||
"recipeUrlLabel": "Link ricetta (opzionale)",
|
"recipeUrlLabel": "Link ricetta (opzionale)",
|
||||||
"recipeUrlPlaceholder": "https://…",
|
"recipeUrlPlaceholder": "https://…",
|
||||||
"openRecipe": "Apri ricetta"
|
"openRecipe": "Apri ricetta",
|
||||||
|
"savedRecipeLabel": "Saved recipe",
|
||||||
|
"savedRecipePlaceholder": "Select recipe",
|
||||||
|
"saveAsRecipe": "Save as recipe",
|
||||||
|
"recipeScaleLabel": "Scale ingredients"
|
||||||
},
|
},
|
||||||
"calendar": {
|
"calendar": {
|
||||||
"title": "Calendario",
|
"title": "Calendario",
|
||||||
@@ -604,5 +609,29 @@
|
|||||||
"unitWeeks": "settimane",
|
"unitWeeks": "settimane",
|
||||||
"unitMonth": "mese",
|
"unitMonth": "mese",
|
||||||
"unitMonths": "mesi"
|
"unitMonths": "mesi"
|
||||||
|
},
|
||||||
|
"recipes": {
|
||||||
|
"title": "Recipes",
|
||||||
|
"addRecipe": "Add recipe",
|
||||||
|
"editRecipe": "Edit recipe",
|
||||||
|
"emptyTitle": "No recipes yet",
|
||||||
|
"emptyDescription": "Save your favorite recipes and reuse them in meal planning.",
|
||||||
|
"titleLabel": "Title *",
|
||||||
|
"titlePlaceholder": "e.g. Pasta Carbonara",
|
||||||
|
"notesLabel": "Notes",
|
||||||
|
"notesPlaceholder": "Optional...",
|
||||||
|
"urlLabel": "Recipe link",
|
||||||
|
"urlPlaceholder": "https://...",
|
||||||
|
"ingredientsLabel": "Ingredients",
|
||||||
|
"addToMeals": "Add to meal plan",
|
||||||
|
"openLink": "Open recipe link",
|
||||||
|
"deleteConfirm": "Delete recipe \"{{title}}\"?",
|
||||||
|
"created": "Recipe saved.",
|
||||||
|
"updated": "Recipe updated.",
|
||||||
|
"deleted": "Recipe deleted.",
|
||||||
|
"titleRequired": "Title is required",
|
||||||
|
"duplicate": "Duplicate",
|
||||||
|
"duplicated": "Recipe duplicated.",
|
||||||
|
"copySuffix": "copy"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+31
-2
@@ -42,7 +42,8 @@
|
|||||||
"settings": "設定",
|
"settings": "設定",
|
||||||
"main": "メインナビゲーション",
|
"main": "メインナビゲーション",
|
||||||
"navigation": "ナビゲーション",
|
"navigation": "ナビゲーション",
|
||||||
"quickActions": "クイックアクション"
|
"quickActions": "クイックアクション",
|
||||||
|
"recipes": "Recipes"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "ダッシュボード",
|
"title": "ダッシュボード",
|
||||||
@@ -242,7 +243,11 @@
|
|||||||
"loadingIndicator": "読み込み中…",
|
"loadingIndicator": "読み込み中…",
|
||||||
"recipeUrlLabel": "レシピリンク(任意)",
|
"recipeUrlLabel": "レシピリンク(任意)",
|
||||||
"recipeUrlPlaceholder": "https://…",
|
"recipeUrlPlaceholder": "https://…",
|
||||||
"openRecipe": "レシピを開く"
|
"openRecipe": "レシピを開く",
|
||||||
|
"savedRecipeLabel": "Saved recipe",
|
||||||
|
"savedRecipePlaceholder": "Select recipe",
|
||||||
|
"saveAsRecipe": "Save as recipe",
|
||||||
|
"recipeScaleLabel": "Scale ingredients"
|
||||||
},
|
},
|
||||||
"calendar": {
|
"calendar": {
|
||||||
"title": "カレンダー",
|
"title": "カレンダー",
|
||||||
@@ -604,5 +609,29 @@
|
|||||||
"unitWeeks": "週",
|
"unitWeeks": "週",
|
||||||
"unitMonth": "ヶ月",
|
"unitMonth": "ヶ月",
|
||||||
"unitMonths": "ヶ月"
|
"unitMonths": "ヶ月"
|
||||||
|
},
|
||||||
|
"recipes": {
|
||||||
|
"title": "Recipes",
|
||||||
|
"addRecipe": "Add recipe",
|
||||||
|
"editRecipe": "Edit recipe",
|
||||||
|
"emptyTitle": "No recipes yet",
|
||||||
|
"emptyDescription": "Save your favorite recipes and reuse them in meal planning.",
|
||||||
|
"titleLabel": "Title *",
|
||||||
|
"titlePlaceholder": "e.g. Pasta Carbonara",
|
||||||
|
"notesLabel": "Notes",
|
||||||
|
"notesPlaceholder": "Optional...",
|
||||||
|
"urlLabel": "Recipe link",
|
||||||
|
"urlPlaceholder": "https://...",
|
||||||
|
"ingredientsLabel": "Ingredients",
|
||||||
|
"addToMeals": "Add to meal plan",
|
||||||
|
"openLink": "Open recipe link",
|
||||||
|
"deleteConfirm": "Delete recipe \"{{title}}\"?",
|
||||||
|
"created": "Recipe saved.",
|
||||||
|
"updated": "Recipe updated.",
|
||||||
|
"deleted": "Recipe deleted.",
|
||||||
|
"titleRequired": "Title is required",
|
||||||
|
"duplicate": "Duplicate",
|
||||||
|
"duplicated": "Recipe duplicated.",
|
||||||
|
"copySuffix": "copy"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+31
-2
@@ -42,7 +42,8 @@
|
|||||||
"settings": "Configurações",
|
"settings": "Configurações",
|
||||||
"main": "Navegação principal",
|
"main": "Navegação principal",
|
||||||
"navigation": "Navegação",
|
"navigation": "Navegação",
|
||||||
"quickActions": "Ações rápidas"
|
"quickActions": "Ações rápidas",
|
||||||
|
"recipes": "Recipes"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Painel",
|
"title": "Painel",
|
||||||
@@ -242,7 +243,11 @@
|
|||||||
"loadingIndicator": "Carregando…",
|
"loadingIndicator": "Carregando…",
|
||||||
"recipeUrlLabel": "Link da receita (opcional)",
|
"recipeUrlLabel": "Link da receita (opcional)",
|
||||||
"recipeUrlPlaceholder": "https://…",
|
"recipeUrlPlaceholder": "https://…",
|
||||||
"openRecipe": "Abrir receita"
|
"openRecipe": "Abrir receita",
|
||||||
|
"savedRecipeLabel": "Saved recipe",
|
||||||
|
"savedRecipePlaceholder": "Select recipe",
|
||||||
|
"saveAsRecipe": "Save as recipe",
|
||||||
|
"recipeScaleLabel": "Scale ingredients"
|
||||||
},
|
},
|
||||||
"calendar": {
|
"calendar": {
|
||||||
"title": "Calendário",
|
"title": "Calendário",
|
||||||
@@ -604,5 +609,29 @@
|
|||||||
"unitWeeks": "semanas",
|
"unitWeeks": "semanas",
|
||||||
"unitMonth": "mês",
|
"unitMonth": "mês",
|
||||||
"unitMonths": "meses"
|
"unitMonths": "meses"
|
||||||
|
},
|
||||||
|
"recipes": {
|
||||||
|
"title": "Recipes",
|
||||||
|
"addRecipe": "Add recipe",
|
||||||
|
"editRecipe": "Edit recipe",
|
||||||
|
"emptyTitle": "No recipes yet",
|
||||||
|
"emptyDescription": "Save your favorite recipes and reuse them in meal planning.",
|
||||||
|
"titleLabel": "Title *",
|
||||||
|
"titlePlaceholder": "e.g. Pasta Carbonara",
|
||||||
|
"notesLabel": "Notes",
|
||||||
|
"notesPlaceholder": "Optional...",
|
||||||
|
"urlLabel": "Recipe link",
|
||||||
|
"urlPlaceholder": "https://...",
|
||||||
|
"ingredientsLabel": "Ingredients",
|
||||||
|
"addToMeals": "Add to meal plan",
|
||||||
|
"openLink": "Open recipe link",
|
||||||
|
"deleteConfirm": "Delete recipe \"{{title}}\"?",
|
||||||
|
"created": "Recipe saved.",
|
||||||
|
"updated": "Recipe updated.",
|
||||||
|
"deleted": "Recipe deleted.",
|
||||||
|
"titleRequired": "Title is required",
|
||||||
|
"duplicate": "Duplicate",
|
||||||
|
"duplicated": "Recipe duplicated.",
|
||||||
|
"copySuffix": "copy"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+31
-2
@@ -42,7 +42,8 @@
|
|||||||
"settings": "Настройки",
|
"settings": "Настройки",
|
||||||
"main": "Главная навигация",
|
"main": "Главная навигация",
|
||||||
"navigation": "Навигация",
|
"navigation": "Навигация",
|
||||||
"quickActions": "Быстрые действия"
|
"quickActions": "Быстрые действия",
|
||||||
|
"recipes": "Recipes"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Обзор",
|
"title": "Обзор",
|
||||||
@@ -242,7 +243,11 @@
|
|||||||
"loadingIndicator": "Загрузка…",
|
"loadingIndicator": "Загрузка…",
|
||||||
"recipeUrlLabel": "Ссылка на рецепт (необязательно)",
|
"recipeUrlLabel": "Ссылка на рецепт (необязательно)",
|
||||||
"recipeUrlPlaceholder": "https://…",
|
"recipeUrlPlaceholder": "https://…",
|
||||||
"openRecipe": "Открыть рецепт"
|
"openRecipe": "Открыть рецепт",
|
||||||
|
"savedRecipeLabel": "Saved recipe",
|
||||||
|
"savedRecipePlaceholder": "Select recipe",
|
||||||
|
"saveAsRecipe": "Save as recipe",
|
||||||
|
"recipeScaleLabel": "Scale ingredients"
|
||||||
},
|
},
|
||||||
"calendar": {
|
"calendar": {
|
||||||
"title": "Календарь",
|
"title": "Календарь",
|
||||||
@@ -604,5 +609,29 @@
|
|||||||
"unitWeeks": "недель",
|
"unitWeeks": "недель",
|
||||||
"unitMonth": "месяц",
|
"unitMonth": "месяц",
|
||||||
"unitMonths": "месяцев"
|
"unitMonths": "месяцев"
|
||||||
|
},
|
||||||
|
"recipes": {
|
||||||
|
"title": "Recipes",
|
||||||
|
"addRecipe": "Add recipe",
|
||||||
|
"editRecipe": "Edit recipe",
|
||||||
|
"emptyTitle": "No recipes yet",
|
||||||
|
"emptyDescription": "Save your favorite recipes and reuse them in meal planning.",
|
||||||
|
"titleLabel": "Title *",
|
||||||
|
"titlePlaceholder": "e.g. Pasta Carbonara",
|
||||||
|
"notesLabel": "Notes",
|
||||||
|
"notesPlaceholder": "Optional...",
|
||||||
|
"urlLabel": "Recipe link",
|
||||||
|
"urlPlaceholder": "https://...",
|
||||||
|
"ingredientsLabel": "Ingredients",
|
||||||
|
"addToMeals": "Add to meal plan",
|
||||||
|
"openLink": "Open recipe link",
|
||||||
|
"deleteConfirm": "Delete recipe \"{{title}}\"?",
|
||||||
|
"created": "Recipe saved.",
|
||||||
|
"updated": "Recipe updated.",
|
||||||
|
"deleted": "Recipe deleted.",
|
||||||
|
"titleRequired": "Title is required",
|
||||||
|
"duplicate": "Duplicate",
|
||||||
|
"duplicated": "Recipe duplicated.",
|
||||||
|
"copySuffix": "copy"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+31
-2
@@ -42,7 +42,8 @@
|
|||||||
"settings": "Inställningar",
|
"settings": "Inställningar",
|
||||||
"main": "Huvudnavigering",
|
"main": "Huvudnavigering",
|
||||||
"navigation": "Navigering",
|
"navigation": "Navigering",
|
||||||
"quickActions": "Snabba åtgärder"
|
"quickActions": "Snabba åtgärder",
|
||||||
|
"recipes": "Recipes"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Översikt",
|
"title": "Översikt",
|
||||||
@@ -242,7 +243,11 @@
|
|||||||
"loadingIndicator": "Laddar…",
|
"loadingIndicator": "Laddar…",
|
||||||
"recipeUrlLabel": "Receptlänk (valfri)",
|
"recipeUrlLabel": "Receptlänk (valfri)",
|
||||||
"recipeUrlPlaceholder": "https://…",
|
"recipeUrlPlaceholder": "https://…",
|
||||||
"openRecipe": "Öppna recept"
|
"openRecipe": "Öppna recept",
|
||||||
|
"savedRecipeLabel": "Saved recipe",
|
||||||
|
"savedRecipePlaceholder": "Select recipe",
|
||||||
|
"saveAsRecipe": "Save as recipe",
|
||||||
|
"recipeScaleLabel": "Scale ingredients"
|
||||||
},
|
},
|
||||||
"calendar": {
|
"calendar": {
|
||||||
"title": "Kalender",
|
"title": "Kalender",
|
||||||
@@ -604,5 +609,29 @@
|
|||||||
"unitWeeks": "veckor",
|
"unitWeeks": "veckor",
|
||||||
"unitMonth": "månad",
|
"unitMonth": "månad",
|
||||||
"unitMonths": "månader"
|
"unitMonths": "månader"
|
||||||
|
},
|
||||||
|
"recipes": {
|
||||||
|
"title": "Recipes",
|
||||||
|
"addRecipe": "Add recipe",
|
||||||
|
"editRecipe": "Edit recipe",
|
||||||
|
"emptyTitle": "No recipes yet",
|
||||||
|
"emptyDescription": "Save your favorite recipes and reuse them in meal planning.",
|
||||||
|
"titleLabel": "Title *",
|
||||||
|
"titlePlaceholder": "e.g. Pasta Carbonara",
|
||||||
|
"notesLabel": "Notes",
|
||||||
|
"notesPlaceholder": "Optional...",
|
||||||
|
"urlLabel": "Recipe link",
|
||||||
|
"urlPlaceholder": "https://...",
|
||||||
|
"ingredientsLabel": "Ingredients",
|
||||||
|
"addToMeals": "Add to meal plan",
|
||||||
|
"openLink": "Open recipe link",
|
||||||
|
"deleteConfirm": "Delete recipe \"{{title}}\"?",
|
||||||
|
"created": "Recipe saved.",
|
||||||
|
"updated": "Recipe updated.",
|
||||||
|
"deleted": "Recipe deleted.",
|
||||||
|
"titleRequired": "Title is required",
|
||||||
|
"duplicate": "Duplicate",
|
||||||
|
"duplicated": "Recipe duplicated.",
|
||||||
|
"copySuffix": "copy"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+31
-2
@@ -42,7 +42,8 @@
|
|||||||
"settings": "Ayarlar",
|
"settings": "Ayarlar",
|
||||||
"main": "Ana gezinme",
|
"main": "Ana gezinme",
|
||||||
"navigation": "Gezinme",
|
"navigation": "Gezinme",
|
||||||
"quickActions": "Hızlı işlemler"
|
"quickActions": "Hızlı işlemler",
|
||||||
|
"recipes": "Recipes"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Genel Bakış",
|
"title": "Genel Bakış",
|
||||||
@@ -242,7 +243,11 @@
|
|||||||
"loadingIndicator": "Yükleniyor…",
|
"loadingIndicator": "Yükleniyor…",
|
||||||
"recipeUrlLabel": "Tarif bağlantısı (isteğe bağlı)",
|
"recipeUrlLabel": "Tarif bağlantısı (isteğe bağlı)",
|
||||||
"recipeUrlPlaceholder": "https://…",
|
"recipeUrlPlaceholder": "https://…",
|
||||||
"openRecipe": "Tarifi aç"
|
"openRecipe": "Tarifi aç",
|
||||||
|
"savedRecipeLabel": "Saved recipe",
|
||||||
|
"savedRecipePlaceholder": "Select recipe",
|
||||||
|
"saveAsRecipe": "Save as recipe",
|
||||||
|
"recipeScaleLabel": "Scale ingredients"
|
||||||
},
|
},
|
||||||
"calendar": {
|
"calendar": {
|
||||||
"title": "Takvim",
|
"title": "Takvim",
|
||||||
@@ -604,5 +609,29 @@
|
|||||||
"unitWeeks": "hafta",
|
"unitWeeks": "hafta",
|
||||||
"unitMonth": "ay",
|
"unitMonth": "ay",
|
||||||
"unitMonths": "ay"
|
"unitMonths": "ay"
|
||||||
|
},
|
||||||
|
"recipes": {
|
||||||
|
"title": "Recipes",
|
||||||
|
"addRecipe": "Add recipe",
|
||||||
|
"editRecipe": "Edit recipe",
|
||||||
|
"emptyTitle": "No recipes yet",
|
||||||
|
"emptyDescription": "Save your favorite recipes and reuse them in meal planning.",
|
||||||
|
"titleLabel": "Title *",
|
||||||
|
"titlePlaceholder": "e.g. Pasta Carbonara",
|
||||||
|
"notesLabel": "Notes",
|
||||||
|
"notesPlaceholder": "Optional...",
|
||||||
|
"urlLabel": "Recipe link",
|
||||||
|
"urlPlaceholder": "https://...",
|
||||||
|
"ingredientsLabel": "Ingredients",
|
||||||
|
"addToMeals": "Add to meal plan",
|
||||||
|
"openLink": "Open recipe link",
|
||||||
|
"deleteConfirm": "Delete recipe \"{{title}}\"?",
|
||||||
|
"created": "Recipe saved.",
|
||||||
|
"updated": "Recipe updated.",
|
||||||
|
"deleted": "Recipe deleted.",
|
||||||
|
"titleRequired": "Title is required",
|
||||||
|
"duplicate": "Duplicate",
|
||||||
|
"duplicated": "Recipe duplicated.",
|
||||||
|
"copySuffix": "copy"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+31
-2
@@ -42,7 +42,8 @@
|
|||||||
"settings": "Налаштування",
|
"settings": "Налаштування",
|
||||||
"main": "Головна навігація",
|
"main": "Головна навігація",
|
||||||
"navigation": "Навігація",
|
"navigation": "Навігація",
|
||||||
"quickActions": "Швидкі дії"
|
"quickActions": "Швидкі дії",
|
||||||
|
"recipes": "Recipes"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Огляд",
|
"title": "Огляд",
|
||||||
@@ -242,7 +243,11 @@
|
|||||||
"loadingIndicator": "Завантаження…",
|
"loadingIndicator": "Завантаження…",
|
||||||
"recipeUrlLabel": "Посилання на рецепт (необов'язково)",
|
"recipeUrlLabel": "Посилання на рецепт (необов'язково)",
|
||||||
"recipeUrlPlaceholder": "https://…",
|
"recipeUrlPlaceholder": "https://…",
|
||||||
"openRecipe": "Відкрити рецепт"
|
"openRecipe": "Відкрити рецепт",
|
||||||
|
"savedRecipeLabel": "Saved recipe",
|
||||||
|
"savedRecipePlaceholder": "Select recipe",
|
||||||
|
"saveAsRecipe": "Save as recipe",
|
||||||
|
"recipeScaleLabel": "Scale ingredients"
|
||||||
},
|
},
|
||||||
"calendar": {
|
"calendar": {
|
||||||
"title": "Календар",
|
"title": "Календар",
|
||||||
@@ -625,5 +630,29 @@
|
|||||||
"notificationHint": "Отримуйте сповіщення, поки додаток відкрито.",
|
"notificationHint": "Отримуйте сповіщення, поки додаток відкрито.",
|
||||||
"pendingBadgeTitle": "{{count}} нагадування",
|
"pendingBadgeTitle": "{{count}} нагадування",
|
||||||
"pendingBadgeTitlePlural": "{{count}} нагадувань"
|
"pendingBadgeTitlePlural": "{{count}} нагадувань"
|
||||||
|
},
|
||||||
|
"recipes": {
|
||||||
|
"title": "Recipes",
|
||||||
|
"addRecipe": "Add recipe",
|
||||||
|
"editRecipe": "Edit recipe",
|
||||||
|
"emptyTitle": "No recipes yet",
|
||||||
|
"emptyDescription": "Save your favorite recipes and reuse them in meal planning.",
|
||||||
|
"titleLabel": "Title *",
|
||||||
|
"titlePlaceholder": "e.g. Pasta Carbonara",
|
||||||
|
"notesLabel": "Notes",
|
||||||
|
"notesPlaceholder": "Optional...",
|
||||||
|
"urlLabel": "Recipe link",
|
||||||
|
"urlPlaceholder": "https://...",
|
||||||
|
"ingredientsLabel": "Ingredients",
|
||||||
|
"addToMeals": "Add to meal plan",
|
||||||
|
"openLink": "Open recipe link",
|
||||||
|
"deleteConfirm": "Delete recipe \"{{title}}\"?",
|
||||||
|
"created": "Recipe saved.",
|
||||||
|
"updated": "Recipe updated.",
|
||||||
|
"deleted": "Recipe deleted.",
|
||||||
|
"titleRequired": "Title is required",
|
||||||
|
"duplicate": "Duplicate",
|
||||||
|
"duplicated": "Recipe duplicated.",
|
||||||
|
"copySuffix": "copy"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+31
-2
@@ -42,7 +42,8 @@
|
|||||||
"settings": "设置",
|
"settings": "设置",
|
||||||
"main": "主导航",
|
"main": "主导航",
|
||||||
"navigation": "导航",
|
"navigation": "导航",
|
||||||
"quickActions": "快捷操作"
|
"quickActions": "快捷操作",
|
||||||
|
"recipes": "Recipes"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "概览",
|
"title": "概览",
|
||||||
@@ -242,7 +243,11 @@
|
|||||||
"loadingIndicator": "加载中…",
|
"loadingIndicator": "加载中…",
|
||||||
"recipeUrlLabel": "食谱链接(可选)",
|
"recipeUrlLabel": "食谱链接(可选)",
|
||||||
"recipeUrlPlaceholder": "https://…",
|
"recipeUrlPlaceholder": "https://…",
|
||||||
"openRecipe": "打开食谱"
|
"openRecipe": "打开食谱",
|
||||||
|
"savedRecipeLabel": "Saved recipe",
|
||||||
|
"savedRecipePlaceholder": "Select recipe",
|
||||||
|
"saveAsRecipe": "Save as recipe",
|
||||||
|
"recipeScaleLabel": "Scale ingredients"
|
||||||
},
|
},
|
||||||
"calendar": {
|
"calendar": {
|
||||||
"title": "日历",
|
"title": "日历",
|
||||||
@@ -604,5 +609,29 @@
|
|||||||
"unitWeeks": "周",
|
"unitWeeks": "周",
|
||||||
"unitMonth": "个月",
|
"unitMonth": "个月",
|
||||||
"unitMonths": "个月"
|
"unitMonths": "个月"
|
||||||
|
},
|
||||||
|
"recipes": {
|
||||||
|
"title": "Recipes",
|
||||||
|
"addRecipe": "Add recipe",
|
||||||
|
"editRecipe": "Edit recipe",
|
||||||
|
"emptyTitle": "No recipes yet",
|
||||||
|
"emptyDescription": "Save your favorite recipes and reuse them in meal planning.",
|
||||||
|
"titleLabel": "Title *",
|
||||||
|
"titlePlaceholder": "e.g. Pasta Carbonara",
|
||||||
|
"notesLabel": "Notes",
|
||||||
|
"notesPlaceholder": "Optional...",
|
||||||
|
"urlLabel": "Recipe link",
|
||||||
|
"urlPlaceholder": "https://...",
|
||||||
|
"ingredientsLabel": "Ingredients",
|
||||||
|
"addToMeals": "Add to meal plan",
|
||||||
|
"openLink": "Open recipe link",
|
||||||
|
"deleteConfirm": "Delete recipe \"{{title}}\"?",
|
||||||
|
"created": "Recipe saved.",
|
||||||
|
"updated": "Recipe updated.",
|
||||||
|
"deleted": "Recipe deleted.",
|
||||||
|
"titleRequired": "Title is required",
|
||||||
|
"duplicate": "Duplicate",
|
||||||
|
"duplicated": "Recipe duplicated.",
|
||||||
|
"copySuffix": "copy"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+194
-11
@@ -36,6 +36,7 @@ const EXCLUDED_MEAL_CATEGORY_NAMES = new Set(['Haushalt', 'Drogerie']);
|
|||||||
let state = {
|
let state = {
|
||||||
currentWeek: null, // YYYY-MM-DD (Montag)
|
currentWeek: null, // YYYY-MM-DD (Montag)
|
||||||
meals: [],
|
meals: [],
|
||||||
|
recipes: [],
|
||||||
lists: [], // Einkaufslisten für Transfer-Dropdown
|
lists: [], // Einkaufslisten für Transfer-Dropdown
|
||||||
categories: [], // Einkaufskategorien für Zutaten
|
categories: [], // Einkaufskategorien für Zutaten
|
||||||
modal: null,
|
modal: null,
|
||||||
@@ -115,6 +116,15 @@ async function loadCategories() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadRecipes() {
|
||||||
|
try {
|
||||||
|
const res = await api.get('/recipes');
|
||||||
|
state.recipes = res.data;
|
||||||
|
} catch {
|
||||||
|
state.recipes = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadPreferences() {
|
async function loadPreferences() {
|
||||||
try {
|
try {
|
||||||
const res = await api.get('/preferences');
|
const res = await api.get('/preferences');
|
||||||
@@ -157,10 +167,19 @@ export async function render(container, { user }) {
|
|||||||
const today = new Date().toISOString().slice(0, 10);
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
const monday = getMondayOf(today);
|
const monday = getMondayOf(today);
|
||||||
|
|
||||||
await Promise.all([loadWeek(monday), loadLists(), loadPreferences(), loadCategories()]);
|
await Promise.all([loadWeek(monday), loadLists(), loadPreferences(), loadCategories(), loadRecipes()]);
|
||||||
renderWeekGrid();
|
renderWeekGrid();
|
||||||
wireNav();
|
wireNav();
|
||||||
|
|
||||||
|
const selectedRecipeId = Number(new URLSearchParams(window.location.search).get('recipe'));
|
||||||
|
if (selectedRecipeId) {
|
||||||
|
const selectedRecipe = state.recipes.find((r) => r.id === selectedRecipeId);
|
||||||
|
if (selectedRecipe) {
|
||||||
|
const firstType = state.visibleMealTypes[0] ?? 'lunch';
|
||||||
|
openMealModal({ mode: 'create', date: today, mealType: firstType, presetRecipeId: selectedRecipe.id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
container.querySelector('#fab-new-meal').addEventListener('click', () => {
|
container.querySelector('#fab-new-meal').addEventListener('click', () => {
|
||||||
const firstType = state.visibleMealTypes[0] ?? 'lunch';
|
const firstType = state.visibleMealTypes[0] ?? 'lunch';
|
||||||
openMealModal({ mode: 'create', date: today, mealType: firstType });
|
openMealModal({ mode: 'create', date: today, mealType: firstType });
|
||||||
@@ -473,7 +492,7 @@ async function moveMeal(mealId, sourceDate, sourceType, targetDate, targetType,
|
|||||||
|
|
||||||
function openMealModal(opts) {
|
function openMealModal(opts) {
|
||||||
state.modal = opts;
|
state.modal = opts;
|
||||||
const { mode, date, mealType, meal } = opts;
|
const { mode, date, mealType, meal, presetRecipeId = null } = opts;
|
||||||
const isEdit = mode === 'edit';
|
const isEdit = mode === 'edit';
|
||||||
|
|
||||||
const content = buildModalContent(opts);
|
const content = buildModalContent(opts);
|
||||||
@@ -523,6 +542,144 @@ function openMealModal(opts) {
|
|||||||
// Zutaten
|
// Zutaten
|
||||||
const ingList = panel.querySelector('#ingredient-list');
|
const ingList = panel.querySelector('#ingredient-list');
|
||||||
const addIngBtn = panel.querySelector('#add-ingredient-btn');
|
const addIngBtn = panel.querySelector('#add-ingredient-btn');
|
||||||
|
const recipeSelect = panel.querySelector('#modal-recipe-id');
|
||||||
|
const recipeScaleInput = panel.querySelector('#modal-recipe-scale');
|
||||||
|
const saveAsRecipeBtn = panel.querySelector('#modal-save-as-recipe');
|
||||||
|
let currentAppliedRecipe = null;
|
||||||
|
|
||||||
|
const scaleQuantityText = (quantity, factor) => {
|
||||||
|
if (!quantity || factor === 1) return quantity;
|
||||||
|
|
||||||
|
const formatNumber = (num, useComma = false) => {
|
||||||
|
const rounded = Math.round(num * 100) / 100;
|
||||||
|
if (Number.isInteger(rounded)) return String(rounded);
|
||||||
|
const text = String(rounded);
|
||||||
|
return useComma ? text.replace('.', ',') : text;
|
||||||
|
};
|
||||||
|
|
||||||
|
const mixed = quantity.match(/^(\d+)\s+(\d+)\/(\d+)(.*)$/);
|
||||||
|
if (mixed) {
|
||||||
|
const whole = Number(mixed[1]);
|
||||||
|
const num = Number(mixed[2]);
|
||||||
|
const den = Number(mixed[3]);
|
||||||
|
if (den > 0) {
|
||||||
|
const value = (whole + (num / den)) * factor;
|
||||||
|
return `${formatNumber(value)}${mixed[4]}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const frac = quantity.match(/^(\d+)\/(\d+)(.*)$/);
|
||||||
|
if (frac) {
|
||||||
|
const num = Number(frac[1]);
|
||||||
|
const den = Number(frac[2]);
|
||||||
|
if (den > 0) {
|
||||||
|
const value = (num / den) * factor;
|
||||||
|
return `${formatNumber(value)}${frac[3]}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const dec = quantity.match(/^(\d+(?:[.,]\d+)?)(.*)$/);
|
||||||
|
if (dec) {
|
||||||
|
const useComma = dec[1].includes(',');
|
||||||
|
const base = Number(dec[1].replace(',', '.'));
|
||||||
|
if (Number.isFinite(base)) {
|
||||||
|
return `${formatNumber(base * factor, useComma)}${dec[2]}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return quantity;
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyRecipe = (recipeId) => {
|
||||||
|
const id = Number(recipeId);
|
||||||
|
const factor = Math.max(Number(recipeScaleInput?.value || 1), 0.1);
|
||||||
|
if (!id) {
|
||||||
|
currentAppliedRecipe = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const recipe = state.recipes.find((r) => r.id === id);
|
||||||
|
if (!recipe) return;
|
||||||
|
|
||||||
|
currentAppliedRecipe = recipe;
|
||||||
|
|
||||||
|
panel.querySelector('#modal-title').value = recipe.title || '';
|
||||||
|
panel.querySelector('#modal-notes').value = recipe.notes || '';
|
||||||
|
panel.querySelector('#modal-recipe-url').value = recipe.recipe_url || '';
|
||||||
|
|
||||||
|
ingList.innerHTML = (recipe.ingredients || [])
|
||||||
|
.map((ing) => {
|
||||||
|
const scaledQty = scaleQuantityText(ing.quantity ?? '', factor);
|
||||||
|
return ingredientRowHTML(ing.name, scaledQty, null, ing.category ?? DEFAULT_CATEGORY_NAME);
|
||||||
|
})
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
if (window.lucide) lucide.createIcons();
|
||||||
|
};
|
||||||
|
|
||||||
|
recipeSelect?.addEventListener('change', () => {
|
||||||
|
if (recipeScaleInput) recipeScaleInput.value = '1';
|
||||||
|
applyRecipe(recipeSelect.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
recipeScaleInput?.addEventListener('input', () => {
|
||||||
|
const currentRecipeId = Number(recipeSelect?.value || 0);
|
||||||
|
if (!currentRecipeId || !currentAppliedRecipe) return;
|
||||||
|
|
||||||
|
const factor = Number(recipeScaleInput.value || 1);
|
||||||
|
if (!Number.isFinite(factor) || factor <= 0) return;
|
||||||
|
|
||||||
|
ingList.innerHTML = (currentAppliedRecipe.ingredients || [])
|
||||||
|
.map((ing) => ingredientRowHTML(
|
||||||
|
ing.name,
|
||||||
|
scaleQuantityText(ing.quantity ?? '', Math.max(factor, 0.1)),
|
||||||
|
null,
|
||||||
|
ing.category ?? DEFAULT_CATEGORY_NAME
|
||||||
|
))
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
if (window.lucide) lucide.createIcons();
|
||||||
|
});
|
||||||
|
|
||||||
|
saveAsRecipeBtn?.addEventListener('click', async () => {
|
||||||
|
const title = panel.querySelector('#modal-title').value.trim();
|
||||||
|
if (!title) {
|
||||||
|
window.oikos?.showToast(t('meals.titleRequired'), 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const notes = panel.querySelector('#modal-notes').value.trim() || null;
|
||||||
|
const recipe_url = panel.querySelector('#modal-recipe-url').value.trim() || null;
|
||||||
|
const ingredients = collectModalIngredients(panel).map((ing) => ({
|
||||||
|
name: ing.name,
|
||||||
|
quantity: ing.quantity,
|
||||||
|
category: ing.category,
|
||||||
|
}));
|
||||||
|
|
||||||
|
saveAsRecipeBtn.disabled = true;
|
||||||
|
try {
|
||||||
|
const created = await api.post('/recipes', { title, notes, recipe_url, ingredients });
|
||||||
|
state.recipes.push(created.data);
|
||||||
|
|
||||||
|
if (recipeSelect) {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = String(created.data.id);
|
||||||
|
option.textContent = created.data.title;
|
||||||
|
recipeSelect.appendChild(option);
|
||||||
|
recipeSelect.value = String(created.data.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.oikos?.showToast(t('recipes.created'), 'success');
|
||||||
|
} catch (err) {
|
||||||
|
window.oikos?.showToast(err.data?.error ?? t('common.errorGeneric'), 'error');
|
||||||
|
} finally {
|
||||||
|
saveAsRecipeBtn.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (presetRecipeId && recipeSelect) {
|
||||||
|
recipeSelect.value = String(presetRecipeId);
|
||||||
|
applyRecipe(presetRecipeId);
|
||||||
|
}
|
||||||
|
|
||||||
addIngBtn.addEventListener('click', () => {
|
addIngBtn.addEventListener('click', () => {
|
||||||
const tmp = document.createElement('div');
|
const tmp = document.createElement('div');
|
||||||
@@ -584,6 +741,11 @@ function buildModalContent({ mode, date, mealType, meal }) {
|
|||||||
|
|
||||||
const hasIngOpen = isEdit && meal.ingredients?.some((i) => !i.on_shopping_list);
|
const hasIngOpen = isEdit && meal.ingredients?.some((i) => !i.on_shopping_list);
|
||||||
|
|
||||||
|
const recipeOptions = [
|
||||||
|
`<option value="">${t('meals.savedRecipePlaceholder')}</option>`,
|
||||||
|
...state.recipes.map((r) => `<option value="${r.id}" ${isEdit && meal.recipe_id === r.id ? 'selected' : ''}>${esc(r.title)}</option>`),
|
||||||
|
].join('');
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="modal-grid modal-grid--2">
|
<div class="modal-grid modal-grid--2">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -605,6 +767,21 @@ function buildModalContent({ mode, date, mealType, meal }) {
|
|||||||
<div id="modal-autocomplete" class="meal-modal__autocomplete" hidden></div>
|
<div id="modal-autocomplete" class="meal-modal__autocomplete" hidden></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="modal-recipe-id">${t('meals.savedRecipeLabel')}</label>
|
||||||
|
<select class="form-input" id="modal-recipe-id">${recipeOptions}</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-grid modal-grid--2">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="modal-recipe-scale">${t('meals.recipeScaleLabel')}</label>
|
||||||
|
<input type="number" class="form-input" id="modal-recipe-scale" min="0.1" step="0.1" value="1">
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="display:flex;align-items:flex-end;">
|
||||||
|
<button class="btn btn--secondary" id="modal-save-as-recipe" type="button">${t('meals.saveAsRecipe')}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" for="modal-notes">${t('meals.notesLabel')}</label>
|
<label class="form-label" for="modal-notes">${t('meals.notesLabel')}</label>
|
||||||
<textarea class="form-input" id="modal-notes" rows="2"
|
<textarea class="form-input" id="modal-notes" rows="2"
|
||||||
@@ -678,19 +855,14 @@ async function saveModal(overlay) {
|
|||||||
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;
|
const recipe_url = overlay.querySelector('#modal-recipe-url').value.trim() || null;
|
||||||
|
const recipe_id = overlay.querySelector('#modal-recipe-id')?.value || null;
|
||||||
|
|
||||||
if (!title) {
|
if (!title) {
|
||||||
window.oikos?.showToast(t('meals.titleRequired'), 'error');
|
window.oikos?.showToast(t('meals.titleRequired'), 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ingredients = [];
|
const ingredients = collectModalIngredients(overlay);
|
||||||
overlay.querySelectorAll('.ingredient-row').forEach((row) => {
|
|
||||||
const name = row.querySelector('.ingredient-row__name').value.trim();
|
|
||||||
const qty = row.querySelector('.ingredient-row__qty').value.trim() || null;
|
|
||||||
const category = row.querySelector('.ingredient-row__cat')?.value || DEFAULT_CATEGORY_NAME;
|
|
||||||
if (name) ingredients.push({ name, quantity: qty, category, id: row.dataset.ingId || null });
|
|
||||||
});
|
|
||||||
|
|
||||||
saveBtn.disabled = true;
|
saveBtn.disabled = true;
|
||||||
saveBtn.textContent = '…';
|
saveBtn.textContent = '…';
|
||||||
@@ -699,11 +871,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, recipe_url, ingredients });
|
const res = await api.post('/meals', { date, meal_type, title, notes, recipe_url, recipe_id, 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, recipe_url });
|
await api.put(`/meals/${meal.id}`, { date, meal_type, title, notes, recipe_url, recipe_id });
|
||||||
|
|
||||||
// Sync ingredients
|
// Sync ingredients
|
||||||
const existingIds = new Set((meal.ingredients ?? []).map((i) => i.id));
|
const existingIds = new Set((meal.ingredients ?? []).map((i) => i.id));
|
||||||
@@ -732,6 +904,17 @@ async function saveModal(overlay) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function collectModalIngredients(overlay) {
|
||||||
|
const ingredients = [];
|
||||||
|
overlay.querySelectorAll('.ingredient-row').forEach((row) => {
|
||||||
|
const name = row.querySelector('.ingredient-row__name').value.trim();
|
||||||
|
const qty = row.querySelector('.ingredient-row__qty').value.trim() || null;
|
||||||
|
const category = row.querySelector('.ingredient-row__cat')?.value || DEFAULT_CATEGORY_NAME;
|
||||||
|
if (name) ingredients.push({ name, quantity: qty, category, id: row.dataset.ingId || null });
|
||||||
|
});
|
||||||
|
return ingredients;
|
||||||
|
}
|
||||||
|
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
// Mahlzeit löschen
|
// Mahlzeit löschen
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
|
|||||||
@@ -0,0 +1,372 @@
|
|||||||
|
/**
|
||||||
|
* Modul: Rezepte (Recipes)
|
||||||
|
* Zweck: Gespeicherte Rezepte verwalten und in den Essensplan uebernehmen
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { api } from '/api.js';
|
||||||
|
import { t } from '/i18n.js';
|
||||||
|
import { esc } from '/utils/html.js';
|
||||||
|
import { openModal as openSharedModal, closeModal as closeSharedModal, confirmModal } from '/components/modal.js';
|
||||||
|
import { DEFAULT_CATEGORY_NAME, categoryLabel } from '/utils/shopping-categories.js';
|
||||||
|
|
||||||
|
let _container = null;
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
recipes: [],
|
||||||
|
categories: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
function mealCategories() {
|
||||||
|
return state.categories.filter((c) => c.name !== 'Haushalt' && c.name !== 'Drogerie');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRecipes() {
|
||||||
|
const res = await api.get('/recipes');
|
||||||
|
state.recipes = res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCategories() {
|
||||||
|
try {
|
||||||
|
const res = await api.get('/shopping/categories');
|
||||||
|
state.categories = res.data;
|
||||||
|
} catch {
|
||||||
|
state.categories = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function render(container) {
|
||||||
|
_container = container;
|
||||||
|
|
||||||
|
const page = document.createElement('div');
|
||||||
|
page.className = 'recipes-page';
|
||||||
|
|
||||||
|
const header = document.createElement('div');
|
||||||
|
header.className = 'recipes-header';
|
||||||
|
|
||||||
|
const title = document.createElement('h1');
|
||||||
|
title.className = 'recipes-header__title';
|
||||||
|
title.textContent = t('recipes.title');
|
||||||
|
|
||||||
|
const addBtn = document.createElement('button');
|
||||||
|
addBtn.className = 'btn btn--primary';
|
||||||
|
addBtn.type = 'button';
|
||||||
|
addBtn.id = 'recipes-add';
|
||||||
|
addBtn.textContent = t('recipes.addRecipe');
|
||||||
|
|
||||||
|
header.append(title, addBtn);
|
||||||
|
|
||||||
|
const list = document.createElement('div');
|
||||||
|
list.className = 'recipes-list';
|
||||||
|
list.id = 'recipes-list';
|
||||||
|
|
||||||
|
const fab = document.createElement('button');
|
||||||
|
fab.className = 'page-fab';
|
||||||
|
fab.type = 'button';
|
||||||
|
fab.id = 'recipes-fab';
|
||||||
|
fab.setAttribute('aria-label', t('recipes.addRecipe'));
|
||||||
|
const fabIcon = document.createElement('i');
|
||||||
|
fabIcon.dataset.lucide = 'plus';
|
||||||
|
fabIcon.setAttribute('aria-hidden', 'true');
|
||||||
|
fab.appendChild(fabIcon);
|
||||||
|
|
||||||
|
page.append(header, list, fab);
|
||||||
|
container.replaceChildren(page);
|
||||||
|
|
||||||
|
if (window.lucide) window.lucide.createIcons();
|
||||||
|
|
||||||
|
await Promise.all([loadRecipes(), loadCategories()]);
|
||||||
|
renderRecipeList();
|
||||||
|
|
||||||
|
addBtn.addEventListener('click', () => openRecipeModal('create'));
|
||||||
|
fab.addEventListener('click', () => openRecipeModal('create'));
|
||||||
|
|
||||||
|
list.addEventListener('click', async (e) => {
|
||||||
|
const actionBtn = e.target.closest('[data-action]');
|
||||||
|
if (!actionBtn) return;
|
||||||
|
|
||||||
|
const recipeId = Number(actionBtn.dataset.id);
|
||||||
|
const recipe = state.recipes.find((r) => r.id === recipeId);
|
||||||
|
if (!recipe) return;
|
||||||
|
|
||||||
|
if (actionBtn.dataset.action === 'edit') {
|
||||||
|
openRecipeModal('edit', recipe);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actionBtn.dataset.action === 'delete') {
|
||||||
|
await removeRecipe(recipe);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actionBtn.dataset.action === 'duplicate') {
|
||||||
|
await duplicateRecipe(recipe);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actionBtn.dataset.action === 'add-to-meals') {
|
||||||
|
window.oikos?.navigate(`/meals?recipe=${recipe.id}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRecipeList() {
|
||||||
|
const list = _container.querySelector('#recipes-list');
|
||||||
|
if (!list) return;
|
||||||
|
|
||||||
|
list.replaceChildren();
|
||||||
|
|
||||||
|
if (!state.recipes.length) {
|
||||||
|
const empty = document.createElement('div');
|
||||||
|
empty.className = 'empty-state';
|
||||||
|
|
||||||
|
const emptyTitle = document.createElement('div');
|
||||||
|
emptyTitle.className = 'empty-state__title';
|
||||||
|
emptyTitle.textContent = t('recipes.emptyTitle');
|
||||||
|
|
||||||
|
const emptyDesc = document.createElement('div');
|
||||||
|
emptyDesc.className = 'empty-state__description';
|
||||||
|
emptyDesc.textContent = t('recipes.emptyDescription');
|
||||||
|
|
||||||
|
empty.append(emptyTitle, emptyDesc);
|
||||||
|
list.appendChild(empty);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const recipe of state.recipes) {
|
||||||
|
const card = document.createElement('article');
|
||||||
|
card.className = 'recipe-card';
|
||||||
|
|
||||||
|
const h = document.createElement('h2');
|
||||||
|
h.className = 'recipe-card__title';
|
||||||
|
h.textContent = recipe.title;
|
||||||
|
|
||||||
|
card.appendChild(h);
|
||||||
|
|
||||||
|
if (recipe.notes) {
|
||||||
|
const notes = document.createElement('p');
|
||||||
|
notes.className = 'recipe-card__notes';
|
||||||
|
notes.textContent = recipe.notes;
|
||||||
|
card.appendChild(notes);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recipe.recipe_url) {
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.className = 'btn btn--ghost';
|
||||||
|
link.href = recipe.recipe_url;
|
||||||
|
link.target = '_blank';
|
||||||
|
link.rel = 'noopener noreferrer';
|
||||||
|
link.textContent = t('recipes.openLink');
|
||||||
|
card.appendChild(link);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ingredients = recipe.ingredients ?? [];
|
||||||
|
if (ingredients.length) {
|
||||||
|
const ul = document.createElement('ul');
|
||||||
|
ul.className = 'recipe-card__ingredients';
|
||||||
|
for (const ing of ingredients) {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
li.className = 'recipe-card__ingredient';
|
||||||
|
const qty = ing.quantity ? `${ing.quantity} · ` : '';
|
||||||
|
li.textContent = `${qty}${ing.name}`;
|
||||||
|
ul.appendChild(li);
|
||||||
|
}
|
||||||
|
card.appendChild(ul);
|
||||||
|
}
|
||||||
|
|
||||||
|
const actions = document.createElement('div');
|
||||||
|
actions.className = 'recipe-card__actions';
|
||||||
|
|
||||||
|
const addToMeals = document.createElement('button');
|
||||||
|
addToMeals.className = 'btn btn--secondary';
|
||||||
|
addToMeals.type = 'button';
|
||||||
|
addToMeals.dataset.action = 'add-to-meals';
|
||||||
|
addToMeals.dataset.id = String(recipe.id);
|
||||||
|
addToMeals.textContent = t('recipes.addToMeals');
|
||||||
|
|
||||||
|
const edit = document.createElement('button');
|
||||||
|
edit.className = 'btn btn--secondary';
|
||||||
|
edit.type = 'button';
|
||||||
|
edit.dataset.action = 'edit';
|
||||||
|
edit.dataset.id = String(recipe.id);
|
||||||
|
edit.textContent = t('common.edit');
|
||||||
|
|
||||||
|
const del = document.createElement('button');
|
||||||
|
del.className = 'btn btn--danger';
|
||||||
|
del.type = 'button';
|
||||||
|
del.dataset.action = 'delete';
|
||||||
|
del.dataset.id = String(recipe.id);
|
||||||
|
del.textContent = t('common.delete');
|
||||||
|
|
||||||
|
const duplicate = document.createElement('button');
|
||||||
|
duplicate.className = 'btn btn--secondary';
|
||||||
|
duplicate.type = 'button';
|
||||||
|
duplicate.dataset.action = 'duplicate';
|
||||||
|
duplicate.dataset.id = String(recipe.id);
|
||||||
|
duplicate.textContent = t('recipes.duplicate');
|
||||||
|
|
||||||
|
actions.append(addToMeals, edit, duplicate, del);
|
||||||
|
card.appendChild(actions);
|
||||||
|
|
||||||
|
list.appendChild(card);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function recipeIngredientRowHTML(name, qty, category = DEFAULT_CATEGORY_NAME) {
|
||||||
|
const categories = mealCategories();
|
||||||
|
const resolvedCategory = categories.some((c) => c.name === category)
|
||||||
|
? category
|
||||||
|
: (categories[0]?.name ?? DEFAULT_CATEGORY_NAME);
|
||||||
|
const catOptions = categories.length
|
||||||
|
? categories.map((c) => `<option value="${esc(c.name)}" ${c.name === resolvedCategory ? 'selected' : ''}>${esc(categoryLabel(c.name))}</option>`).join('')
|
||||||
|
: `<option value="${DEFAULT_CATEGORY_NAME}" selected>${t('meals.ingredientCategoryDefault')}</option>`;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="recipe-ingredient-row">
|
||||||
|
<input type="text" class="form-input recipe-ingredient-row__name" placeholder="${t('meals.ingredientNamePlaceholder')}" value="${esc(name)}">
|
||||||
|
<input type="text" class="form-input recipe-ingredient-row__qty" placeholder="${t('meals.ingredientQtyPlaceholder')}" value="${esc(qty)}">
|
||||||
|
<select class="form-input recipe-ingredient-row__cat" aria-label="${t('meals.ingredientCategoryLabel')}">${catOptions}</select>
|
||||||
|
<button class="recipe-ingredient-row__remove" data-action="remove-ingredient" type="button" aria-label="${t('meals.removeIngredient')}">
|
||||||
|
<i data-lucide="x" style="width:14px;height:14px;" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openRecipeModal(mode, recipe = null) {
|
||||||
|
const isEdit = mode === 'edit';
|
||||||
|
const ingredientRows = isEdit && recipe.ingredients?.length
|
||||||
|
? recipe.ingredients.map((i) => recipeIngredientRowHTML(i.name, i.quantity ?? '', i.category ?? DEFAULT_CATEGORY_NAME)).join('')
|
||||||
|
: '';
|
||||||
|
|
||||||
|
openSharedModal({
|
||||||
|
title: isEdit ? t('recipes.editRecipe') : t('recipes.addRecipe'),
|
||||||
|
size: 'md',
|
||||||
|
content: `
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="recipe-title">${t('recipes.titleLabel')}</label>
|
||||||
|
<input id="recipe-title" class="form-input" type="text" value="${esc(isEdit ? recipe.title : '')}" placeholder="${t('recipes.titlePlaceholder')}">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="recipe-notes">${t('recipes.notesLabel')}</label>
|
||||||
|
<textarea id="recipe-notes" class="form-input" rows="3" placeholder="${t('recipes.notesPlaceholder')}">${esc(isEdit && recipe.notes ? recipe.notes : '')}</textarea>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="recipe-url">${t('recipes.urlLabel')}</label>
|
||||||
|
<input id="recipe-url" class="form-input" type="url" value="${esc(isEdit && recipe.recipe_url ? recipe.recipe_url : '')}" placeholder="${t('recipes.urlPlaceholder')}">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">${t('recipes.ingredientsLabel')}</label>
|
||||||
|
<div class="recipe-ingredient-list" id="recipe-ingredient-list">${ingredientRows}</div>
|
||||||
|
<button class="btn btn--secondary recipe-add-ingredient" type="button" id="recipe-add-ingredient">${t('meals.addIngredient')}</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-panel__footer" style="border:none;padding:0;margin-top:var(--space-4)">
|
||||||
|
<button class="btn btn--secondary" id="recipe-cancel">${t('common.cancel')}</button>
|
||||||
|
<button class="btn btn--primary" id="recipe-save">${isEdit ? t('common.save') : t('common.add')}</button>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
onSave(panel) {
|
||||||
|
const ingList = panel.querySelector('#recipe-ingredient-list');
|
||||||
|
panel.querySelector('#recipe-add-ingredient')?.addEventListener('click', () => {
|
||||||
|
const tmp = document.createElement('div');
|
||||||
|
tmp.innerHTML = recipeIngredientRowHTML('', '', null);
|
||||||
|
const row = tmp.firstElementChild;
|
||||||
|
ingList.appendChild(row);
|
||||||
|
if (window.lucide) window.lucide.createIcons();
|
||||||
|
});
|
||||||
|
|
||||||
|
ingList.addEventListener('click', (e) => {
|
||||||
|
const btn = e.target.closest('[data-action="remove-ingredient"]');
|
||||||
|
if (!btn) return;
|
||||||
|
btn.closest('.recipe-ingredient-row')?.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
panel.querySelector('#recipe-cancel')?.addEventListener('click', closeModal);
|
||||||
|
panel.querySelector('#recipe-save')?.addEventListener('click', () => saveRecipe(panel, mode, recipe));
|
||||||
|
|
||||||
|
if (window.lucide) window.lucide.createIcons();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
closeSharedModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveRecipe(panel, mode, recipe) {
|
||||||
|
const saveBtn = panel.querySelector('#recipe-save');
|
||||||
|
const title = panel.querySelector('#recipe-title')?.value.trim() || '';
|
||||||
|
const notes = panel.querySelector('#recipe-notes')?.value.trim() || null;
|
||||||
|
const recipe_url = panel.querySelector('#recipe-url')?.value.trim() || null;
|
||||||
|
|
||||||
|
if (!title) {
|
||||||
|
window.oikos?.showToast(t('recipes.titleRequired'), 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ingredients = [];
|
||||||
|
panel.querySelectorAll('.recipe-ingredient-row').forEach((row) => {
|
||||||
|
const name = row.querySelector('.recipe-ingredient-row__name')?.value.trim() || '';
|
||||||
|
const quantity = row.querySelector('.recipe-ingredient-row__qty')?.value.trim() || null;
|
||||||
|
const category = row.querySelector('.recipe-ingredient-row__cat')?.value || DEFAULT_CATEGORY_NAME;
|
||||||
|
if (name) ingredients.push({ name, quantity, category });
|
||||||
|
});
|
||||||
|
|
||||||
|
saveBtn.disabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (mode === 'create') {
|
||||||
|
const res = await api.post('/recipes', { title, notes, recipe_url, ingredients });
|
||||||
|
state.recipes.push(res.data);
|
||||||
|
} else {
|
||||||
|
const res = await api.put(`/recipes/${recipe.id}`, { title, notes, recipe_url, ingredients });
|
||||||
|
const idx = state.recipes.findIndex((r) => r.id === recipe.id);
|
||||||
|
if (idx >= 0) state.recipes[idx] = res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
closeModal();
|
||||||
|
renderRecipeList();
|
||||||
|
window.oikos?.showToast(mode === 'create' ? t('recipes.created') : t('recipes.updated'), 'success');
|
||||||
|
} catch (err) {
|
||||||
|
saveBtn.disabled = false;
|
||||||
|
window.oikos?.showToast(err.data?.error ?? t('common.errorGeneric'), 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeRecipe(recipe) {
|
||||||
|
const ok = await confirmModal(t('recipes.deleteConfirm', { title: recipe.title }), {
|
||||||
|
danger: true,
|
||||||
|
confirmLabel: t('common.delete'),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!ok) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.delete(`/recipes/${recipe.id}`);
|
||||||
|
state.recipes = state.recipes.filter((r) => r.id !== recipe.id);
|
||||||
|
renderRecipeList();
|
||||||
|
window.oikos?.showToast(t('recipes.deleted'), 'success');
|
||||||
|
} catch (err) {
|
||||||
|
window.oikos?.showToast(err.data?.error ?? t('common.errorGeneric'), 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function duplicateRecipe(recipe) {
|
||||||
|
const copySuffix = t('recipes.copySuffix');
|
||||||
|
const title = `${recipe.title} (${copySuffix})`;
|
||||||
|
const notes = recipe.notes || null;
|
||||||
|
const recipe_url = recipe.recipe_url || null;
|
||||||
|
const ingredients = (recipe.ingredients || []).map((ing) => ({
|
||||||
|
name: ing.name,
|
||||||
|
quantity: ing.quantity || null,
|
||||||
|
category: ing.category || DEFAULT_CATEGORY_NAME,
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.post('/recipes', { title, notes, recipe_url, ingredients });
|
||||||
|
await loadRecipes();
|
||||||
|
renderRecipeList();
|
||||||
|
window.oikos?.showToast(t('recipes.duplicated'), 'success');
|
||||||
|
} catch (err) {
|
||||||
|
window.oikos?.showToast(err.data?.error ?? t('common.errorGeneric'), 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-1
@@ -20,6 +20,7 @@ const ROUTES = [
|
|||||||
{ path: '/meals', page: '/pages/meals.js', requiresAuth: true, module: 'meals' },
|
{ path: '/meals', page: '/pages/meals.js', requiresAuth: true, module: 'meals' },
|
||||||
{ path: '/calendar', page: '/pages/calendar.js', requiresAuth: true, module: 'calendar' },
|
{ path: '/calendar', page: '/pages/calendar.js', requiresAuth: true, module: 'calendar' },
|
||||||
{ path: '/notes', page: '/pages/notes.js', requiresAuth: true, module: 'notes' },
|
{ path: '/notes', page: '/pages/notes.js', requiresAuth: true, module: 'notes' },
|
||||||
|
{ path: '/recipes', page: '/pages/recipes.js', requiresAuth: true, module: 'recipes' },
|
||||||
{ path: '/contacts', page: '/pages/contacts.js', requiresAuth: true, module: 'contacts' },
|
{ path: '/contacts', page: '/pages/contacts.js', requiresAuth: true, module: 'contacts' },
|
||||||
{ path: '/budget', page: '/pages/budget.js', requiresAuth: true, module: 'budget' },
|
{ path: '/budget', page: '/pages/budget.js', requiresAuth: true, module: 'budget' },
|
||||||
{ path: '/settings', page: '/pages/settings.js', requiresAuth: true, module: 'settings' },
|
{ path: '/settings', page: '/pages/settings.js', requiresAuth: true, module: 'settings' },
|
||||||
@@ -123,7 +124,7 @@ let _pendingLoginRedirect = false;
|
|||||||
// Router
|
// Router
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
|
|
||||||
const ROUTE_ORDER = ['/', '/tasks', '/calendar', '/meals', '/shopping',
|
const ROUTE_ORDER = ['/', '/tasks', '/calendar', '/meals', '/recipes', '/shopping',
|
||||||
'/notes', '/contacts', '/budget', '/settings'];
|
'/notes', '/contacts', '/budget', '/settings'];
|
||||||
|
|
||||||
const PRIMARY_NAV = 4;
|
const PRIMARY_NAV = 4;
|
||||||
@@ -581,6 +582,7 @@ function navItems() {
|
|||||||
{ path: '/tasks', label: t('nav.tasks'), icon: 'check-square' },
|
{ path: '/tasks', label: t('nav.tasks'), icon: 'check-square' },
|
||||||
{ path: '/calendar', label: t('nav.calendar'), icon: 'calendar' },
|
{ path: '/calendar', label: t('nav.calendar'), icon: 'calendar' },
|
||||||
{ path: '/meals', label: t('nav.meals'), icon: 'utensils' },
|
{ path: '/meals', label: t('nav.meals'), icon: 'utensils' },
|
||||||
|
{ path: '/recipes', label: t('nav.recipes'), icon: 'book-open-text' },
|
||||||
{ path: '/shopping', label: t('nav.shopping'), icon: 'shopping-cart' },
|
{ path: '/shopping', label: t('nav.shopping'), icon: 'shopping-cart' },
|
||||||
{ path: '/notes', label: t('nav.notes'), icon: 'sticky-note' },
|
{ path: '/notes', label: t('nav.notes'), icon: 'sticky-note' },
|
||||||
{ path: '/contacts', label: t('nav.contacts'), icon: 'book-user' },
|
{ path: '/contacts', label: t('nav.contacts'), icon: 'book-user' },
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
/**
|
||||||
|
* Modul: Rezepte (Recipes)
|
||||||
|
* Zweck: Styles fuer gespeicherte Rezepte und Aktionskarten
|
||||||
|
*/
|
||||||
|
|
||||||
|
.recipes-page {
|
||||||
|
--module-accent: var(--module-recipes);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: calc(100dvh - var(--safe-area-inset-top) - var(--nav-bottom-height) - var(--safe-area-inset-bottom));
|
||||||
|
max-width: var(--content-max-width);
|
||||||
|
margin: 0 auto;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.recipes-page {
|
||||||
|
height: 100dvh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipes-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-4);
|
||||||
|
border-top: 3px solid var(--module-accent);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipes-header__title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--text-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipes-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: var(--space-4);
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-3);
|
||||||
|
align-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-card {
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--color-surface);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
padding: var(--space-4);
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-card__title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--text-base);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-card__notes {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-card__ingredients {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-card__ingredient {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-card__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-ingredient-list {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-ingredient-row {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-ingredient-row__name {
|
||||||
|
flex: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-ingredient-row__qty,
|
||||||
|
.recipe-ingredient-row__cat {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-ingredient-row__remove {
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-add-ingredient {
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
@@ -154,6 +154,8 @@
|
|||||||
--module-calendar: var(--_module-calendar); /* Violett - Termine, Zeit */
|
--module-calendar: var(--_module-calendar); /* Violett - Termine, Zeit */
|
||||||
--_module-meals: #C2410C;
|
--_module-meals: #C2410C;
|
||||||
--module-meals: var(--_module-meals); /* Orange-700 - Essen, Wärme */
|
--module-meals: var(--_module-meals); /* Orange-700 - Essen, Wärme */
|
||||||
|
--_module-recipes: #0D9488;
|
||||||
|
--module-recipes: var(--_module-recipes); /* Teal-600 - Rezepte */
|
||||||
--_module-shopping: #DB2777;
|
--_module-shopping: #DB2777;
|
||||||
--module-shopping: var(--_module-shopping); /* Pink-600 - Einkaufen (trennt von Meals) */
|
--module-shopping: var(--_module-shopping); /* Pink-600 - Einkaufen (trennt von Meals) */
|
||||||
--_module-notes: #A16207;
|
--_module-notes: #A16207;
|
||||||
|
|||||||
@@ -232,6 +232,41 @@ const MIGRATIONS_SQL = {
|
|||||||
WHERE subscription_id IS NOT NULL;
|
WHERE subscription_id IS NOT NULL;
|
||||||
CREATE INDEX IF NOT EXISTS idx_calendar_sub ON calendar_events(subscription_id);
|
CREATE INDEX IF NOT EXISTS idx_calendar_sub ON calendar_events(subscription_id);
|
||||||
`,
|
`,
|
||||||
|
12: `
|
||||||
|
CREATE TABLE IF NOT EXISTS recipes (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
notes TEXT,
|
||||||
|
recipe_url TEXT,
|
||||||
|
created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS recipe_ingredients (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
recipe_id INTEGER NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
quantity TEXT,
|
||||||
|
category TEXT NOT NULL DEFAULT 'Sonstiges',
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_recipes_title ON recipes(title);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_recipe_ingredients_recipe ON recipe_ingredients(recipe_id);
|
||||||
|
|
||||||
|
CREATE TRIGGER IF NOT EXISTS trg_recipes_updated_at
|
||||||
|
AFTER UPDATE ON recipes FOR EACH ROW
|
||||||
|
BEGIN UPDATE recipes SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = OLD.id; END;
|
||||||
|
|
||||||
|
CREATE TRIGGER IF NOT EXISTS trg_recipe_ingredients_updated_at
|
||||||
|
AFTER UPDATE ON recipe_ingredients FOR EACH ROW
|
||||||
|
BEGIN UPDATE recipe_ingredients SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = OLD.id; END;
|
||||||
|
|
||||||
|
ALTER TABLE meals ADD COLUMN recipe_id INTEGER REFERENCES recipes(id) ON DELETE SET NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_meals_recipe_id ON meals(recipe_id);
|
||||||
|
`,
|
||||||
};
|
};
|
||||||
|
|
||||||
export { MIGRATIONS_SQL };
|
export { MIGRATIONS_SQL };
|
||||||
|
|||||||
@@ -484,6 +484,45 @@ const MIGRATIONS = [
|
|||||||
ON calendar_events (subscription_id, external_calendar_id);
|
ON calendar_events (subscription_id, external_calendar_id);
|
||||||
`,
|
`,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
version: 13,
|
||||||
|
description: 'Rezepte-Tabelle und Mahlzeiten-Verknuepfung',
|
||||||
|
up: `
|
||||||
|
CREATE TABLE IF NOT EXISTS recipes (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
notes TEXT,
|
||||||
|
recipe_url TEXT,
|
||||||
|
created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS recipe_ingredients (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
recipe_id INTEGER NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
quantity TEXT,
|
||||||
|
category TEXT NOT NULL DEFAULT 'Sonstiges',
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_recipes_title ON recipes(title);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_recipe_ingredients_recipe ON recipe_ingredients(recipe_id);
|
||||||
|
|
||||||
|
CREATE TRIGGER IF NOT EXISTS trg_recipes_updated_at
|
||||||
|
AFTER UPDATE ON recipes FOR EACH ROW
|
||||||
|
BEGIN UPDATE recipes SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = OLD.id; END;
|
||||||
|
|
||||||
|
CREATE TRIGGER IF NOT EXISTS trg_recipe_ingredients_updated_at
|
||||||
|
AFTER UPDATE ON recipe_ingredients FOR EACH ROW
|
||||||
|
BEGIN UPDATE recipe_ingredients SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = OLD.id; END;
|
||||||
|
|
||||||
|
ALTER TABLE meals ADD COLUMN recipe_id INTEGER REFERENCES recipes(id) ON DELETE SET NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_meals_recipe_id ON meals(recipe_id);
|
||||||
|
`,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import dashboardRouter from './routes/dashboard.js';
|
|||||||
import tasksRouter from './routes/tasks.js';
|
import tasksRouter from './routes/tasks.js';
|
||||||
import shoppingRouter from './routes/shopping.js';
|
import shoppingRouter from './routes/shopping.js';
|
||||||
import mealsRouter from './routes/meals.js';
|
import mealsRouter from './routes/meals.js';
|
||||||
|
import recipesRouter from './routes/recipes.js';
|
||||||
import calendarRouter from './routes/calendar.js';
|
import calendarRouter from './routes/calendar.js';
|
||||||
import notesRouter from './routes/notes.js';
|
import notesRouter from './routes/notes.js';
|
||||||
import contactsRouter from './routes/contacts.js';
|
import contactsRouter from './routes/contacts.js';
|
||||||
@@ -172,6 +173,7 @@ app.use('/api/v1/dashboard', dashboardRouter);
|
|||||||
app.use('/api/v1/tasks', tasksRouter);
|
app.use('/api/v1/tasks', tasksRouter);
|
||||||
app.use('/api/v1/shopping', shoppingRouter);
|
app.use('/api/v1/shopping', shoppingRouter);
|
||||||
app.use('/api/v1/meals', mealsRouter);
|
app.use('/api/v1/meals', mealsRouter);
|
||||||
|
app.use('/api/v1/recipes', recipesRouter);
|
||||||
app.use('/api/v1/calendar', calendarRouter);
|
app.use('/api/v1/calendar', calendarRouter);
|
||||||
app.use('/api/v1/notes', notesRouter);
|
app.use('/api/v1/notes', notesRouter);
|
||||||
app.use('/api/v1/contacts', contactsRouter);
|
app.use('/api/v1/contacts', contactsRouter);
|
||||||
|
|||||||
+20
-6
@@ -7,7 +7,7 @@
|
|||||||
import { createLogger } from '../logger.js';
|
import { createLogger } from '../logger.js';
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import * as db from '../db.js';
|
import * as db from '../db.js';
|
||||||
import { str, oneOf, date, collectErrors, MAX_TITLE, MAX_TEXT, MAX_SHORT, DATE_RE } from '../middleware/validate.js';
|
import { str, oneOf, date, num, collectErrors, MAX_TITLE, MAX_TEXT, MAX_SHORT, DATE_RE } from '../middleware/validate.js';
|
||||||
|
|
||||||
const log = createLogger('Meals');
|
const log = createLogger('Meals');
|
||||||
|
|
||||||
@@ -156,15 +156,21 @@ router.post('/', (req, res) => {
|
|||||||
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 vRecipeUrl = str(req.body.recipe_url, 'Rezept-URL', { 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]);
|
const vRecipeId = num(req.body.recipe_id, 'Rezept-ID', { required: false });
|
||||||
|
const errors = collectErrors([vDate, vType, vTitle, vNotes, vRecipeUrl, vRecipeId]);
|
||||||
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 });
|
||||||
|
|
||||||
|
if (vRecipeId.value !== null) {
|
||||||
|
const recipeExists = db.get().prepare('SELECT id FROM recipes WHERE id = ?').get(vRecipeId.value);
|
||||||
|
if (!recipeExists) return res.status(400).json({ error: 'Rezept nicht gefunden.', 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, recipe_url, created_by)
|
INSERT INTO meals (date, meal_type, title, notes, recipe_url, recipe_id, created_by)
|
||||||
VALUES (?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
`).run(vDate.value, vType.value, vTitle.value, vNotes.value, vRecipeUrl.value, req.session.userId);
|
`).run(vDate.value, vType.value, vTitle.value, vNotes.value, vRecipeUrl.value, vRecipeId.value, req.session.userId);
|
||||||
|
|
||||||
const mealId = result.lastInsertRowid;
|
const mealId = result.lastInsertRowid;
|
||||||
|
|
||||||
@@ -217,16 +223,23 @@ router.put('/:id', (req, res) => {
|
|||||||
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 }));
|
if (req.body.recipe_url !== undefined) checks.push(str(req.body.recipe_url, 'Rezept-URL', { max: MAX_TEXT, required: false }));
|
||||||
|
if (req.body.recipe_id !== undefined) checks.push(num(req.body.recipe_id, 'Rezept-ID', { 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 });
|
||||||
|
|
||||||
|
if (req.body.recipe_id !== undefined && req.body.recipe_id !== null && req.body.recipe_id !== '') {
|
||||||
|
const recipeExists = db.get().prepare('SELECT id FROM recipes WHERE id = ?').get(req.body.recipe_id);
|
||||||
|
if (!recipeExists) return res.status(400).json({ error: 'Rezept nicht gefunden.', 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 = ?
|
recipe_url = ?,
|
||||||
|
recipe_id = ?
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`).run(
|
`).run(
|
||||||
req.body.date ?? null,
|
req.body.date ?? null,
|
||||||
@@ -234,6 +247,7 @@ router.put('/:id', (req, res) => {
|
|||||||
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,
|
req.body.recipe_url !== undefined ? (req.body.recipe_url || null) : meal.recipe_url,
|
||||||
|
req.body.recipe_id !== undefined ? (req.body.recipe_id || null) : meal.recipe_id,
|
||||||
id
|
id
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
/**
|
||||||
|
* Modul: Rezepte (Recipes)
|
||||||
|
* Zweck: REST-API-Routen fuer Rezept-CRUD inkl. Zutaten
|
||||||
|
* Abhaengigkeiten: express, server/db.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createLogger } from '../logger.js';
|
||||||
|
import express from 'express';
|
||||||
|
import * as db from '../db.js';
|
||||||
|
import { str, num, collectErrors, MAX_TITLE, MAX_TEXT, MAX_SHORT } from '../middleware/validate.js';
|
||||||
|
|
||||||
|
const log = createLogger('Recipes');
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
function loadRecipeWithIngredients(id) {
|
||||||
|
const recipe = db.get().prepare(`
|
||||||
|
SELECT r.*, u.display_name AS creator_name, u.avatar_color AS creator_color
|
||||||
|
FROM recipes r
|
||||||
|
LEFT JOIN users u ON u.id = r.created_by
|
||||||
|
WHERE r.id = ?
|
||||||
|
`).get(id);
|
||||||
|
|
||||||
|
if (!recipe) return null;
|
||||||
|
|
||||||
|
const ingredients = db.get().prepare(`
|
||||||
|
SELECT * FROM recipe_ingredients
|
||||||
|
WHERE recipe_id = ?
|
||||||
|
ORDER BY id ASC
|
||||||
|
`).all(id);
|
||||||
|
|
||||||
|
return { ...recipe, ingredients };
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get('/', (_req, res) => {
|
||||||
|
try {
|
||||||
|
const recipes = db.get().prepare(`
|
||||||
|
SELECT r.*, u.display_name AS creator_name, u.avatar_color AS creator_color
|
||||||
|
FROM recipes r
|
||||||
|
LEFT JOIN users u ON u.id = r.created_by
|
||||||
|
ORDER BY r.title COLLATE NOCASE ASC, r.id DESC
|
||||||
|
`).all();
|
||||||
|
|
||||||
|
const ids = recipes.map((r) => r.id);
|
||||||
|
let ingredientMap = {};
|
||||||
|
|
||||||
|
if (ids.length > 0) {
|
||||||
|
const placeholders = ids.map(() => '?').join(',');
|
||||||
|
const ingredients = db.get().prepare(`
|
||||||
|
SELECT * FROM recipe_ingredients
|
||||||
|
WHERE recipe_id IN (${placeholders})
|
||||||
|
ORDER BY id ASC
|
||||||
|
`).all(...ids);
|
||||||
|
|
||||||
|
for (const ing of ingredients) {
|
||||||
|
if (!ingredientMap[ing.recipe_id]) ingredientMap[ing.recipe_id] = [];
|
||||||
|
ingredientMap[ing.recipe_id].push(ing);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ data: recipes.map((r) => ({ ...r, ingredients: ingredientMap[r.id] || [] })) });
|
||||||
|
} catch (err) {
|
||||||
|
log.error('GET / Fehler:', err);
|
||||||
|
res.status(500).json({ error: 'Interner Fehler', code: 500 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/', (req, res) => {
|
||||||
|
try {
|
||||||
|
const { ingredients = [] } = req.body;
|
||||||
|
|
||||||
|
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([vTitle, vNotes, vRecipeUrl]);
|
||||||
|
if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 });
|
||||||
|
|
||||||
|
const recipeId = db.transaction(() => {
|
||||||
|
const result = db.get().prepare(`
|
||||||
|
INSERT INTO recipes (title, notes, recipe_url, created_by)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
`).run(vTitle.value, vNotes.value, vRecipeUrl.value, req.session.userId);
|
||||||
|
|
||||||
|
const rid = Number(result.lastInsertRowid);
|
||||||
|
const insertIng = db.get().prepare(`
|
||||||
|
INSERT INTO recipe_ingredients (recipe_id, name, quantity, category)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
`);
|
||||||
|
|
||||||
|
for (const ing of ingredients) {
|
||||||
|
const name = String(ing.name || '').trim().slice(0, MAX_TITLE);
|
||||||
|
const quantity = String(ing.quantity || '').trim().slice(0, MAX_SHORT) || null;
|
||||||
|
const category = String(ing.category || '').trim().slice(0, MAX_SHORT) || 'Sonstiges';
|
||||||
|
if (name) insertIng.run(rid, name, quantity, category);
|
||||||
|
}
|
||||||
|
|
||||||
|
return rid;
|
||||||
|
});
|
||||||
|
|
||||||
|
const created = loadRecipeWithIngredients(recipeId);
|
||||||
|
res.status(201).json({ data: created });
|
||||||
|
} catch (err) {
|
||||||
|
log.error('POST / Fehler:', err);
|
||||||
|
res.status(500).json({ error: 'Interner Fehler', code: 500 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/:id', (req, res) => {
|
||||||
|
try {
|
||||||
|
const id = parseInt(req.params.id, 10);
|
||||||
|
if (!id) return res.status(400).json({ error: 'Ungueltige Rezept-ID', code: 400 });
|
||||||
|
|
||||||
|
const existing = db.get().prepare('SELECT id FROM recipes WHERE id = ?').get(id);
|
||||||
|
if (!existing) return res.status(404).json({ error: 'Rezept nicht gefunden', code: 404 });
|
||||||
|
|
||||||
|
const { ingredients = [] } = req.body;
|
||||||
|
|
||||||
|
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([vTitle, vNotes, vRecipeUrl]);
|
||||||
|
if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 });
|
||||||
|
|
||||||
|
db.transaction(() => {
|
||||||
|
db.get().prepare(`
|
||||||
|
UPDATE recipes
|
||||||
|
SET title = ?, notes = ?, recipe_url = ?
|
||||||
|
WHERE id = ?
|
||||||
|
`).run(vTitle.value, vNotes.value, vRecipeUrl.value, id);
|
||||||
|
|
||||||
|
db.get().prepare('DELETE FROM recipe_ingredients WHERE recipe_id = ?').run(id);
|
||||||
|
|
||||||
|
const insertIng = db.get().prepare(`
|
||||||
|
INSERT INTO recipe_ingredients (recipe_id, name, quantity, category)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
`);
|
||||||
|
|
||||||
|
for (const ing of ingredients) {
|
||||||
|
const name = String(ing.name || '').trim().slice(0, MAX_TITLE);
|
||||||
|
const quantity = String(ing.quantity || '').trim().slice(0, MAX_SHORT) || null;
|
||||||
|
const category = String(ing.category || '').trim().slice(0, MAX_SHORT) || 'Sonstiges';
|
||||||
|
if (name) insertIng.run(id, name, quantity, category);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const updated = loadRecipeWithIngredients(id);
|
||||||
|
res.json({ data: updated });
|
||||||
|
} catch (err) {
|
||||||
|
log.error('PUT /:id Fehler:', err);
|
||||||
|
res.status(500).json({ error: 'Interner Fehler', code: 500 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/:id', (req, res) => {
|
||||||
|
try {
|
||||||
|
const id = parseInt(req.params.id, 10);
|
||||||
|
const existing = num(id, 'Rezept-ID', { required: true });
|
||||||
|
if (existing.error) return res.status(400).json({ error: existing.error, code: 400 });
|
||||||
|
|
||||||
|
const result = db.get().prepare('DELETE FROM recipes WHERE id = ?').run(id);
|
||||||
|
if (result.changes === 0) return res.status(404).json({ error: 'Rezept nicht gefunden', code: 404 });
|
||||||
|
|
||||||
|
res.status(204).end();
|
||||||
|
} catch (err) {
|
||||||
|
log.error('DELETE /:id Fehler:', err);
|
||||||
|
res.status(500).json({ error: 'Interner Fehler', code: 500 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
Reference in New Issue
Block a user