feat: add comprehensive base recipe translation support

- Add language prop to CreateIngredientList and CreateStepList components
  - Support both 'de' and 'en' with translation dictionaries
  - All UI labels now respect the lang prop

- Implement syncBaseRecipeReferences() in TranslationApproval
  - Always runs on component mount (not just for new translations)
  - Fetches English names for base recipe references
  - Merges German structure with existing English translations
  - Preserves existing translations while adding new base recipe refs

- Enhance partial translation in translation.ts
  - Handle base recipe reference fields (itemsBefore/itemsAfter, stepsBefore/stepsAfter)
  - Detect changes using JSON comparison
  - Only re-translate fields that changed
  - Ensures additional items/steps in base recipe refs are preserved during updates
This commit is contained in:
2026-01-04 22:25:31 +01:00
parent 1d4daf11ad
commit 8a152c5fb2
4 changed files with 566 additions and 81 deletions

View File

@@ -21,6 +21,66 @@ export function set_portions(){
portions.update((p) => portions_local)
}
export let lang: 'de' | 'en' = 'de';
// Translation strings
const t = {
de: {
portions: 'Portionen:',
ingredients: 'Zutaten',
baseRecipe: 'Basisrezept',
unnamed: 'Unbenannt',
additionalIngredientsBefore: 'Zusätzliche Zutaten davor:',
additionalIngredientsAfter: 'Zusätzliche Zutaten danach:',
addIngredientBefore: 'Zutat davor hinzufügen',
addIngredientAfter: 'Zutat danach hinzufügen',
baseRecipeContent: '→ Inhalt vom Basisrezept wird hier eingefügt ←',
insertBaseRecipe: 'Basisrezept einfügen',
categoryOptional: 'Kategorie (optional)',
editIngredient: 'Zutat verändern',
renameCategory: 'Kategorie umbenennen',
confirmDeleteReference: 'Bist du dir sicher, dass du diese Referenz löschen möchtest?',
confirmDeleteList: 'Bist du dir sicher, dass du diese Liste löschen möchtest? Alle Zutaten der Liste werden hiermit auch gelöscht.',
empty: 'Leer',
editHeading: 'Überschrift bearbeiten',
removeList: 'Liste entfernen',
editIngredientAria: 'Zutat bearbeiten',
removeIngredientAria: 'Zutat entfernen',
moveUpAria: 'Nach oben verschieben',
moveDownAria: 'Nach unten verschieben',
moveReferenceUpAria: 'Referenz nach oben verschieben',
moveReferenceDownAria: 'Referenz nach unten verschieben',
removeReferenceAria: 'Referenz entfernen'
},
en: {
portions: 'Portions:',
ingredients: 'Ingredients',
baseRecipe: 'Base Recipe',
unnamed: 'Unnamed',
additionalIngredientsBefore: 'Additional ingredients before:',
additionalIngredientsAfter: 'Additional ingredients after:',
addIngredientBefore: 'Add ingredient before',
addIngredientAfter: 'Add ingredient after',
baseRecipeContent: '→ Base recipe content will be inserted here ←',
insertBaseRecipe: 'Insert Base Recipe',
categoryOptional: 'Category (optional)',
editIngredient: 'Edit Ingredient',
renameCategory: 'Rename Category',
confirmDeleteReference: 'Are you sure you want to delete this reference?',
confirmDeleteList: 'Are you sure you want to delete this list? All ingredients in the list will also be deleted.',
empty: 'Empty',
editHeading: 'Edit heading',
removeList: 'Remove list',
editIngredientAria: 'Edit ingredient',
removeIngredientAria: 'Remove ingredient',
moveUpAria: 'Move up',
moveDownAria: 'Move down',
moveReferenceUpAria: 'Move reference up',
moveReferenceDownAria: 'Move reference down',
removeReferenceAria: 'Remove reference'
}
};
export let ingredients
let new_ingredient = {
@@ -80,7 +140,7 @@ function handleSelect(recipe: any, options: any) {
}
export function removeReference(list_index: number) {
const confirmed = confirm("Bist du dir sicher, dass du diese Referenz löschen möchtest?");
const confirmed = confirm(t[lang].confirmDeleteReference);
if (confirmed) {
ingredients.splice(list_index, 1);
ingredients = ingredients;
@@ -208,7 +268,7 @@ export function add_new_ingredient(){
}
export function remove_list(list_index){
if(ingredients[list_index].list.length > 1){
const response = confirm("Bist du dir sicher, dass du diese Liste löschen möchtest? Alle Zutaten der Liste werden hiermit auch gelöscht.");
const response = confirm(t[lang].confirmDeleteList);
if(!response){
return
}
@@ -669,28 +729,28 @@ h3{
</style>
<div class=list_wrapper >
<h4>Portionen:</h4>
<h4>{t[lang].portions}</h4>
<p contenteditable type="text" bind:innerText={portions_local} on:blur={set_portions}></p>
<h2>Zutaten</h2>
<h2>{t[lang].ingredients}</h2>
{#each ingredients as list, list_index}
{#if list.type === 'reference'}
<!-- Reference item display -->
<div class="reference-container">
<div class="reference-header">
<div class="move_buttons_container">
<button on:click={() => update_list_position(list_index, 1)} aria-label="Referenz nach oben verschieben">
<button on:click={() => update_list_position(list_index, 1)} aria-label={t[lang].moveReferenceUpAria}>
<svg class="button_arrow" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16px" height="16px"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M7.41 15.41L12 10.83l4.59 4.58L18 14l-6-6-6 6 1.41 1.41z"/></svg>
</button>
<button on:click={() => update_list_position(list_index, -1)} aria-label="Referenz nach unten verschieben">
<button on:click={() => update_list_position(list_index, -1)} aria-label={t[lang].moveReferenceDownAria}>
<svg class="button_arrow" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16px" height="16px"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z"/></svg>
</button>
</div>
<div class="reference-badge">
📋 Basisrezept: {list.name || 'Unbenannt'}
📋 {t[lang].baseRecipe}: {list.name || t[lang].unnamed}
</div>
<div class="mod_icons">
<button class="action_button button_subtle" on:click={() => removeReference(list_index)} aria-label="Referenz entfernen">
<button class="action_button button_subtle" on:click={() => removeReference(list_index)} aria-label={t[lang].removeReferenceAria}>
<Cross fill="var(--nord11)"></Cross>
</button>
</div>
@@ -698,7 +758,7 @@ h3{
<!-- Items before base recipe -->
{#if list.itemsBefore && list.itemsBefore.length > 0}
<h4 style="margin-block: 0.5em; color: var(--nord9);">Zusätzliche Zutaten davor:</h4>
<h4 style="margin-block: 0.5em; color: var(--nord9);">{t[lang].additionalIngredientsBefore}</h4>
<div class="ingredients_grid">
{#each list.itemsBefore as item, item_index}
<div class=move_buttons_container>
@@ -711,10 +771,10 @@ h3{
{@html item.name}
</button>
<div class="mod_icons">
<button class="action_button button_subtle" on:click={() => editItemFromReference(list_index, 'before', item_index)} aria-label="Zutat bearbeiten">
<button class="action_button button_subtle" on:click={() => editItemFromReference(list_index, 'before', item_index)} aria-label={t[lang].editIngredientAria}>
<Pen fill="var(--nord6)" height="1em" width="1em"></Pen>
</button>
<button class="action_button button_subtle" on:click={() => removeItemFromReference(list_index, 'before', item_index)} aria-label="Zutat entfernen">
<button class="action_button button_subtle" on:click={() => removeItemFromReference(list_index, 'before', item_index)} aria-label={t[lang].removeIngredientAria}>
<Cross fill="var(--nord6)" height="1em" width="1em"></Cross>
</button>
</div>
@@ -722,20 +782,20 @@ h3{
</div>
{/if}
<button class="action_button button_subtle add-to-reference-button" on:click={() => openAddToReferenceModal(list_index, 'before')}>
<Plus fill="var(--nord9)" height="1em" width="1em"></Plus> Zutat davor hinzufügen
<Plus fill="var(--nord9)" height="1em" width="1em"></Plus> {t[lang].addIngredientBefore}
</button>
<!-- Base recipe content indicator -->
<div style="text-align: center; padding: 0.5em; margin: 0.5em 0; font-style: italic; color: var(--nord10); background-color: rgba(143, 188, 187, 0.4); border-radius: 5px;">
→ Inhalt vom Basisrezept wird hier eingefügt ←
{t[lang].baseRecipeContent}
</div>
<!-- Items after base recipe -->
<button class="action_button button_subtle add-to-reference-button" on:click={() => openAddToReferenceModal(list_index, 'after')}>
<Plus fill="var(--nord9)" height="1em" width="1em"></Plus> Zutat danach hinzufügen
<Plus fill="var(--nord9)" height="1em" width="1em"></Plus> {t[lang].addIngredientAfter}
</button>
{#if list.itemsAfter && list.itemsAfter.length > 0}
<h4 style="margin-block: 0.5em; color: var(--nord9);">Zusätzliche Zutaten danach:</h4>
<h4 style="margin-block: 0.5em; color: var(--nord9);">{t[lang].additionalIngredientsAfter}</h4>
<div class="ingredients_grid">
{#each list.itemsAfter as item, item_index}
<div class=move_buttons_container>
@@ -748,10 +808,10 @@ h3{
{@html item.name}
</button>
<div class="mod_icons">
<button class="action_button button_subtle" on:click={() => editItemFromReference(list_index, 'after', item_index)} aria-label="Zutat bearbeiten">
<button class="action_button button_subtle" on:click={() => editItemFromReference(list_index, 'after', item_index)} aria-label={t[lang].editIngredientAria}>
<Pen fill="var(--nord6)" height="1em" width="1em"></Pen>
</button>
<button class="action_button button_subtle" on:click={() => removeItemFromReference(list_index, 'after', item_index)} aria-label="Zutat entfernen">
<button class="action_button button_subtle" on:click={() => removeItemFromReference(list_index, 'after', item_index)} aria-label={t[lang].removeIngredientAria}>
<Cross fill="var(--nord6)" height="1em" width="1em"></Cross>
</button>
</div>
@@ -775,23 +835,23 @@ h3{
{#if list.name }
{list.name}
{:else}
Leer
{t[lang].empty}
{/if}
</button>
<div class=mod_icons>
<button class="action_button button_subtle" on:click="{() => show_modal_edit_subheading_ingredient(list_index)}" aria-label="Überschrift bearbeiten">
<button class="action_button button_subtle" on:click="{() => show_modal_edit_subheading_ingredient(list_index)}" aria-label={t[lang].editHeading}>
<Pen fill=var(--nord1)></Pen> </button>
<button class="action_button button_subtle" on:click="{() => remove_list(list_index)}" aria-label="Liste entfernen">
<button class="action_button button_subtle" on:click="{() => remove_list(list_index)}" aria-label={t[lang].removeList}>
<Cross fill=var(--nord1)></Cross></button>
</div>
</h3>
<div class=ingredients_grid>
{#each list.list as ingredient, ingredient_index (ingredient_index)}
<div class=move_buttons_container>
<button on:click="{() => update_ingredient_position(list_index, ingredient_index, 1)}" aria-label="Zutat nach oben verschieben">
<button on:click="{() => update_ingredient_position(list_index, ingredient_index, 1)}" aria-label={t[lang].moveUpAria}>
<svg class=button_arrow xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16px" height="16px"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M7.41 15.41L12 10.83l4.59 4.58L18 14l-6-6-6 6 1.41 1.41z"/></svg>
</button>
<button on:click="{() => update_ingredient_position(list_index, ingredient_index, -1)}" aria-label="Zutat nach unten verschieben">
<button on:click="{() => update_ingredient_position(list_index, ingredient_index, -1)}" aria-label={t[lang].moveDownAria}>
<svg class=button_arrow xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16px" height="16px"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z"/></svg>
</button>
</div>
@@ -801,9 +861,9 @@ h3{
<button class="force_wrap ingredient-name-button" on:click={() => show_modal_edit_ingredient(list_index, ingredient_index)}>
{@html ingredient.name}
</button>
<div class=mod_icons><button class="action_button button_subtle" on:click={() => show_modal_edit_ingredient(list_index, ingredient_index)} aria-label="Zutat bearbeiten">
<div class=mod_icons><button class="action_button button_subtle" on:click={() => show_modal_edit_ingredient(list_index, ingredient_index)} aria-label={t[lang].editIngredientAria}>
<Pen fill=var(--nord1) height=1em width=1em></Pen></button>
<button class="action_button button_subtle" on:click="{() => remove_ingredient(list_index, ingredient_index)}" aria-label="Zutat entfernen"><Cross fill=var(--nord1) height=1em width=1em></Cross></button></div>
<button class="action_button button_subtle" on:click="{() => remove_ingredient(list_index, ingredient_index)}" aria-label={t[lang].removeIngredientAria}><Cross fill=var(--nord1) height=1em width=1em></Cross></button></div>
{/each}
</div>
{/if}
@@ -812,12 +872,12 @@ h3{
<!-- Button to insert base recipe -->
<button class="insert-base-recipe-button" on:click={() => openSelector(ingredients.length)}>
<Plus fill="white" style="display: inline; width: 1.5em; height: 1.5em; vertical-align: middle;"></Plus>
Basisrezept einfügen
{t[lang].insertBaseRecipe}
</button>
</div>
<div class="adder shadow">
<input class=category type="text" bind:value={new_ingredient.sublist} placeholder="Kategorie (optional)" on:keydown={(event) => do_on_key(event, 'Enter', false, add_new_ingredient)}>
<input class=category type="text" bind:value={new_ingredient.sublist} placeholder={t[lang].categoryOptional} on:keydown={(event) => do_on_key(event, 'Enter', false, add_new_ingredient)}>
<div class=add_ingredient>
<input type="text" placeholder="250..." bind:value={new_ingredient.amount} on:keydown={(event) => do_on_key(event, 'Enter', false, add_new_ingredient)}>
<input type="text" placeholder="mL..." bind:value={new_ingredient.unit} on:keydown={(event) => do_on_key(event, 'Enter', false, add_new_ingredient)}>
@@ -828,9 +888,9 @@ h3{
</div>
</div>
<dialog id=edit_ingredient_modal on:cancel={handleIngredientModalCancel}>
<h2>Zutat verändern</h2>
<h2>{t[lang].editIngredient}</h2>
<div class=adder>
<input class=category type="text" bind:value={edit_ingredient.sublist} placeholder="Kategorie (optional)">
<input class=category type="text" bind:value={edit_ingredient.sublist} placeholder={t[lang].categoryOptional}>
<div class=add_ingredient role="group" on:keydown={(event) => do_on_key(event, 'Enter', false, edit_ingredient_and_close_modal)}>
<input type="text" placeholder="250..." bind:value={edit_ingredient.amount} on:keydown={(event) => do_on_key(event, 'Enter', false, edit_ingredient_and_close_modal)}>
<input type="text" placeholder="mL..." bind:value={edit_ingredient.unit} on:keydown={(event) => do_on_key(event, 'Enter', false, edit_ingredient_and_close_modal)}>
@@ -843,7 +903,7 @@ h3{
</dialog>
<dialog id=edit_subheading_ingredient_modal>
<h2>Kategorie umbenennen</h2>
<h2>{t[lang].renameCategory}</h2>
<div class=heading_wrapper>
<input class=heading type="text" bind:value={edit_heading.name} on:keydown={(event) => do_on_key(event, 'Enter', false, edit_subheading_and_close_modal)} >
<button class=action_button on:keydown={(event) => do_on_key(event, 'Enter', false, edit_subheading_and_close_modal)} on:click={edit_subheading_and_close_modal}>

View File

@@ -11,6 +11,82 @@ import "$lib/css/action_button.css"
import { do_on_key } from '$lib/components/do_on_key.js'
import BaseRecipeSelector from '$lib/components/BaseRecipeSelector.svelte'
export let lang: 'de' | 'en' = 'de';
// Translation strings
const t = {
de: {
preparation: 'Vorbereitung:',
bulkFermentation: 'Stockgare:',
finalFermentation: 'Stückgare:',
baking: 'Backen:',
cooking: 'Kochen:',
totalTime: 'Auf dem Teller:',
instructions: 'Zubereitung',
baseRecipe: 'Basisrezept',
unnamed: 'Unbenannt',
additionalStepsBefore: 'Zusätzliche Schritte davor:',
additionalStepsAfter: 'Zusätzliche Schritte danach:',
addStepBefore: 'Schritt davor hinzufügen',
addStepAfter: 'Schritt danach hinzufügen',
baseRecipeContent: '→ Inhalt vom Basisrezept wird hier eingefügt ←',
insertBaseRecipe: 'Basisrezept einfügen',
categoryOptional: 'Kategorie (optional)',
subcategoryOptional: 'Unterkategorie (optional)',
editStep: 'Schritt verändern',
renameCategory: 'Kategorie umbenennen',
confirmDeleteReference: 'Bist du dir sicher, dass du diese Referenz löschen möchtest?',
confirmDeleteList: 'Bist du dir sicher, dass du diese Liste löschen möchtest? Alle Zubereitungsschritte der Liste werden hiermit auch gelöscht.',
empty: 'Leer',
editHeading: 'Überschrift bearbeiten',
removeList: 'Liste entfernen',
editStepAria: 'Schritt bearbeiten',
removeStepAria: 'Schritt entfernen',
moveUpAria: 'Nach oben verschieben',
moveDownAria: 'Nach unten verschieben',
moveReferenceUpAria: 'Referenz nach oben verschieben',
moveReferenceDownAria: 'Referenz nach unten verschieben',
removeReferenceAria: 'Referenz entfernen',
moveListUpAria: 'Liste nach oben verschieben',
moveListDownAria: 'Liste nach unten verschieben'
},
en: {
preparation: 'Preparation:',
bulkFermentation: 'Bulk Fermentation:',
finalFermentation: 'Final Fermentation:',
baking: 'Baking:',
cooking: 'Cooking:',
totalTime: 'Total Time:',
instructions: 'Instructions',
baseRecipe: 'Base Recipe',
unnamed: 'Unnamed',
additionalStepsBefore: 'Additional steps before:',
additionalStepsAfter: 'Additional steps after:',
addStepBefore: 'Add step before',
addStepAfter: 'Add step after',
baseRecipeContent: '→ Base recipe content will be inserted here ←',
insertBaseRecipe: 'Insert Base Recipe',
categoryOptional: 'Category (optional)',
subcategoryOptional: 'Subcategory (optional)',
editStep: 'Edit Step',
renameCategory: 'Rename Category',
confirmDeleteReference: 'Are you sure you want to delete this reference?',
confirmDeleteList: 'Are you sure you want to delete this list? All preparation steps in the list will also be deleted.',
empty: 'Empty',
editHeading: 'Edit heading',
removeList: 'Remove list',
editStepAria: 'Edit step',
removeStepAria: 'Remove step',
moveUpAria: 'Move up',
moveDownAria: 'Move down',
moveReferenceUpAria: 'Move reference up',
moveReferenceDownAria: 'Move reference down',
removeReferenceAria: 'Remove reference',
moveListUpAria: 'Move list up',
moveListDownAria: 'Move list down'
}
};
const step_placeholder = "Kartoffeln schälen..."
export let instructions
export let add_info
@@ -61,7 +137,7 @@ function handleSelect(recipe: any, options: any) {
}
export function removeReference(list_index: number) {
const confirmed = confirm("Bist du dir sicher, dass du diese Referenz löschen möchtest?");
const confirmed = confirm(t[lang].confirmDeleteReference);
if (confirmed) {
instructions.splice(list_index, 1);
instructions = instructions;
@@ -147,7 +223,7 @@ function get_sublist_index(sublist_name, list){
}
export function remove_list(list_index){
if(instructions[list_index].steps.length > 1){
const response = confirm("Bist du dir sicher, dass du diese Liste löschen möchtest? Alle Zubereitungsschritte der Liste werden hiermit auch gelöscht.");
const response = confirm(t[lang].confirmDeleteList);
if(!response){
return
}
@@ -682,50 +758,50 @@ h3{
<div class=instructions>
<div class=additional_info>
<div><h4>Vorbereitung:</h4>
<div><h4>{t[lang].preparation}</h4>
<p contenteditable type="text" bind:innerText={add_info.preparation}></p>
</div>
<div><h4>Stockgare:</h4>
<div><h4>{t[lang].bulkFermentation}</h4>
<p contenteditable type="text" bind:innerText={add_info.fermentation.bulk}></p>
</div>
<div><h4>Stückgare:</h4>
<div><h4>{t[lang].finalFermentation}</h4>
<p contenteditable type="text" bind:innerText={add_info.fermentation.final}></p>
</div>
<div><h4>Backen:</h4>
<div><h4>{t[lang].baking}</h4>
<div><p type="text" bind:innerText={add_info.baking.length} contenteditable placeholder="40 min..."></p></div> bei <div><p type="text" bind:innerText={add_info.baking.temperature} contenteditable placeholder=200...></p></div> °C <div><p type="text" bind:innerText={add_info.baking.mode} contenteditable placeholder="Ober-/Unterhitze..."></p></div></div>
<div><h4>Kochen:</h4>
<div><h4>{t[lang].cooking}</h4>
<p contenteditable type="text" bind:innerText={add_info.cooking}></p>
</div>
<div><h4>Auf dem Teller:</h4>
<div><h4>{t[lang].totalTime}</h4>
<p contenteditable type="text" bind:innerText={add_info.total_time}></p>
</div>
</div>
<h2>Zubereitung</h2>
<h2>{t[lang].instructions}</h2>
{#each instructions as list, list_index}
{#if list.type === 'reference'}
<!-- Reference item display -->
<div class="reference-container">
<div class="reference-header">
<div class="move_buttons_container">
<button on:click={() => update_list_position(list_index, 1)} aria-label="Referenz nach oben verschieben">
<button on:click={() => update_list_position(list_index, 1)} aria-label={t[lang].moveReferenceUpAria}>
<svg class="button_arrow" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16px" height="16px"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M7.41 15.41L12 10.83l4.59 4.58L18 14l-6-6-6 6 1.41 1.41z"/></svg>
</button>
<button on:click={() => update_list_position(list_index, -1)} aria-label="Referenz nach unten verschieben">
<button on:click={() => update_list_position(list_index, -1)} aria-label={t[lang].moveReferenceDownAria}>
<svg class="button_arrow" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16px" height="16px"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z"/></svg>
</button>
</div>
<div class="reference-badge">
📋 Basisrezept: {list.name || 'Unbenannt'}
📋 {t[lang].baseRecipe}: {list.name || t[lang].unnamed}
</div>
<div class="mod_icons">
<button class="action_button button_subtle" on:click={() => removeReference(list_index)} aria-label="Referenz entfernen">
<button class="action_button button_subtle" on:click={() => removeReference(list_index)} aria-label={t[lang].removeReferenceAria}>
<Cross fill="var(--nord11)"></Cross>
</button>
</div>
@@ -733,7 +809,7 @@ h3{
<!-- Steps before base recipe -->
{#if list.stepsBefore && list.stepsBefore.length > 0}
<h4 style="margin-block: 0.5em; color: var(--nord9);">Zusätzliche Schritte davor:</h4>
<h4 style="margin-block: 0.5em; color: var(--nord9);">{t[lang].additionalStepsBefore}</h4>
<ol>
{#each list.stepsBefore as step, step_index}
<li>
@@ -745,10 +821,10 @@ h3{
{@html step}
</button>
<div>
<button class="action_button button_subtle" on:click={() => editStepFromReference(list_index, 'before', step_index)} aria-label="Schritt bearbeiten">
<button class="action_button button_subtle" on:click={() => editStepFromReference(list_index, 'before', step_index)} aria-label={t[lang].editStepAria}>
<Pen fill="var(--nord6)" height="1em" width="1em"></Pen>
</button>
<button class="action_button button_subtle" on:click={() => removeStepFromReference(list_index, 'before', step_index)} aria-label="Schritt entfernen">
<button class="action_button button_subtle" on:click={() => removeStepFromReference(list_index, 'before', step_index)} aria-label={t[lang].removeStepAria}>
<Cross fill="var(--nord6)" height="1em" width="1em"></Cross>
</button>
</div>
@@ -758,20 +834,20 @@ h3{
</ol>
{/if}
<button class="action_button button_subtle add-to-reference-button" on:click={() => openAddToReferenceModal(list_index, 'before')}>
<Plus fill="var(--nord9)" height="1em" width="1em"></Plus> Schritt davor hinzufügen
<Plus fill="var(--nord9)" height="1em" width="1em"></Plus> {t[lang].addStepBefore}
</button>
<!-- Base recipe content indicator -->
<div style="text-align: center; padding: 0.5em; margin: 0.5em 0; font-style: italic; color: var(--nord10); background-color: rgba(143, 188, 187, 0.4); border-radius: 5px;">
→ Inhalt vom Basisrezept wird hier eingefügt ←
{t[lang].baseRecipeContent}
</div>
<!-- Steps after base recipe -->
<button class="action_button button_subtle add-to-reference-button" on:click={() => openAddToReferenceModal(list_index, 'after')}>
<Plus fill="var(--nord9)" height="1em" width="1em"></Plus> Schritt danach hinzufügen
<Plus fill="var(--nord9)" height="1em" width="1em"></Plus> {t[lang].addStepAfter}
</button>
{#if list.stepsAfter && list.stepsAfter.length > 0}
<h4 style="margin-block: 0.5em; color: var(--nord9);">Zusätzliche Schritte danach:</h4>
<h4 style="margin-block: 0.5em; color: var(--nord9);">{t[lang].additionalStepsAfter}</h4>
<ol>
{#each list.stepsAfter as step, step_index}
<li>
@@ -783,10 +859,10 @@ h3{
{@html step}
</button>
<div>
<button class="action_button button_subtle" on:click={() => editStepFromReference(list_index, 'after', step_index)} aria-label="Schritt bearbeiten">
<button class="action_button button_subtle" on:click={() => editStepFromReference(list_index, 'after', step_index)} aria-label={t[lang].editStepAria}>
<Pen fill="var(--nord6)" height="1em" width="1em"></Pen>
</button>
<button class="action_button button_subtle" on:click={() => removeStepFromReference(list_index, 'after', step_index)} aria-label="Schritt entfernen">
<button class="action_button button_subtle" on:click={() => removeStepFromReference(list_index, 'after', step_index)} aria-label={t[lang].removeStepAria}>
<Cross fill="var(--nord6)" height="1em" width="1em"></Cross>
</button>
</div>
@@ -800,10 +876,10 @@ h3{
<!-- svelte-ignore a11y-click-events-have-key-events -->
<h3>
<div class=move_buttons_container>
<button on:click="{() => update_list_position(list_index, 1)}" aria-label="Liste nach oben verschieben">
<button on:click="{() => update_list_position(list_index, 1)}" aria-label={t[lang].moveListUpAria}>
<svg class=button_arrow xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16px" height="16px"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M7.41 15.41L12 10.83l4.59 4.58L18 14l-6-6-6 6 1.41 1.41z"/></svg>
</button>
<button on:click="{() => update_list_position(list_index, -1)}" aria-label="Liste nach unten verschieben">
<button on:click="{() => update_list_position(list_index, -1)}" aria-label={t[lang].moveListDownAria}>
<svg class=button_arrow xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16px" height="16px"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z"/></svg>
</button>
</div>
@@ -811,12 +887,12 @@ h3{
{#if list.name}
{list.name}
{:else}
Leer
{t[lang].empty}
{/if}
</button>
<button class="action_button button_subtle" on:click="{() => show_modal_edit_subheading_step(list_index)}" aria-label="Überschrift bearbeiten">
<button class="action_button button_subtle" on:click="{() => show_modal_edit_subheading_step(list_index)}" aria-label={t[lang].editHeading}>
<Pen fill=var(--nord1)></Pen> </button>
<button class="action_button button_subtle" on:click="{() => remove_list(list_index)}" aria-label="Liste entfernen">
<button class="action_button button_subtle" on:click="{() => remove_list(list_index)}" aria-label={t[lang].removeList}>
<Cross fill=var(--nord1)></Cross>
</button>
</h3>
@@ -825,10 +901,10 @@ h3{
<!-- svelte-ignore a11y-click-events-have-key-events -->
<li>
<div class="move_buttons_container step_move_buttons">
<button on:click="{() => update_step_position(list_index, step_index, 1)}" aria-label="Schritt nach oben verschieben">
<button on:click="{() => update_step_position(list_index, step_index, 1)}" aria-label={t[lang].moveUpAria}>
<svg class=button_arrow xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16px" height="16px"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M7.41 15.41L12 10.83l4.59 4.58L18 14l-6-6-6 6 1.41 1.41z"/></svg>
</button>
<button on:click="{() => update_step_position(list_index, step_index, -1)}" aria-label="Schritt nach unten verschieben">
<button on:click="{() => update_step_position(list_index, step_index, -1)}" aria-label={t[lang].moveDownAria}>
<svg class=button_arrow xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16px" height="16px"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z"/></svg>
</button>
</div>
@@ -836,10 +912,10 @@ h3{
<button on:click={() => show_modal_edit_step(list_index, step_index)} class="step-button">
{@html step}
</button>
<div><button class="action_button button_subtle" on:click={() => show_modal_edit_step(list_index, step_index)}>
<div><button class="action_button button_subtle" on:click={() => show_modal_edit_step(list_index, step_index)} aria-label={t[lang].editStepAria}>
<Pen fill=var(--nord1)></Pen>
</button>
<button class="action_button button_subtle" on:click="{() => remove_step(list_index, step_index)}">
<button class="action_button button_subtle" on:click="{() => remove_step(list_index, step_index)}" aria-label={t[lang].removeStepAria}>
<Cross fill=var(--nord1)></Cross>
</button>
</div></div>
@@ -852,12 +928,12 @@ h3{
<!-- Button to insert base recipe -->
<button class="insert-base-recipe-button" on:click={() => openSelector(instructions.length)}>
<Plus fill="white" style="display: inline; width: 1.5em; height: 1.5em; vertical-align: middle;"></Plus>
Basisrezept einfügen
{t[lang].insertBaseRecipe}
</button>
</div>
<div class='adder shadow'>
<input class=category type="text" bind:value={new_step.name} placeholder="Kategorie (optional)"on:keydown={(event) => do_on_key(event, 'Enter', false , add_new_step)} >
<input class=category type="text" bind:value={new_step.name} placeholder={t[lang].categoryOptional} on:keydown={(event) => do_on_key(event, 'Enter', false , add_new_step)} >
<div class=add_step>
<p id=step contenteditable on:focus='{clear_step}' on:blur={add_placeholder} bind:innerText={new_step.step} on:keydown={(event) => do_on_key(event, 'Enter', true , add_new_step)}></p>
<button on:click={() => add_new_step()} class=action_button>
@@ -867,9 +943,9 @@ h3{
</div>
</div>
<dialog id=edit_step_modal on:cancel={handleStepModalCancel}>
<h2>Schritt verändern</h2>
<h2>{t[lang].editStep}</h2>
<div class=adder>
<input class=category type="text" bind:value={edit_step.name} placeholder="Unterkategorie (optional)" on:keydown={(event) => do_on_key(event, 'Enter', false , edit_step_and_close_modal)}>
<input class=category type="text" bind:value={edit_step.name} placeholder={t[lang].subcategoryOptional} on:keydown={(event) => do_on_key(event, 'Enter', false , edit_step_and_close_modal)}>
<div class=add_step>
<p id=step contenteditable bind:innerText={edit_step.step} on:keydown={(event) => do_on_key(event, 'Enter', true , edit_step_and_close_modal)}></p>
<button class=action_button on:click="{() => edit_step_and_close_modal()}" >
@@ -879,7 +955,7 @@ h3{
</dialog>
<dialog id=edit_subheading_steps_modal>
<h2>Kategorie umbenennen</h2>
<h2>{t[lang].renameCategory}</h2>
<div class=heading_wrapper>
<input class="heading" type="text" bind:value={edit_heading.name} on:keydown={(event) => do_on_key(event, 'Enter', false, edit_subheading_steps_and_close_modal)}>
<button on:click={edit_subheading_steps_and_close_modal} class=action_button>

View File

@@ -17,7 +17,7 @@
let errorMessage: string = '';
let validationErrors: string[] = [];
// Editable English data (clone of englishData)
// Editable English data (clone of englishData or initialized from germanData)
let editableEnglish: any = englishData ? { ...englishData } : null;
// Store old recipe data for granular change detection
@@ -26,6 +26,155 @@
// Translation metadata (tracks which items were re-translated)
let translationMetadata: any = null;
// Track base recipes that need translation
let untranslatedBaseRecipes: { id: string, name: string }[] = [];
let checkingBaseRecipes = false;
// Sync base recipe references from German to English
async function syncBaseRecipeReferences() {
if (!germanData) return;
checkingBaseRecipes = true;
// Collect all base recipe references from German data
const germanBaseRecipeIds = new Set<string>();
(germanData.ingredients || []).forEach((ing: any) => {
if (ing.type === 'reference' && ing.baseRecipeRef) {
germanBaseRecipeIds.add(ing.baseRecipeRef);
}
});
(germanData.instructions || []).forEach((inst: any) => {
if (inst.type === 'reference' && inst.baseRecipeRef) {
germanBaseRecipeIds.add(inst.baseRecipeRef);
}
});
// If no base recipes in German, just initialize editableEnglish from German data if needed
if (germanBaseRecipeIds.size === 0) {
if (!editableEnglish) {
editableEnglish = {
...germanData,
translationStatus: 'pending',
ingredients: JSON.parse(JSON.stringify(germanData.ingredients || [])),
instructions: JSON.parse(JSON.stringify(germanData.instructions || []))
};
}
checkingBaseRecipes = false;
return;
}
// Fetch all base recipes and check their English translations
const untranslated: { id: string, name: string }[] = [];
const baseRecipeTranslations = new Map<string, { deName: string, enName: string }>();
for (const recipeId of germanBaseRecipeIds) {
try {
const response = await fetch(`/api/rezepte/${recipeId}`);
if (response.ok) {
const recipe = await response.json();
if (!recipe.translations?.en) {
untranslated.push({ id: recipeId, name: recipe.name });
} else {
baseRecipeTranslations.set(recipeId, {
deName: recipe.name,
enName: recipe.translations.en.name
});
}
}
} catch (error) {
console.error(`Error fetching base recipe ${recipeId}:`, error);
}
}
untranslatedBaseRecipes = untranslated;
checkingBaseRecipes = false;
// Don't proceed if there are untranslated base recipes
if (untranslated.length > 0) {
return;
}
// Now merge German base recipe references into editableEnglish
// This works for both new translations and existing translations
if (!editableEnglish) {
// No existing English translation - create from German structure with English base recipe names
editableEnglish = {
...germanData,
translationStatus: 'pending',
ingredients: JSON.parse(JSON.stringify(germanData.ingredients || [])).map((ing: any) => {
if (ing.type === 'reference' && ing.baseRecipeRef) {
const translation = baseRecipeTranslations.get(ing.baseRecipeRef);
return translation ? { ...ing, name: translation.enName } : ing;
}
return ing;
}),
instructions: JSON.parse(JSON.stringify(germanData.instructions || [])).map((inst: any) => {
if (inst.type === 'reference' && inst.baseRecipeRef) {
const translation = baseRecipeTranslations.get(inst.baseRecipeRef);
return translation ? { ...inst, name: translation.enName } : inst;
}
return inst;
})
};
} else {
// Existing English translation - merge German structure with English translations
// Use German structure but keep English translations where they exist
editableEnglish = {
...editableEnglish,
ingredients: germanData.ingredients.map((germanIng: any, index: number) => {
if (germanIng.type === 'reference' && germanIng.baseRecipeRef) {
// This is a base recipe reference - use English base recipe name
const translation = baseRecipeTranslations.get(germanIng.baseRecipeRef);
const englishIng = editableEnglish.ingredients[index];
// If English already has this reference at same position, keep it
if (englishIng?.type === 'reference' && englishIng.baseRecipeRef === germanIng.baseRecipeRef) {
return englishIng;
}
// Otherwise, create new reference with English base recipe name
return translation ? { ...germanIng, name: translation.enName } : germanIng;
} else {
// Regular ingredient section - keep existing English translation if it exists
const englishIng = editableEnglish.ingredients[index];
if (englishIng && englishIng.type !== 'reference') {
return englishIng;
}
// If no English translation exists, use German structure (will be translated later)
return germanIng;
}
}),
instructions: germanData.instructions.map((germanInst: any, index: number) => {
if (germanInst.type === 'reference' && germanInst.baseRecipeRef) {
// This is a base recipe reference - use English base recipe name
const translation = baseRecipeTranslations.get(germanInst.baseRecipeRef);
const englishInst = editableEnglish.instructions[index];
// If English already has this reference at same position, keep it
if (englishInst?.type === 'reference' && englishInst.baseRecipeRef === germanInst.baseRecipeRef) {
return englishInst;
}
// Otherwise, create new reference with English base recipe name
return translation ? { ...germanInst, name: translation.enName } : germanInst;
} else {
// Regular instruction section - keep existing English translation if it exists
const englishInst = editableEnglish.instructions[index];
if (englishInst && englishInst.type !== 'reference') {
return englishInst;
}
// If no English translation exists, use German structure (will be translated later)
return germanInst;
}
})
};
}
}
// Always sync base recipe references when component mounts
syncBaseRecipeReferences();
// Handle auto-translate button click
async function handleAutoTranslate() {
translationState = 'translating';
@@ -445,28 +594,52 @@ button:disabled {
</div>
{/if}
{#if checkingBaseRecipes}
<div style="background: var(--nord9); color: var(--nord6); padding: 1rem; border-radius: 4px; margin-bottom: 1.5rem; text-align: center;">
<p>Checking if referenced base recipes are translated...</p>
</div>
{/if}
{#if untranslatedBaseRecipes.length > 0}
<div style="background: var(--nord12); color: var(--nord0); padding: 1.5rem; border-radius: 4px; margin-bottom: 1.5rem;">
<h4 style="margin-top: 0;">⚠️ Base Recipes Need Translation</h4>
<p>The following base recipes need to be translated to English before you can translate this recipe:</p>
<ul style="margin: 1rem 0;">
{#each untranslatedBaseRecipes as baseRecipe}
<li>
<strong>{baseRecipe.name}</strong>
<a href="/de/edit/{baseRecipe.id}" target="_blank" rel="noopener noreferrer" style="margin-left: 0.5rem; color: var(--nord10);">
Open in new tab →
</a>
</li>
{/each}
</ul>
<p style="margin-bottom: 0;">
<button class="btn-secondary" on:click={syncBaseRecipeReferences}>
Re-check Base Recipes
</button>
</p>
</div>
{/if}
{#if translationState === 'idle'}
<div class="idle-state">
<p>Click "Auto-translate" to generate English translation using DeepL.</p>
<div class="actions">
<button class="btn-primary" on:click={handleAutoTranslate}>
Auto-translate
</button>
<button class="btn-secondary" on:click={handleSkip}>
Skip Translation
</button>
</div>
<div style="background: var(--nord13); color: var(--nord0); padding: 1rem; border-radius: 4px; margin-bottom: 1.5rem; text-align: center;">
<strong>Preview (Not yet translated)</strong>
<p style="margin: 0.5rem 0;">The structure below shows what will be translated. Click "Auto-translate" to generate English translation.</p>
</div>
{:else if translationState === 'translating'}
{/if}
{#if translationState === 'translating'}
<div class="idle-state">
<p>
<span class="loading-spinner"></span>
Translating recipe...
</p>
</div>
{/if}
{:else if translationState === 'preview' || translationState === 'approved'}
{#if translationState === 'idle' || translationState === 'preview' || translationState === 'approved'}
<div class="translation-preview">
<h3 style="margin-bottom: 1.5rem; color: var(--nord8);">🇬🇧 English Translation</h3>
@@ -575,12 +748,12 @@ button:disabled {
<div class="list-wrapper">
<div>
{#if editableEnglish?.ingredients}
<CreateIngredientList bind:ingredients={editableEnglish.ingredients} />
<CreateIngredientList bind:ingredients={editableEnglish.ingredients} lang="en" />
{/if}
</div>
<div>
{#if editableEnglish?.instructions && englishAddInfo}
<CreateStepList bind:instructions={editableEnglish.instructions} add_info={englishAddInfo} />
<CreateStepList bind:instructions={editableEnglish.instructions} add_info={englishAddInfo} lang="en" />
{/if}
</div>
</div>
@@ -602,7 +775,21 @@ button:disabled {
</div>
<div class="actions">
{#if translationState !== 'approved'}
{#if translationState === 'idle'}
<button class="btn-danger" on:click={handleCancel}>
Cancel
</button>
<button class="btn-secondary" on:click={handleSkip}>
Skip Translation
</button>
<button class="btn-primary" on:click={handleAutoTranslate} disabled={untranslatedBaseRecipes.length > 0}>
{#if untranslatedBaseRecipes.length > 0}
Translate base recipes first
{:else}
Auto-translate
{/if}
</button>
{:else if translationState !== 'approved'}
<button class="btn-danger" on:click={handleCancel}>
Cancel
</button>

View File

@@ -931,6 +931,98 @@ class DeepLTranslationService {
const group = newIngredients[i];
const existingGroup = existingTranslatedIngredients[i];
// Handle base recipe references
if (group.type === 'reference') {
// If entire group doesn't exist in old version or no change info, translate all reference fields
if (!changeInfo || !existingGroup) {
const textsToTranslate: string[] = [group.labelOverride || ''];
(group.itemsBefore || []).forEach((item: any) => {
textsToTranslate.push(item.name || '');
textsToTranslate.push(item.unit || '');
});
(group.itemsAfter || []).forEach((item: any) => {
textsToTranslate.push(item.name || '');
textsToTranslate.push(item.unit || '');
});
const translated = await this.translateBatch(textsToTranslate);
let index = 0;
result.push({
type: 'reference',
name: group.name,
baseRecipeRef: group.baseRecipeRef,
includeIngredients: group.includeIngredients,
showLabel: group.showLabel,
labelOverride: translated[index++],
itemsBefore: (group.itemsBefore || []).map((item: any) => ({
name: translated[index++],
unit: translated[index++],
amount: item.amount,
})),
itemsAfter: (group.itemsAfter || []).map((item: any) => ({
name: translated[index++],
unit: translated[index++],
amount: item.amount,
}))
});
continue;
}
// Reference changed - translate changed fields
const translatedRef: any = {
type: 'reference',
name: group.name,
baseRecipeRef: group.baseRecipeRef,
includeIngredients: group.includeIngredients,
showLabel: group.showLabel,
labelOverride: existingGroup.labelOverride,
itemsBefore: existingGroup.itemsBefore || [],
itemsAfter: existingGroup.itemsAfter || []
};
// Translate labelOverride if changed
if (changeInfo.nameChanged) {
translatedRef.labelOverride = await this.translateText(group.labelOverride || '');
}
// Translate itemsBefore if changed
if (JSON.stringify(group.itemsBefore) !== JSON.stringify(existingGroup.itemsBefore)) {
const textsToTranslate: string[] = [];
(group.itemsBefore || []).forEach((item: any) => {
textsToTranslate.push(item.name || '');
textsToTranslate.push(item.unit || '');
});
const translated = await this.translateBatch(textsToTranslate);
let index = 0;
translatedRef.itemsBefore = (group.itemsBefore || []).map((item: any) => ({
name: translated[index++],
unit: translated[index++],
amount: item.amount,
}));
}
// Translate itemsAfter if changed
if (JSON.stringify(group.itemsAfter) !== JSON.stringify(existingGroup.itemsAfter)) {
const textsToTranslate: string[] = [];
(group.itemsAfter || []).forEach((item: any) => {
textsToTranslate.push(item.name || '');
textsToTranslate.push(item.unit || '');
});
const translated = await this.translateBatch(textsToTranslate);
let index = 0;
translatedRef.itemsAfter = (group.itemsAfter || []).map((item: any) => ({
name: translated[index++],
unit: translated[index++],
amount: item.amount,
}));
}
result.push(translatedRef);
continue;
}
// Handle regular ingredient sections
// If entire group doesn't exist in old version or no change info, translate everything
if (!changeInfo || !existingGroup) {
const textsToTranslate: string[] = [group.name || ''];
@@ -1068,6 +1160,76 @@ class DeepLTranslationService {
const group = newInstructions[i];
const existingGroup = existingTranslatedInstructions[i];
// Handle base recipe references
if (group.type === 'reference') {
// If entire group doesn't exist in old version or no change info, translate all reference fields
if (!changeInfo || !existingGroup) {
const textsToTranslate: string[] = [group.labelOverride || ''];
(group.stepsBefore || []).forEach((step: string) => {
textsToTranslate.push(step || '');
});
(group.stepsAfter || []).forEach((step: string) => {
textsToTranslate.push(step || '');
});
const translated = await this.translateBatch(textsToTranslate);
let index = 0;
result.push({
type: 'reference',
name: group.name,
baseRecipeRef: group.baseRecipeRef,
includeInstructions: group.includeInstructions,
showLabel: group.showLabel,
labelOverride: translated[index++],
stepsBefore: (group.stepsBefore || []).map(() => translated[index++]),
stepsAfter: (group.stepsAfter || []).map(() => translated[index++])
});
continue;
}
// Reference changed - translate changed fields
const translatedRef: any = {
type: 'reference',
name: group.name,
baseRecipeRef: group.baseRecipeRef,
includeInstructions: group.includeInstructions,
showLabel: group.showLabel,
labelOverride: existingGroup.labelOverride,
stepsBefore: existingGroup.stepsBefore || [],
stepsAfter: existingGroup.stepsAfter || []
};
// Translate labelOverride if changed
if (changeInfo.nameChanged) {
translatedRef.labelOverride = await this.translateText(group.labelOverride || '');
}
// Translate stepsBefore if changed
if (JSON.stringify(group.stepsBefore) !== JSON.stringify(existingGroup.stepsBefore)) {
const textsToTranslate: string[] = [];
(group.stepsBefore || []).forEach((step: string) => {
textsToTranslate.push(step || '');
});
const translated = await this.translateBatch(textsToTranslate);
translatedRef.stepsBefore = translated;
}
// Translate stepsAfter if changed
if (JSON.stringify(group.stepsAfter) !== JSON.stringify(existingGroup.stepsAfter)) {
const textsToTranslate: string[] = [];
(group.stepsAfter || []).forEach((step: string) => {
textsToTranslate.push(step || '');
});
const translated = await this.translateBatch(textsToTranslate);
translatedRef.stepsAfter = translated;
}
result.push(translatedRef);
continue;
}
// Handle regular instruction sections
// If entire group doesn't exist in old version or no change info, translate everything
if (!changeInfo || !existingGroup) {
const textsToTranslate: string[] = [group.name || ''];