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:
@@ -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 });
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user