add English translation support for recipes with DeepL integration

- Add embedded translations schema to Recipe model with English support
- Create DeepL translation service with batch translation and change detection
- Build translation approval UI with side-by-side editing for all recipe fields
- Integrate translation workflow into add/edit pages with field comparison
- Create complete English recipe routes at /recipes/* mirroring German structure
- Add language switcher component with hreflang SEO tags
- Support image loading from German short_name for English recipes
- Add English API endpoints for all recipe filters (category, tag, icon, season)
- Include layout with English navigation header for all recipe subroutes
This commit is contained in:
2025-12-26 20:28:43 +01:00
parent f6b432691d
commit 1f16d1c5c9
34 changed files with 3061 additions and 44 deletions
@@ -0,0 +1,96 @@
import type { RequestHandler } from '@sveltejs/kit';
import { Recipe } from '../../../../../models/Recipe';
import { dbConnect } from '../../../../../utils/db';
import { error } from '@sveltejs/kit';
/**
* GET /api/recipes/items/[name]
* Fetch an English recipe by its English short_name
*/
export const GET: RequestHandler = async ({ params }) => {
await dbConnect();
try {
// Find recipe by English short_name
const recipe = await Recipe.findOne({
"translations.en.short_name": params.name
});
if (!recipe) {
throw error(404, 'Recipe not found');
}
if (!recipe.translations?.en) {
throw error(404, 'English translation not available for this recipe');
}
// Return English translation with necessary metadata
const englishRecipe = {
_id: recipe._id,
short_name: recipe.translations.en.short_name,
name: recipe.translations.en.name,
description: recipe.translations.en.description,
preamble: recipe.translations.en.preamble || '',
addendum: recipe.translations.en.addendum || '',
note: recipe.translations.en.note || '',
category: recipe.translations.en.category,
tags: recipe.translations.en.tags || [],
ingredients: recipe.translations.en.ingredients || [],
instructions: recipe.translations.en.instructions || [],
images: recipe.images || [], // Use original images with full paths, but English alt/captions
// Copy timing/metadata from German version (with defaults)
icon: recipe.icon || '',
dateCreated: recipe.dateCreated,
dateModified: recipe.dateModified,
season: recipe.season || [],
baking: recipe.baking || { temperature: '', length: '', mode: '' },
preparation: recipe.preparation || '',
fermentation: recipe.fermentation || { bulk: '', final: '' },
portions: recipe.portions || '',
cooking: recipe.cooking || '',
total_time: recipe.total_time || '',
// Include translation status for display
translationStatus: recipe.translations.en.translationStatus,
// Include German short_name for language switcher
germanShortName: recipe.short_name,
};
// Merge English alt/caption with original image paths
// Handle both array and single object (there's a bug in add page that sometimes saves as object)
const imagesArray = Array.isArray(recipe.images) ? recipe.images : (recipe.images ? [recipe.images] : []);
if (imagesArray.length > 0) {
const translatedImages = recipe.translations.en.images || [];
if (translatedImages.length > 0) {
englishRecipe.images = imagesArray.map((img: any, index: number) => ({
mediapath: img.mediapath,
alt: translatedImages[index]?.alt || img.alt || '',
caption: translatedImages[index]?.caption || img.caption || '',
}));
} else {
// No translated image captions, use German ones
englishRecipe.images = imagesArray.map((img: any) => ({
mediapath: img.mediapath,
alt: img.alt || '',
caption: img.caption || '',
}));
}
}
return new Response(JSON.stringify(englishRecipe), {
status: 200,
headers: {
'Content-Type': 'application/json',
},
});
} catch (err: any) {
console.error('Error fetching English recipe:', err);
if (err.status) {
throw err;
}
throw error(500, 'Failed to fetch recipe');
}
};
@@ -0,0 +1,31 @@
import { json, type RequestHandler } from '@sveltejs/kit';
import type { BriefRecipeType } from '../../../../../types/types';
import { Recipe } from '../../../../../models/Recipe'
import { dbConnect } from '../../../../../utils/db';
import { rand_array } from '$lib/js/randomize';
export const GET: RequestHandler = async ({params}) => {
await dbConnect();
// Find all recipes that have English translations
const recipes = await Recipe.find(
{ 'translations.en': { $exists: true } },
'_id translations.en short_name season dateModified icon'
).lean();
// Map to brief format with English data
const found_brief = recipes.map((recipe: any) => ({
_id: recipe._id,
name: recipe.translations.en.name,
short_name: recipe.translations.en.short_name,
tags: recipe.translations.en.tags || [],
category: recipe.translations.en.category,
icon: recipe.icon,
description: recipe.translations.en.description,
season: recipe.season || [],
dateModified: recipe.dateModified,
germanShortName: recipe.short_name // For language switcher
})) as BriefRecipeType[];
return json(JSON.parse(JSON.stringify(rand_array(found_brief))));
};
@@ -0,0 +1,35 @@
import { json, type RequestHandler } from '@sveltejs/kit';
import { Recipe } from '../../../../../../models/Recipe';
import { dbConnect } from '../../../../../../utils/db';
import type {BriefRecipeType} from '../../../../../../types/types';
import { rand_array } from '$lib/js/randomize';
export const GET: RequestHandler = async ({params}) => {
await dbConnect();
// Find recipes in this category that have English translations
const recipes = await Recipe.find(
{
'translations.en.category': params.category,
'translations.en': { $exists: true }
},
'_id translations.en short_name images season dateModified icon'
).lean();
// Map to brief format with English data
const englishRecipes = recipes.map((recipe: any) => ({
_id: recipe._id,
name: recipe.translations.en.name,
short_name: recipe.translations.en.short_name,
images: recipe.images || [],
tags: recipe.translations.en.tags || [],
category: recipe.translations.en.category,
icon: recipe.icon,
description: recipe.translations.en.description,
season: recipe.season || [],
dateModified: recipe.dateModified,
germanShortName: recipe.short_name
})) as BriefRecipeType[];
return json(JSON.parse(JSON.stringify(rand_array(englishRecipes))));
};
@@ -0,0 +1,35 @@
import { json, type RequestHandler } from '@sveltejs/kit';
import { Recipe } from '../../../../../../models/Recipe';
import { dbConnect } from '../../../../../../utils/db';
import type {BriefRecipeType} from '../../../../../../types/types';
import { rand_array } from '$lib/js/randomize';
export const GET: RequestHandler = async ({params}) => {
await dbConnect();
// Find recipes with this icon that have English translations
const recipes = await Recipe.find(
{
icon: params.icon,
'translations.en': { $exists: true }
},
'_id translations.en short_name images season dateModified icon'
).lean();
// Map to brief format with English data
const englishRecipes = recipes.map((recipe: any) => ({
_id: recipe._id,
name: recipe.translations.en.name,
short_name: recipe.translations.en.short_name,
images: recipe.images || [],
tags: recipe.translations.en.tags || [],
category: recipe.translations.en.category,
icon: recipe.icon,
description: recipe.translations.en.description,
season: recipe.season || [],
dateModified: recipe.dateModified,
germanShortName: recipe.short_name
})) as BriefRecipeType[];
return json(JSON.parse(JSON.stringify(rand_array(englishRecipes))));
};
@@ -0,0 +1,35 @@
import { json, type RequestHandler } from '@sveltejs/kit';
import { Recipe } from '../../../../../../models/Recipe'
import { dbConnect } from '../../../../../../utils/db';
import { rand_array } from '$lib/js/randomize';
export const GET: RequestHandler = async ({params}) => {
await dbConnect();
// Find recipes in season that have English translations
const recipes = await Recipe.find(
{
season: params.month,
icon: {$ne: "🍽️"},
'translations.en': { $exists: true }
},
'_id translations.en short_name images season dateModified icon'
).lean();
// Map to format with English data
const found_in_season = recipes.map((recipe: any) => ({
_id: recipe._id,
name: recipe.translations.en.name,
short_name: recipe.translations.en.short_name,
images: recipe.images || [],
tags: recipe.translations.en.tags || [],
category: recipe.translations.en.category,
icon: recipe.icon,
description: recipe.translations.en.description,
season: recipe.season || [],
dateModified: recipe.dateModified,
germanShortName: recipe.short_name // For language switcher
}));
return json(JSON.parse(JSON.stringify(rand_array(found_in_season))));
};
@@ -0,0 +1,35 @@
import { json, type RequestHandler } from '@sveltejs/kit';
import { Recipe } from '../../../../../../models/Recipe';
import { dbConnect } from '../../../../../../utils/db';
import type {BriefRecipeType} from '../../../../../../types/types';
import { rand_array } from '$lib/js/randomize';
export const GET: RequestHandler = async ({params}) => {
await dbConnect();
// Find recipes with this tag that have English translations
const recipes = await Recipe.find(
{
'translations.en.tags': params.tag,
'translations.en': { $exists: true }
},
'_id translations.en short_name images season dateModified icon'
).lean();
// Map to brief format with English data
const englishRecipes = recipes.map((recipe: any) => ({
_id: recipe._id,
name: recipe.translations.en.name,
short_name: recipe.translations.en.short_name,
images: recipe.images || [],
tags: recipe.translations.en.tags || [],
category: recipe.translations.en.category,
icon: recipe.icon,
description: recipe.translations.en.description,
season: recipe.season || [],
dateModified: recipe.dateModified,
germanShortName: recipe.short_name
})) as BriefRecipeType[];
return json(JSON.parse(JSON.stringify(rand_array(englishRecipes))));
};
@@ -0,0 +1,88 @@
import { json, error } from '@sveltejs/kit';
import { translationService } from '$lib/../utils/translation';
import type { RequestHandler } from './$types';
/**
* POST /api/rezepte/translate
* Translates recipe data from German to English using DeepL API
*
* Request body:
* - recipe: Recipe object with German content
* - fields?: Optional array of specific fields to translate (for partial updates)
*
* Response:
* - translatedRecipe: Translated recipe data
*/
export const POST: RequestHandler = async ({ request }) => {
try {
const body = await request.json();
const { recipe, fields } = body;
if (!recipe) {
throw error(400, 'Recipe data is required');
}
// Validate that recipe has required fields
if (!recipe.name || !recipe.description) {
throw error(400, 'Recipe must have at least name and description');
}
let translatedRecipe;
// If specific fields are provided, translate only those
if (fields && Array.isArray(fields) && fields.length > 0) {
translatedRecipe = await translationService.translateFields(recipe, fields);
} else {
// Translate entire recipe
translatedRecipe = await translationService.translateRecipe(recipe);
}
return json({
success: true,
translatedRecipe,
});
} catch (err: any) {
console.error('Translation API error:', err);
// Handle specific error cases
if (err.message?.includes('DeepL API')) {
throw error(503, `Translation service error: ${err.message}`);
}
if (err.message?.includes('API key not configured')) {
throw error(500, 'Translation service is not configured. Please add DEEPL_API_KEY to environment variables.');
}
// Re-throw SvelteKit errors
if (err.status) {
throw err;
}
// Generic error
throw error(500, `Translation failed: ${err.message || 'Unknown error'}`);
}
};
/**
* GET /api/rezepte/translate/health
* Health check endpoint to verify translation service is configured
*/
export const GET: RequestHandler = async () => {
try {
// Simple check to verify API key is configured
const isConfigured = process.env.DEEPL_API_KEY ? true : false;
return json({
configured: isConfigured,
service: 'DeepL Translation API',
status: isConfigured ? 'ready' : 'not configured',
});
} catch (err: any) {
return json({
configured: false,
status: 'error',
error: err.message,
}, { status: 500 });
}
};