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 731adda897
commit 36a7fac39a
34 changed files with 3061 additions and 44 deletions

View File

@@ -13,6 +13,7 @@
import RecipeNote from '$lib/components/RecipeNote.svelte';
import {stripHtmlTags} from '$lib/js/stripHtmlTags';
import FavoriteButton from '$lib/components/FavoriteButton.svelte';
import RecipeLanguageSwitcher from '$lib/components/RecipeLanguageSwitcher.svelte';
export let data: PageData;
@@ -278,8 +279,23 @@ h4{
<meta property="og:image:type" content="image/webp" />
<meta property="og:image:alt" content="{stripHtmlTags(data.name)}" />
{@html `<script type="application/ld+json">${JSON.stringify(data.recipeJsonLd)}</script>`}
<!-- SEO: hreflang tags -->
<link rel="alternate" hreflang="de" href="https://bocken.org/rezepte/{data.short_name}" />
{#if data.hasEnglishTranslation}
<link rel="alternate" hreflang="en" href="https://bocken.org/recipes/{data.englishShortName}" />
{/if}
<link rel="alternate" hreflang="x-default" href="https://bocken.org/rezepte/{data.short_name}" />
</svelte:head>
{#if data.hasEnglishTranslation}
<RecipeLanguageSwitcher
germanUrl="/rezepte/{data.short_name}"
englishUrl="/recipes/{data.englishShortName}"
currentLang="de"
hasTranslation={true}
/>
{/if}
<TitleImgParallax src={hero_img_src} {placeholder_src}>
<div class=title>
<a class="category" href='/rezepte/category/{data.category}'>{data.category}</a>

View File

@@ -104,11 +104,17 @@ export async function load({ fetch, params, url}) {
// Generate JSON-LD server-side
const recipeJsonLd = generateRecipeJsonLd(item);
// Check if English translation exists
const hasEnglishTranslation = !!(item.translations?.en?.short_name);
const englishShortName = item.translations?.en?.short_name || '';
return {
...item,
isFavorite,
multiplier,
recipeJsonLd
recipeJsonLd,
hasEnglishTranslation,
englishShortName,
};
}

View File

@@ -1,12 +1,17 @@
<script lang="ts">
import Check from '$lib/assets/icons/Check.svelte';
import SeasonSelect from '$lib/components/SeasonSelect.svelte';
import TranslationApproval from '$lib/components/TranslationApproval.svelte';
import '$lib/css/action_button.css'
import '$lib/css/nordtheme.css'
let preamble = ""
let addendum = ""
// Translation workflow state
let showTranslationWorkflow = false;
let translationData: any = null;
import { season } from '$lib/js/season_store';
import { portions } from '$lib/js/portions_store';
import { img } from '$lib/js/img_store';
@@ -98,32 +103,89 @@
});
}
async function doPost () {
// Prepare the German recipe data
function getGermanRecipeData() {
return {
...card_data,
...add_info,
images: {mediapath: short_name.trim() + '.webp', alt: "", caption: ""},
season: season_local,
short_name : short_name.trim(),
portions: portions_local,
datecreated,
datemodified,
instructions,
ingredients,
preamble,
addendum,
};
}
// Show translation workflow before submission
function prepareSubmit() {
// Validate required fields
if (!short_name.trim()) {
alert('Bitte geben Sie einen Kurznamen ein');
return;
}
if (!card_data.name) {
alert('Bitte geben Sie einen Namen ein');
return;
}
showTranslationWorkflow = true;
// Scroll to translation section
setTimeout(() => {
document.getElementById('translation-section')?.scrollIntoView({ behavior: 'smooth' });
}, 100);
}
// Handle translation approval
function handleTranslationApproved(event: CustomEvent) {
translationData = event.detail.translatedRecipe;
doPost();
}
// Handle translation skipped
function handleTranslationSkipped() {
translationData = null;
doPost();
}
// Handle translation cancelled
function handleTranslationCancelled() {
showTranslationWorkflow = false;
translationData = null;
}
// Actually submit the recipe
async function doPost () {
upload_img()
console.log(add_info.total_time)
const recipeData = getGermanRecipeData();
// Add translations if available
if (translationData) {
recipeData.translations = {
en: translationData
};
recipeData.translationMetadata = {
lastModifiedGerman: new Date(),
fieldsModifiedSinceTranslation: [],
};
}
const res = await fetch('/api/rezepte/add', {
method: 'POST',
body: JSON.stringify({
recipe: {
...card_data,
...add_info,
images: {mediapath: short_name.trim() + '.webp', alt: "", caption: ""}, // TODO
season: season_local,
short_name : short_name.trim(),
portions: portions_local,
datecreated,
datemodified,
instructions,
ingredients,
preamble,
addendum,
},
recipe: recipeData,
}),
headers: {
'content-type': 'application/json',
}
})
}
});
if(res.status === 200){
const url = location.href.split('/')
url.splice(url.length -1, 1);
@@ -282,6 +344,19 @@ button.action_button{
<div class=addendum bind:innerText={addendum} contenteditable></div>
</div>
{#if !showTranslationWorkflow}
<div class=submit_buttons>
<button class=action_button on:click={doPost}><p>Hinzufügen</p><Check fill=white width=2rem height=2rem></Check></button>
<button class=action_button on:click={prepareSubmit}><p>Weiter zur Übersetzung</p><Check fill=white width=2rem height=2rem></Check></button>
</div>
{/if}
{#if showTranslationWorkflow}
<div id="translation-section">
<TranslationApproval
germanData={getGermanRecipeData()}
on:approved={handleTranslationApproved}
on:skipped={handleTranslationSkipped}
on:cancelled={handleTranslationCancelled}
/>
</div>
{/if}

View File

@@ -2,6 +2,7 @@
import Check from '$lib/assets/icons/Check.svelte';
import Cross from '$lib/assets/icons/Cross.svelte';
import SeasonSelect from '$lib/components/SeasonSelect.svelte';
import TranslationApproval from '$lib/components/TranslationApproval.svelte';
import '$lib/css/action_button.css'
import '$lib/css/nordtheme.css'
import { redirect } from '@sveltejs/kit';
@@ -13,6 +14,14 @@
let image_preview_url="https://bocken.org/static/rezepte/thumb/" + data.recipe.short_name + ".webp?v=" + data.recipe.dateModified;
let note = data.recipe.note
// Translation workflow state
let showTranslationWorkflow = false;
let translationData: any = data.recipe.translations?.en || null;
let changedFields: string[] = [];
// Store original recipe data for change detection
const originalRecipe = JSON.parse(JSON.stringify(data.recipe));
import { season } from '$lib/js/season_store';
import { portions } from '$lib/js/portions_store';
@@ -92,6 +101,78 @@
}
}
// Get current German recipe data
function getCurrentRecipeData() {
return {
...card_data,
...add_info,
images,
season: season_local,
short_name: short_name.trim(),
datecreated,
portions: portions_local,
datemodified,
instructions,
ingredients,
addendum,
preamble,
note,
};
}
// Detect which fields have changed from the original
function detectChangedFields() {
const current = getCurrentRecipeData();
const changed: string[] = [];
const fieldsToCheck = [
'name', 'description', 'preamble', 'addendum',
'note', 'category', 'tags', 'ingredients', 'instructions'
];
for (const field of fieldsToCheck) {
const oldValue = JSON.stringify(originalRecipe[field] || '');
const newValue = JSON.stringify(current[field] || '');
if (oldValue !== newValue) {
changed.push(field);
}
}
return changed;
}
// Show translation workflow before submission
function prepareSubmit() {
changedFields = detectChangedFields();
showTranslationWorkflow = true;
// Scroll to translation section
setTimeout(() => {
document.getElementById('translation-section')?.scrollIntoView({ behavior: 'smooth' });
}, 100);
}
// Handle translation approval
function handleTranslationApproved(event: CustomEvent) {
translationData = event.detail.translatedRecipe;
doEdit();
}
// Handle translation skipped
function handleTranslationSkipped() {
// Mark translation as needing update if fields changed
if (changedFields.length > 0 && translationData) {
translationData.translationStatus = 'needs_update';
translationData.changedFields = changedFields;
}
doEdit();
}
// Handle translation cancelled
function handleTranslationCancelled() {
showTranslationWorkflow = false;
}
async function doDelete(){
const response = confirm("Bist du dir sicher, dass du das Rezept löschen willst?")
if(!response){
@@ -200,30 +281,34 @@
return
}
}
const recipeData = getCurrentRecipeData();
// Add translations if available
if (translationData) {
recipeData.translations = {
en: translationData
};
// Update translation metadata
if (changedFields.length > 0) {
recipeData.translationMetadata = {
lastModifiedGerman: new Date(),
fieldsModifiedSinceTranslation: translationData.translationStatus === 'needs_update' ? changedFields : [],
};
}
}
const res = await fetch('/api/rezepte/edit', {
method: 'POST',
body: JSON.stringify({
recipe: {
...card_data,
...add_info,
images, // TODO
season: season_local,
short_name: short_name.trim(),
datecreated,
portions: portions_local,
datemodified,
instructions,
ingredients,
addendum,
preamble,
note,
},
recipe: recipeData,
old_short_name,
old_recipe: originalRecipe, // For change detection in API
}),
headers: {
'content-type': 'application/json',
credentials: 'include',
}
})
}
})
if(res.ok){
const url = location.href.split('/');
@@ -381,7 +466,23 @@ button.action_button{
<div class=addendum bind:innerText={addendum} contenteditable></div>
</div>
{#if !showTranslationWorkflow}
<div class=submit_buttons>
<button class=action_button on:click={doDelete}><p>Löschen</p><Cross fill=white width=2rem height=2rem></Cross></button>
<button class=action_button on:click={doEdit}><p>Speichern</p><Check fill=white width=2rem height=2rem></Check></button>
<button class=action_button on:click={prepareSubmit}><p>Weiter zur Übersetzung</p><Check fill=white width=2rem height=2rem></Check></button>
</div>
{/if}
{#if showTranslationWorkflow}
<div id="translation-section">
<TranslationApproval
germanData={getCurrentRecipeData()}
englishData={translationData}
{changedFields}
isEditMode={true}
on:approved={handleTranslationApproved}
on:skipped={handleTranslationSkipped}
on:cancelled={handleTranslationCancelled}
/>
</div>
{/if}