From 0b54fe255b6442f3dee5bd07e06345a674fc4312 Mon Sep 17 00:00:00 2001 From: Serhiy Bobrov Date: Tue, 21 Apr 2026 13:43:42 +0300 Subject: [PATCH] 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. --- public/locales/ar.json | 33 +++- public/locales/de.json | 33 +++- public/locales/el.json | 33 +++- public/locales/en.json | 33 +++- public/locales/es.json | 33 +++- public/locales/fr.json | 33 +++- public/locales/hi.json | 33 +++- public/locales/it.json | 33 +++- public/locales/ja.json | 33 +++- public/locales/pt.json | 33 +++- public/locales/ru.json | 33 +++- public/locales/sv.json | 33 +++- public/locales/tr.json | 33 +++- public/locales/uk.json | 33 +++- public/locales/zh.json | 33 +++- public/pages/meals.js | 205 +++++++++++++++++++-- public/pages/recipes.js | 372 ++++++++++++++++++++++++++++++++++++++ public/router.js | 4 +- public/styles/recipes.css | 119 ++++++++++++ public/styles/tokens.css | 2 + server/db-schema-test.js | 35 ++++ server/db.js | 39 ++++ server/index.js | 2 + server/routes/meals.js | 26 ++- server/routes/recipes.js | 170 +++++++++++++++++ 25 files changed, 1421 insertions(+), 48 deletions(-) create mode 100644 public/pages/recipes.js create mode 100644 public/styles/recipes.css create mode 100644 server/routes/recipes.js diff --git a/public/locales/ar.json b/public/locales/ar.json index c4a3a40..33ad1f2 100644 --- a/public/locales/ar.json +++ b/public/locales/ar.json @@ -42,7 +42,8 @@ "settings": "الإعدادات", "main": "القائمة الرئيسية", "navigation": "التنقل", - "quickActions": "الإجراءات السريعة" + "quickActions": "الإجراءات السريعة", + "recipes": "Recipes" }, "dashboard": { "title": "لوحة التحكم", @@ -242,7 +243,11 @@ "loadingIndicator": "جارٍ التحميل…", "recipeUrlLabel": "رابط الوصفة (اختياري)", "recipeUrlPlaceholder": "https://…", - "openRecipe": "فتح الوصفة" + "openRecipe": "فتح الوصفة", + "savedRecipeLabel": "Saved recipe", + "savedRecipePlaceholder": "Select recipe", + "saveAsRecipe": "Save as recipe", + "recipeScaleLabel": "Scale ingredients" }, "calendar": { "title": "التقويم", @@ -604,5 +609,29 @@ "unitWeeks": "أسابيع", "unitMonth": "شهر", "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" } } diff --git a/public/locales/de.json b/public/locales/de.json index 74f0e09..c4ff9bc 100644 --- a/public/locales/de.json +++ b/public/locales/de.json @@ -43,7 +43,8 @@ "main": "Hauptnavigation", "navigation": "Navigation", "quickActions": "Schnellaktionen", - "more": "Mehr" + "more": "Mehr", + "recipes": "Recipes" }, "search": { "title": "Suche", @@ -256,7 +257,11 @@ "loadingIndicator": "Lade…", "recipeUrlLabel": "Rezept-Link (optional)", "recipeUrlPlaceholder": "https://…", - "openRecipe": "Rezept öffnen" + "openRecipe": "Rezept öffnen", + "savedRecipeLabel": "Saved recipe", + "savedRecipePlaceholder": "Select recipe", + "saveAsRecipe": "Save as recipe", + "recipeScaleLabel": "Scale ingredients" }, "calendar": { "title": "Kalender", @@ -676,5 +681,29 @@ "notificationHint": "Erhalte Benachrichtigungen auch wenn die App geöffnet ist.", "pendingBadgeTitle": "{{count}} fällige Erinnerung", "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" } } diff --git a/public/locales/el.json b/public/locales/el.json index ffff781..3c33441 100644 --- a/public/locales/el.json +++ b/public/locales/el.json @@ -42,7 +42,8 @@ "settings": "Ρυθμίσεις", "main": "Κύρια πλοήγηση", "navigation": "Πλοήγηση", - "quickActions": "Γρήγορες ενέργειες" + "quickActions": "Γρήγορες ενέργειες", + "recipes": "Recipes" }, "dashboard": { "title": "Επισκόπηση", @@ -242,7 +243,11 @@ "loadingIndicator": "Φόρτωση…", "recipeUrlLabel": "Σύνδεσμος συνταγής (προαιρετικό)", "recipeUrlPlaceholder": "https://…", - "openRecipe": "Άνοιγμα συνταγής" + "openRecipe": "Άνοιγμα συνταγής", + "savedRecipeLabel": "Saved recipe", + "savedRecipePlaceholder": "Select recipe", + "saveAsRecipe": "Save as recipe", + "recipeScaleLabel": "Scale ingredients" }, "calendar": { "title": "Ημερολόγιο", @@ -604,5 +609,29 @@ "unitWeeks": "εβδομάδες", "unitMonth": "μήνα", "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" } } diff --git a/public/locales/en.json b/public/locales/en.json index 873e2f1..9148647 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -42,7 +42,8 @@ "settings": "Settings", "main": "Main navigation", "navigation": "Navigation", - "quickActions": "Quick actions" + "quickActions": "Quick actions", + "recipes": "Recipes" }, "dashboard": { "title": "Overview", @@ -242,7 +243,11 @@ "loadingIndicator": "Loading…", "recipeUrlLabel": "Recipe link (optional)", "recipeUrlPlaceholder": "https://…", - "openRecipe": "Open recipe" + "openRecipe": "Open recipe", + "savedRecipeLabel": "Saved recipe", + "savedRecipePlaceholder": "Select recipe", + "saveAsRecipe": "Save as recipe", + "recipeScaleLabel": "Scale ingredients" }, "calendar": { "title": "Calendar", @@ -625,5 +630,29 @@ "notificationHint": "Receive notifications while the app is open.", "pendingBadgeTitle": "{{count}} reminder 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" } } diff --git a/public/locales/es.json b/public/locales/es.json index 2149472..163c657 100644 --- a/public/locales/es.json +++ b/public/locales/es.json @@ -42,7 +42,8 @@ "settings": "Ajustes", "main": "Navegación principal", "navigation": "Navegación", - "quickActions": "Acciones rápidas" + "quickActions": "Acciones rápidas", + "recipes": "Recipes" }, "dashboard": { "title": "Inicio", @@ -242,7 +243,11 @@ "loadingIndicator": "Cargando…", "recipeUrlLabel": "Enlace a la receta (opcional)", "recipeUrlPlaceholder": "https://…", - "openRecipe": "Abrir receta" + "openRecipe": "Abrir receta", + "savedRecipeLabel": "Saved recipe", + "savedRecipePlaceholder": "Select recipe", + "saveAsRecipe": "Save as recipe", + "recipeScaleLabel": "Scale ingredients" }, "calendar": { "title": "Calendario", @@ -604,5 +609,29 @@ "unitWeeks": "semanas", "unitMonth": "mes", "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" } } diff --git a/public/locales/fr.json b/public/locales/fr.json index 3b606ca..1fc10d2 100644 --- a/public/locales/fr.json +++ b/public/locales/fr.json @@ -42,7 +42,8 @@ "settings": "Paramètres", "main": "Navigation principale", "navigation": "Navigation", - "quickActions": "Actions rapides" + "quickActions": "Actions rapides", + "recipes": "Recipes" }, "dashboard": { "title": "Accueil", @@ -242,7 +243,11 @@ "loadingIndicator": "Chargement…", "recipeUrlLabel": "Lien recette (optionnel)", "recipeUrlPlaceholder": "https://…", - "openRecipe": "Ouvrir la recette" + "openRecipe": "Ouvrir la recette", + "savedRecipeLabel": "Saved recipe", + "savedRecipePlaceholder": "Select recipe", + "saveAsRecipe": "Save as recipe", + "recipeScaleLabel": "Scale ingredients" }, "calendar": { "title": "Calendrier", @@ -604,5 +609,29 @@ "unitWeeks": "semaines", "unitMonth": "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" } } diff --git a/public/locales/hi.json b/public/locales/hi.json index 6e083ad..f4f0c55 100644 --- a/public/locales/hi.json +++ b/public/locales/hi.json @@ -42,7 +42,8 @@ "settings": "सेटिंग्स", "main": "मुख्य नेविगेशन", "navigation": "नेविगेशन", - "quickActions": "त्वरित क्रियाएं" + "quickActions": "त्वरित क्रियाएं", + "recipes": "Recipes" }, "dashboard": { "title": "डैशबोर्ड", @@ -242,7 +243,11 @@ "loadingIndicator": "लोड हो रहा है…", "recipeUrlLabel": "रेसिपी लिंक (वैकल्पिक)", "recipeUrlPlaceholder": "https://…", - "openRecipe": "रेसिपी खोलें" + "openRecipe": "रेसिपी खोलें", + "savedRecipeLabel": "Saved recipe", + "savedRecipePlaceholder": "Select recipe", + "saveAsRecipe": "Save as recipe", + "recipeScaleLabel": "Scale ingredients" }, "calendar": { "title": "कैलेंडर", @@ -604,5 +609,29 @@ "unitWeeks": "सप्ताह", "unitMonth": "माह", "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" } } diff --git a/public/locales/it.json b/public/locales/it.json index 49869a2..81ee7ce 100644 --- a/public/locales/it.json +++ b/public/locales/it.json @@ -42,7 +42,8 @@ "settings": "Impostazioni", "main": "Navigazione principale", "navigation": "Navigazione", - "quickActions": "Azioni rapide" + "quickActions": "Azioni rapide", + "recipes": "Recipes" }, "dashboard": { "title": "Panoramica", @@ -242,7 +243,11 @@ "loadingIndicator": "Caricamento…", "recipeUrlLabel": "Link ricetta (opzionale)", "recipeUrlPlaceholder": "https://…", - "openRecipe": "Apri ricetta" + "openRecipe": "Apri ricetta", + "savedRecipeLabel": "Saved recipe", + "savedRecipePlaceholder": "Select recipe", + "saveAsRecipe": "Save as recipe", + "recipeScaleLabel": "Scale ingredients" }, "calendar": { "title": "Calendario", @@ -604,5 +609,29 @@ "unitWeeks": "settimane", "unitMonth": "mese", "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" } } diff --git a/public/locales/ja.json b/public/locales/ja.json index 93c600f..53f94b5 100644 --- a/public/locales/ja.json +++ b/public/locales/ja.json @@ -42,7 +42,8 @@ "settings": "設定", "main": "メインナビゲーション", "navigation": "ナビゲーション", - "quickActions": "クイックアクション" + "quickActions": "クイックアクション", + "recipes": "Recipes" }, "dashboard": { "title": "ダッシュボード", @@ -242,7 +243,11 @@ "loadingIndicator": "読み込み中…", "recipeUrlLabel": "レシピリンク(任意)", "recipeUrlPlaceholder": "https://…", - "openRecipe": "レシピを開く" + "openRecipe": "レシピを開く", + "savedRecipeLabel": "Saved recipe", + "savedRecipePlaceholder": "Select recipe", + "saveAsRecipe": "Save as recipe", + "recipeScaleLabel": "Scale ingredients" }, "calendar": { "title": "カレンダー", @@ -604,5 +609,29 @@ "unitWeeks": "週", "unitMonth": "ヶ月", "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" } } diff --git a/public/locales/pt.json b/public/locales/pt.json index 3ed43e9..8afd852 100644 --- a/public/locales/pt.json +++ b/public/locales/pt.json @@ -42,7 +42,8 @@ "settings": "Configurações", "main": "Navegação principal", "navigation": "Navegação", - "quickActions": "Ações rápidas" + "quickActions": "Ações rápidas", + "recipes": "Recipes" }, "dashboard": { "title": "Painel", @@ -242,7 +243,11 @@ "loadingIndicator": "Carregando…", "recipeUrlLabel": "Link da receita (opcional)", "recipeUrlPlaceholder": "https://…", - "openRecipe": "Abrir receita" + "openRecipe": "Abrir receita", + "savedRecipeLabel": "Saved recipe", + "savedRecipePlaceholder": "Select recipe", + "saveAsRecipe": "Save as recipe", + "recipeScaleLabel": "Scale ingredients" }, "calendar": { "title": "Calendário", @@ -604,5 +609,29 @@ "unitWeeks": "semanas", "unitMonth": "mês", "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" } } diff --git a/public/locales/ru.json b/public/locales/ru.json index 519ccc6..e51822c 100644 --- a/public/locales/ru.json +++ b/public/locales/ru.json @@ -42,7 +42,8 @@ "settings": "Настройки", "main": "Главная навигация", "navigation": "Навигация", - "quickActions": "Быстрые действия" + "quickActions": "Быстрые действия", + "recipes": "Recipes" }, "dashboard": { "title": "Обзор", @@ -242,7 +243,11 @@ "loadingIndicator": "Загрузка…", "recipeUrlLabel": "Ссылка на рецепт (необязательно)", "recipeUrlPlaceholder": "https://…", - "openRecipe": "Открыть рецепт" + "openRecipe": "Открыть рецепт", + "savedRecipeLabel": "Saved recipe", + "savedRecipePlaceholder": "Select recipe", + "saveAsRecipe": "Save as recipe", + "recipeScaleLabel": "Scale ingredients" }, "calendar": { "title": "Календарь", @@ -604,5 +609,29 @@ "unitWeeks": "недель", "unitMonth": "месяц", "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" } } diff --git a/public/locales/sv.json b/public/locales/sv.json index 1cb208b..dab8915 100644 --- a/public/locales/sv.json +++ b/public/locales/sv.json @@ -42,7 +42,8 @@ "settings": "Inställningar", "main": "Huvudnavigering", "navigation": "Navigering", - "quickActions": "Snabba åtgärder" + "quickActions": "Snabba åtgärder", + "recipes": "Recipes" }, "dashboard": { "title": "Översikt", @@ -242,7 +243,11 @@ "loadingIndicator": "Laddar…", "recipeUrlLabel": "Receptlänk (valfri)", "recipeUrlPlaceholder": "https://…", - "openRecipe": "Öppna recept" + "openRecipe": "Öppna recept", + "savedRecipeLabel": "Saved recipe", + "savedRecipePlaceholder": "Select recipe", + "saveAsRecipe": "Save as recipe", + "recipeScaleLabel": "Scale ingredients" }, "calendar": { "title": "Kalender", @@ -604,5 +609,29 @@ "unitWeeks": "veckor", "unitMonth": "månad", "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" } } diff --git a/public/locales/tr.json b/public/locales/tr.json index fc81daf..4e4a712 100644 --- a/public/locales/tr.json +++ b/public/locales/tr.json @@ -42,7 +42,8 @@ "settings": "Ayarlar", "main": "Ana gezinme", "navigation": "Gezinme", - "quickActions": "Hızlı işlemler" + "quickActions": "Hızlı işlemler", + "recipes": "Recipes" }, "dashboard": { "title": "Genel Bakış", @@ -242,7 +243,11 @@ "loadingIndicator": "Yükleniyor…", "recipeUrlLabel": "Tarif bağlantısı (isteğe bağlı)", "recipeUrlPlaceholder": "https://…", - "openRecipe": "Tarifi aç" + "openRecipe": "Tarifi aç", + "savedRecipeLabel": "Saved recipe", + "savedRecipePlaceholder": "Select recipe", + "saveAsRecipe": "Save as recipe", + "recipeScaleLabel": "Scale ingredients" }, "calendar": { "title": "Takvim", @@ -604,5 +609,29 @@ "unitWeeks": "hafta", "unitMonth": "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" } } diff --git a/public/locales/uk.json b/public/locales/uk.json index d87d355..452b350 100644 --- a/public/locales/uk.json +++ b/public/locales/uk.json @@ -42,7 +42,8 @@ "settings": "Налаштування", "main": "Головна навігація", "navigation": "Навігація", - "quickActions": "Швидкі дії" + "quickActions": "Швидкі дії", + "recipes": "Recipes" }, "dashboard": { "title": "Огляд", @@ -242,7 +243,11 @@ "loadingIndicator": "Завантаження…", "recipeUrlLabel": "Посилання на рецепт (необов'язково)", "recipeUrlPlaceholder": "https://…", - "openRecipe": "Відкрити рецепт" + "openRecipe": "Відкрити рецепт", + "savedRecipeLabel": "Saved recipe", + "savedRecipePlaceholder": "Select recipe", + "saveAsRecipe": "Save as recipe", + "recipeScaleLabel": "Scale ingredients" }, "calendar": { "title": "Календар", @@ -625,5 +630,29 @@ "notificationHint": "Отримуйте сповіщення, поки додаток відкрито.", "pendingBadgeTitle": "{{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" } } diff --git a/public/locales/zh.json b/public/locales/zh.json index 4062696..4c18d63 100644 --- a/public/locales/zh.json +++ b/public/locales/zh.json @@ -42,7 +42,8 @@ "settings": "设置", "main": "主导航", "navigation": "导航", - "quickActions": "快捷操作" + "quickActions": "快捷操作", + "recipes": "Recipes" }, "dashboard": { "title": "概览", @@ -242,7 +243,11 @@ "loadingIndicator": "加载中…", "recipeUrlLabel": "食谱链接(可选)", "recipeUrlPlaceholder": "https://…", - "openRecipe": "打开食谱" + "openRecipe": "打开食谱", + "savedRecipeLabel": "Saved recipe", + "savedRecipePlaceholder": "Select recipe", + "saveAsRecipe": "Save as recipe", + "recipeScaleLabel": "Scale ingredients" }, "calendar": { "title": "日历", @@ -604,5 +609,29 @@ "unitWeeks": "周", "unitMonth": "个月", "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" } } diff --git a/public/pages/meals.js b/public/pages/meals.js index 77c63ac..6b0e50f 100644 --- a/public/pages/meals.js +++ b/public/pages/meals.js @@ -36,6 +36,7 @@ const EXCLUDED_MEAL_CATEGORY_NAMES = new Set(['Haushalt', 'Drogerie']); let state = { currentWeek: null, // YYYY-MM-DD (Montag) meals: [], + recipes: [], lists: [], // Einkaufslisten für Transfer-Dropdown categories: [], // Einkaufskategorien für Zutaten 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() { try { 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 monday = getMondayOf(today); - await Promise.all([loadWeek(monday), loadLists(), loadPreferences(), loadCategories()]); + await Promise.all([loadWeek(monday), loadLists(), loadPreferences(), loadCategories(), loadRecipes()]); renderWeekGrid(); 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', () => { const firstType = state.visibleMealTypes[0] ?? 'lunch'; openMealModal({ mode: 'create', date: today, mealType: firstType }); @@ -473,7 +492,7 @@ async function moveMeal(mealId, sourceDate, sourceType, targetDate, targetType, function openMealModal(opts) { state.modal = opts; - const { mode, date, mealType, meal } = opts; + const { mode, date, mealType, meal, presetRecipeId = null } = opts; const isEdit = mode === 'edit'; const content = buildModalContent(opts); @@ -523,6 +542,144 @@ function openMealModal(opts) { // Zutaten const ingList = panel.querySelector('#ingredient-list'); 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', () => { 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 recipeOptions = [ + ``, + ...state.recipes.map((r) => ``), + ].join(''); + return `