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