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:
@@ -11,6 +11,8 @@ export let isFavorite = false;
|
||||
export let showFavoriteIndicator = false;
|
||||
// to manually override lazy loading for top cards
|
||||
export let loading_strat : "lazy" | "eager" | undefined;
|
||||
// route prefix for language support (/rezepte or /recipes)
|
||||
export let routePrefix = '/rezepte';
|
||||
if(loading_strat === undefined){
|
||||
loading_strat = "lazy"
|
||||
}
|
||||
@@ -27,7 +29,9 @@ onMount(() => {
|
||||
isloaded = document.querySelector("img")?.complete ? true : false
|
||||
})
|
||||
|
||||
const img_name=recipe.short_name + ".webp?v=" + recipe.dateModified
|
||||
// Use germanShortName for images if available (English recipes), otherwise use short_name (German recipes)
|
||||
const imageShortName = recipe.germanShortName || recipe.short_name;
|
||||
const img_name = imageShortName + ".webp?v=" + recipe.dateModified
|
||||
</script>
|
||||
<style>
|
||||
.card_anchor{
|
||||
@@ -253,7 +257,7 @@ const img_name=recipe.short_name + ".webp?v=" + recipe.dateModified
|
||||
|
||||
<div class=card_anchor class:search_me={search} data-tags=[{recipe.tags}]>
|
||||
<div class="card" class:margin_right={do_margin_right}>
|
||||
<a href="/rezepte/{recipe.short_name}" class="card-main-link" aria-label="View recipe: {recipe.name}">
|
||||
<a href="{routePrefix}/{recipe.short_name}" class="card-main-link" aria-label="View recipe: {recipe.name}">
|
||||
<span class="visually-hidden">View recipe: {recipe.name}</span>
|
||||
</a>
|
||||
<div class=div_div_image >
|
||||
@@ -261,24 +265,24 @@ const img_name=recipe.short_name + ".webp?v=" + recipe.dateModified
|
||||
<noscript>
|
||||
<img id=image class="backdrop_blur" src="https://bocken.org/static/rezepte/thumb/{img_name}" loading={loading_strat} alt="{recipe.alt}"/>
|
||||
</noscript>
|
||||
<img class:blur={!isloaded} id=image class="backdrop_blur" src={'https://bocken.org/static/rezepte/thumb/' + recipe.short_name + '.webp'} loading={loading_strat} alt="{recipe.alt}" on:load={() => isloaded=true}/>
|
||||
<img class:blur={!isloaded} id=image class="backdrop_blur" src={'https://bocken.org/static/rezepte/thumb/' + imageShortName + '.webp'} loading={loading_strat} alt="{recipe.alt}" on:load={() => isloaded=true}/>
|
||||
</div>
|
||||
</div>
|
||||
{#if showFavoriteIndicator && isFavorite}
|
||||
<div class="favorite-indicator">❤️</div>
|
||||
{/if}
|
||||
{#if icon_override || recipe.season.includes(current_month)}
|
||||
<a href="/rezepte/icon/{recipe.icon}" class=icon>{recipe.icon}</a>
|
||||
<a href="{routePrefix}/icon/{recipe.icon}" class=icon>{recipe.icon}</a>
|
||||
{/if}
|
||||
<div class="card_title">
|
||||
<a href="/rezepte/category/{recipe.category}" class=category>{recipe.category}</a>
|
||||
<a href="{routePrefix}/category/{recipe.category}" class=category>{recipe.category}</a>
|
||||
<div>
|
||||
<div class=name>{@html recipe.name}</div>
|
||||
<div class=description>{@html recipe.description}</div>
|
||||
</div>
|
||||
<div class=tags>
|
||||
{#each recipe.tags as tag}
|
||||
<a href="/rezepte/tag/{tag}" class=tag>{tag}</a>
|
||||
<a href="{routePrefix}/tag/{tag}" class=tag>{tag}</a>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
137
src/lib/components/EditableIngredients.svelte
Normal file
137
src/lib/components/EditableIngredients.svelte
Normal file
@@ -0,0 +1,137 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
export let ingredients: any[] = [];
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
function handleChange() {
|
||||
dispatch('change', { ingredients });
|
||||
}
|
||||
|
||||
function updateIngredientGroupName(groupIndex: number, event: Event) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
ingredients[groupIndex].name = target.value;
|
||||
handleChange();
|
||||
}
|
||||
|
||||
function updateIngredientItem(groupIndex: number, itemIndex: number, field: string, event: Event) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
ingredients[groupIndex].list[itemIndex][field] = target.value;
|
||||
handleChange();
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.ingredients-editor {
|
||||
background: var(--nord0);
|
||||
border: 1px solid var(--nord3);
|
||||
border-radius: 4px;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
@media(prefers-color-scheme: light) {
|
||||
.ingredients-editor {
|
||||
background: var(--nord5);
|
||||
border-color: var(--nord3);
|
||||
}
|
||||
}
|
||||
|
||||
.ingredient-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.ingredient-group:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.group-name {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
background: var(--nord1);
|
||||
border: 1px solid var(--nord3);
|
||||
border-radius: 4px;
|
||||
color: var(--nord6);
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
@media(prefers-color-scheme: light) {
|
||||
.group-name {
|
||||
background: var(--nord6);
|
||||
color: var(--nord0);
|
||||
}
|
||||
}
|
||||
|
||||
.ingredient-item {
|
||||
display: grid;
|
||||
grid-template-columns: 60px 60px 1fr;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.ingredient-item input {
|
||||
padding: 0.4rem;
|
||||
background: var(--nord1);
|
||||
border: 1px solid var(--nord3);
|
||||
border-radius: 4px;
|
||||
color: var(--nord6);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
@media(prefers-color-scheme: light) {
|
||||
.ingredient-item input {
|
||||
background: var(--nord6);
|
||||
color: var(--nord0);
|
||||
}
|
||||
}
|
||||
|
||||
.ingredient-item input:focus {
|
||||
outline: 2px solid var(--nord14);
|
||||
border-color: var(--nord14);
|
||||
}
|
||||
|
||||
.ingredient-item input.amount {
|
||||
text-align: right;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="ingredients-editor">
|
||||
{#each ingredients as group, groupIndex}
|
||||
<div class="ingredient-group">
|
||||
<input
|
||||
type="text"
|
||||
class="group-name"
|
||||
value={group.name || ''}
|
||||
on:input={(e) => updateIngredientGroupName(groupIndex, e)}
|
||||
placeholder="Ingredient group name"
|
||||
/>
|
||||
{#each group.list as item, itemIndex}
|
||||
<div class="ingredient-item">
|
||||
<input
|
||||
type="text"
|
||||
class="amount"
|
||||
value={item.amount || ''}
|
||||
on:input={(e) => updateIngredientItem(groupIndex, itemIndex, 'amount', e)}
|
||||
placeholder="Amt"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
class="unit"
|
||||
value={item.unit || ''}
|
||||
on:input={(e) => updateIngredientItem(groupIndex, itemIndex, 'unit', e)}
|
||||
placeholder="Unit"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
class="name"
|
||||
value={item.name || ''}
|
||||
on:input={(e) => updateIngredientItem(groupIndex, itemIndex, 'name', e)}
|
||||
placeholder="Ingredient name"
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
140
src/lib/components/EditableInstructions.svelte
Normal file
140
src/lib/components/EditableInstructions.svelte
Normal file
@@ -0,0 +1,140 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
export let instructions: any[] = [];
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
function handleChange() {
|
||||
dispatch('change', { instructions });
|
||||
}
|
||||
|
||||
function updateInstructionGroupName(groupIndex: number, event: Event) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
instructions[groupIndex].name = target.value;
|
||||
handleChange();
|
||||
}
|
||||
|
||||
function updateStep(groupIndex: number, stepIndex: number, event: Event) {
|
||||
const target = event.target as HTMLTextAreaElement;
|
||||
instructions[groupIndex].steps[stepIndex] = target.value;
|
||||
handleChange();
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.instructions-editor {
|
||||
background: var(--nord0);
|
||||
border: 1px solid var(--nord3);
|
||||
border-radius: 4px;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
@media(prefers-color-scheme: light) {
|
||||
.instructions-editor {
|
||||
background: var(--nord5);
|
||||
border-color: var(--nord3);
|
||||
}
|
||||
}
|
||||
|
||||
.instruction-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.instruction-group:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.group-name {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
background: var(--nord1);
|
||||
border: 1px solid var(--nord3);
|
||||
border-radius: 4px;
|
||||
color: var(--nord6);
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
@media(prefers-color-scheme: light) {
|
||||
.group-name {
|
||||
background: var(--nord6);
|
||||
color: var(--nord0);
|
||||
}
|
||||
}
|
||||
|
||||
.step-item {
|
||||
margin-bottom: 0.75rem;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.step-number {
|
||||
min-width: 2rem;
|
||||
padding: 0.4rem 0.5rem;
|
||||
background: var(--nord3);
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
color: var(--nord6);
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
@media(prefers-color-scheme: light) {
|
||||
.step-number {
|
||||
background: var(--nord4);
|
||||
color: var(--nord0);
|
||||
}
|
||||
}
|
||||
|
||||
.step-item textarea {
|
||||
flex: 1;
|
||||
padding: 0.5rem;
|
||||
background: var(--nord1);
|
||||
border: 1px solid var(--nord3);
|
||||
border-radius: 4px;
|
||||
color: var(--nord6);
|
||||
font-size: 0.9rem;
|
||||
font-family: inherit;
|
||||
resize: vertical;
|
||||
min-height: 3rem;
|
||||
}
|
||||
|
||||
@media(prefers-color-scheme: light) {
|
||||
.step-item textarea {
|
||||
background: var(--nord6);
|
||||
color: var(--nord0);
|
||||
}
|
||||
}
|
||||
|
||||
.step-item textarea:focus {
|
||||
outline: 2px solid var(--nord14);
|
||||
border-color: var(--nord14);
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="instructions-editor">
|
||||
{#each instructions as group, groupIndex}
|
||||
<div class="instruction-group">
|
||||
<input
|
||||
type="text"
|
||||
class="group-name"
|
||||
value={group.name || ''}
|
||||
on:input={(e) => updateInstructionGroupName(groupIndex, e)}
|
||||
placeholder="Instruction section name"
|
||||
/>
|
||||
{#each group.steps as step, stepIndex}
|
||||
<div class="step-item">
|
||||
<div class="step-number">{stepIndex + 1}</div>
|
||||
<textarea
|
||||
value={step || ''}
|
||||
on:input={(e) => updateStep(groupIndex, stepIndex, e)}
|
||||
placeholder="Step description"
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
127
src/lib/components/RecipeLanguageSwitcher.svelte
Normal file
127
src/lib/components/RecipeLanguageSwitcher.svelte
Normal file
@@ -0,0 +1,127 @@
|
||||
<script lang="ts">
|
||||
export let germanUrl: string;
|
||||
export let englishUrl: string;
|
||||
export let currentLang: 'de' | 'en' = 'de';
|
||||
export let hasTranslation: boolean = true;
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.language-switcher {
|
||||
position: fixed;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
background: var(--nord0);
|
||||
padding: 0.5rem;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
@media(prefers-color-scheme: light) {
|
||||
.language-switcher {
|
||||
background: var(--nord6);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.language-switcher a {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 4px;
|
||||
text-decoration: none;
|
||||
color: var(--nord4);
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
@media(prefers-color-scheme: light) {
|
||||
.language-switcher a {
|
||||
color: var(--nord2);
|
||||
}
|
||||
}
|
||||
|
||||
.language-switcher a:hover {
|
||||
background: var(--nord3);
|
||||
color: var(--nord6);
|
||||
}
|
||||
|
||||
@media(prefers-color-scheme: light) {
|
||||
.language-switcher a:hover {
|
||||
background: var(--nord4);
|
||||
color: var(--nord0);
|
||||
}
|
||||
}
|
||||
|
||||
.language-switcher a.active {
|
||||
background: var(--nord14);
|
||||
color: var(--nord0);
|
||||
}
|
||||
|
||||
.language-switcher a.active:hover {
|
||||
background: var(--nord15);
|
||||
}
|
||||
|
||||
.language-switcher a.disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.flag {
|
||||
font-size: 1.2rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.language-switcher {
|
||||
top: 0.5rem;
|
||||
right: 0.5rem;
|
||||
padding: 0.25rem;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.language-switcher a {
|
||||
padding: 0.4rem 0.7rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.flag {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="language-switcher">
|
||||
<a
|
||||
href={germanUrl}
|
||||
class:active={currentLang === 'de'}
|
||||
aria-label="Switch to German"
|
||||
>
|
||||
<span class="flag">🇩🇪</span>
|
||||
<span class="label">DE</span>
|
||||
</a>
|
||||
{#if hasTranslation}
|
||||
<a
|
||||
href={englishUrl}
|
||||
class:active={currentLang === 'en'}
|
||||
aria-label="Switch to English"
|
||||
>
|
||||
<span class="flag">🇬🇧</span>
|
||||
<span class="label">EN</span>
|
||||
</a>
|
||||
{:else}
|
||||
<span
|
||||
class="disabled"
|
||||
title="English translation not available"
|
||||
aria-label="English translation not available"
|
||||
>
|
||||
<span class="flag">🇬🇧</span>
|
||||
<span class="label">EN</span>
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
677
src/lib/components/TranslationApproval.svelte
Normal file
677
src/lib/components/TranslationApproval.svelte
Normal file
@@ -0,0 +1,677 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import type { TranslatedRecipeType } from '$types/types';
|
||||
import TranslationFieldComparison from './TranslationFieldComparison.svelte';
|
||||
import EditableIngredients from './EditableIngredients.svelte';
|
||||
import EditableInstructions from './EditableInstructions.svelte';
|
||||
|
||||
export let germanData: any;
|
||||
export let englishData: TranslatedRecipeType | null = null;
|
||||
export let changedFields: string[] = [];
|
||||
export let isEditMode: boolean = false; // true when editing existing recipe
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
type TranslationState = 'idle' | 'translating' | 'preview' | 'approved' | 'error';
|
||||
let translationState: TranslationState = englishData ? 'preview' : 'idle';
|
||||
let errorMessage: string = '';
|
||||
let validationErrors: string[] = [];
|
||||
|
||||
// Editable English data (clone of englishData)
|
||||
let editableEnglish: any = englishData ? { ...englishData } : null;
|
||||
|
||||
// Handle auto-translate button click
|
||||
async function handleAutoTranslate() {
|
||||
translationState = 'translating';
|
||||
errorMessage = '';
|
||||
validationErrors = [];
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/rezepte/translate', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
recipe: germanData,
|
||||
fields: isEditMode && changedFields.length > 0 ? changedFields : undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.message || 'Translation failed');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
editableEnglish = result.translatedRecipe;
|
||||
translationState = 'preview';
|
||||
|
||||
// Notify parent component
|
||||
dispatch('translated', { translatedRecipe: editableEnglish });
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('Translation error:', error);
|
||||
translationState = 'error';
|
||||
errorMessage = error.message || 'Translation failed. Please try again.';
|
||||
}
|
||||
}
|
||||
|
||||
// Handle field changes from TranslationFieldComparison components
|
||||
function handleFieldChange(event: CustomEvent) {
|
||||
const { field, value } = event.detail;
|
||||
if (editableEnglish) {
|
||||
// Special handling for tags (comma-separated string -> array)
|
||||
if (field === 'tags') {
|
||||
editableEnglish[field] = value.split(',').map((t: string) => t.trim()).filter((t: string) => t);
|
||||
} else {
|
||||
editableEnglish[field] = value;
|
||||
}
|
||||
editableEnglish = editableEnglish; // Trigger reactivity
|
||||
}
|
||||
}
|
||||
|
||||
// Handle ingredients changes
|
||||
function handleIngredientsChange(event: CustomEvent) {
|
||||
if (editableEnglish) {
|
||||
editableEnglish.ingredients = event.detail.ingredients;
|
||||
editableEnglish = editableEnglish; // Trigger reactivity
|
||||
}
|
||||
}
|
||||
|
||||
// Handle instructions changes
|
||||
function handleInstructionsChange(event: CustomEvent) {
|
||||
if (editableEnglish) {
|
||||
editableEnglish.instructions = event.detail.instructions;
|
||||
editableEnglish = editableEnglish; // Trigger reactivity
|
||||
}
|
||||
}
|
||||
|
||||
// Handle approval
|
||||
function handleApprove() {
|
||||
// Validate required fields
|
||||
validationErrors = [];
|
||||
|
||||
if (!editableEnglish?.name) {
|
||||
validationErrors.push('English name is required');
|
||||
}
|
||||
if (!editableEnglish?.description) {
|
||||
validationErrors.push('English description is required');
|
||||
}
|
||||
if (!editableEnglish?.short_name) {
|
||||
validationErrors.push('English short_name is required');
|
||||
}
|
||||
|
||||
if (validationErrors.length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
translationState = 'approved';
|
||||
dispatch('approved', {
|
||||
translatedRecipe: {
|
||||
...editableEnglish,
|
||||
translationStatus: 'approved',
|
||||
lastTranslated: new Date(),
|
||||
changedFields: [],
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Handle skip translation
|
||||
function handleSkip() {
|
||||
dispatch('skipped');
|
||||
}
|
||||
|
||||
// Handle cancel
|
||||
function handleCancel() {
|
||||
translationState = 'idle';
|
||||
editableEnglish = null;
|
||||
dispatch('cancelled');
|
||||
}
|
||||
|
||||
// Get status badge color
|
||||
function getStatusColor(status: string): string {
|
||||
switch (status) {
|
||||
case 'approved': return 'var(--nord14)';
|
||||
case 'pending': return 'var(--nord13)';
|
||||
case 'needs_update': return 'var(--nord12)';
|
||||
default: return 'var(--nord9)';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.translation-approval {
|
||||
margin: 2rem 0;
|
||||
padding: 1.5rem;
|
||||
border: 2px solid var(--nord9);
|
||||
border-radius: 8px;
|
||||
background: var(--nord1);
|
||||
}
|
||||
|
||||
@media(prefers-color-scheme: light) {
|
||||
.translation-approval {
|
||||
background: var(--nord6);
|
||||
border-color: var(--nord4);
|
||||
}
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.header h3 {
|
||||
margin: 0;
|
||||
color: var(--nord6);
|
||||
}
|
||||
|
||||
@media(prefers-color-scheme: light) {
|
||||
.header h3 {
|
||||
color: var(--nord0);
|
||||
}
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 16px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--nord0);
|
||||
}
|
||||
|
||||
.status-pending {
|
||||
background: var(--nord13);
|
||||
}
|
||||
|
||||
.status-approved {
|
||||
background: var(--nord14);
|
||||
}
|
||||
|
||||
.status-needs_update {
|
||||
background: var(--nord12);
|
||||
}
|
||||
|
||||
.comparison-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1.5rem;
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.comparison-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.column-header {
|
||||
font-weight: 700;
|
||||
font-size: 1.1rem;
|
||||
color: var(--nord8);
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 2px solid var(--nord9);
|
||||
}
|
||||
|
||||
.field-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: flex-end;
|
||||
margin-top: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 0.75rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--nord14);
|
||||
color: var(--nord0);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--nord15);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--nord9);
|
||||
color: var(--nord6);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: var(--nord10);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--nord11);
|
||||
color: var(--nord6);
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: var(--nord12);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
display: inline-block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 3px solid var(--nord4);
|
||||
border-top-color: var(--nord14);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background: var(--nord11);
|
||||
color: var(--nord6);
|
||||
padding: 1rem;
|
||||
border-radius: 4px;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.validation-errors {
|
||||
background: var(--nord12);
|
||||
color: var(--nord0);
|
||||
padding: 1rem;
|
||||
border-radius: 4px;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.validation-errors ul {
|
||||
margin: 0.5rem 0 0 0;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
.changed-fields {
|
||||
background: var(--nord13);
|
||||
color: var(--nord0);
|
||||
padding: 0.75rem;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.changed-fields strong {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.idle-state {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: var(--nord4);
|
||||
}
|
||||
|
||||
@media(prefers-color-scheme: light) {
|
||||
.idle-state {
|
||||
color: var(--nord2);
|
||||
}
|
||||
}
|
||||
|
||||
.idle-state p {
|
||||
margin-bottom: 1rem;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="translation-approval">
|
||||
<div class="header">
|
||||
<h3>English Translation</h3>
|
||||
{#if editableEnglish?.translationStatus}
|
||||
<span class="status-badge status-{editableEnglish.translationStatus}">
|
||||
{editableEnglish.translationStatus === 'pending' ? 'Pending Approval' : ''}
|
||||
{editableEnglish.translationStatus === 'approved' ? 'Approved' : ''}
|
||||
{editableEnglish.translationStatus === 'needs_update' ? 'Needs Update' : ''}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if errorMessage}
|
||||
<div class="error-message">
|
||||
<strong>Error:</strong> {errorMessage}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if validationErrors.length > 0}
|
||||
<div class="validation-errors">
|
||||
<strong>Please fix the following errors:</strong>
|
||||
<ul>
|
||||
{#each validationErrors as error}
|
||||
<li>{error}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if isEditMode && changedFields.length > 0}
|
||||
<div class="changed-fields">
|
||||
<strong>Changed fields:</strong> {changedFields.join(', ')}
|
||||
<br>
|
||||
<small>Only these fields will be re-translated if you use auto-translate.</small>
|
||||
</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>
|
||||
|
||||
{:else if translationState === 'translating'}
|
||||
<div class="idle-state">
|
||||
<p>
|
||||
<span class="loading-spinner"></span>
|
||||
Translating recipe...
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{:else if translationState === 'preview' || translationState === 'approved'}
|
||||
<div class="comparison-grid">
|
||||
<div>
|
||||
<div class="column-header">🇩🇪 German (Original)</div>
|
||||
|
||||
<div class="field-group">
|
||||
<TranslationFieldComparison
|
||||
label="Name"
|
||||
germanValue={germanData.name}
|
||||
englishValue={editableEnglish?.name || ''}
|
||||
fieldName="name"
|
||||
readonly={true}
|
||||
on:change={handleFieldChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field-group">
|
||||
<TranslationFieldComparison
|
||||
label="Short Name (URL)"
|
||||
germanValue={germanData.short_name}
|
||||
englishValue={editableEnglish?.short_name || ''}
|
||||
fieldName="short_name"
|
||||
readonly={true}
|
||||
on:change={handleFieldChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field-group">
|
||||
<TranslationFieldComparison
|
||||
label="Description"
|
||||
germanValue={germanData.description}
|
||||
englishValue={editableEnglish?.description || ''}
|
||||
fieldName="description"
|
||||
readonly={true}
|
||||
multiline={true}
|
||||
on:change={handleFieldChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field-group">
|
||||
<TranslationFieldComparison
|
||||
label="Category"
|
||||
germanValue={germanData.category}
|
||||
englishValue={editableEnglish?.category || ''}
|
||||
fieldName="category"
|
||||
readonly={true}
|
||||
on:change={handleFieldChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if germanData.tags && germanData.tags.length > 0}
|
||||
<div class="field-group">
|
||||
<TranslationFieldComparison
|
||||
label="Tags"
|
||||
germanValue={germanData.tags.join(', ')}
|
||||
englishValue={editableEnglish?.tags?.join(', ') || ''}
|
||||
fieldName="tags"
|
||||
readonly={true}
|
||||
on:change={handleFieldChange}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if germanData.preamble}
|
||||
<div class="field-group">
|
||||
<TranslationFieldComparison
|
||||
label="Preamble"
|
||||
germanValue={germanData.preamble}
|
||||
englishValue={editableEnglish?.preamble || ''}
|
||||
fieldName="preamble"
|
||||
readonly={true}
|
||||
multiline={true}
|
||||
on:change={handleFieldChange}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if germanData.addendum}
|
||||
<div class="field-group">
|
||||
<TranslationFieldComparison
|
||||
label="Addendum"
|
||||
germanValue={germanData.addendum}
|
||||
englishValue={editableEnglish?.addendum || ''}
|
||||
fieldName="addendum"
|
||||
readonly={true}
|
||||
multiline={true}
|
||||
on:change={handleFieldChange}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if germanData.note}
|
||||
<div class="field-group">
|
||||
<TranslationFieldComparison
|
||||
label="Note"
|
||||
germanValue={germanData.note}
|
||||
englishValue={editableEnglish?.note || ''}
|
||||
fieldName="note"
|
||||
readonly={true}
|
||||
multiline={true}
|
||||
on:change={handleFieldChange}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if germanData.ingredients && germanData.ingredients.length > 0}
|
||||
<div class="field-group">
|
||||
<div class="field-label">Ingredients</div>
|
||||
<div class="field-value readonly readonly-text">
|
||||
{#each germanData.ingredients as ing}
|
||||
<strong>{ing.name || 'Ingredients'}</strong>
|
||||
<ul>
|
||||
{#each ing.list as item}
|
||||
<li>{item.amount} {item.unit} {item.name}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if germanData.instructions && germanData.instructions.length > 0}
|
||||
<div class="field-group">
|
||||
<div class="field-label">Instructions</div>
|
||||
<div class="field-value readonly readonly-text">
|
||||
{#each germanData.instructions as inst}
|
||||
<strong>{inst.name || 'Steps'}</strong>
|
||||
<ol>
|
||||
{#each inst.steps as step}
|
||||
<li>{step}</li>
|
||||
{/each}
|
||||
</ol>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="column-header">🇬🇧 English (Translated)</div>
|
||||
|
||||
<div class="field-group">
|
||||
<TranslationFieldComparison
|
||||
label="Name"
|
||||
germanValue={germanData.name}
|
||||
englishValue={editableEnglish?.name || ''}
|
||||
fieldName="name"
|
||||
readonly={false}
|
||||
on:change={handleFieldChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field-group">
|
||||
<TranslationFieldComparison
|
||||
label="Short Name (URL)"
|
||||
germanValue={germanData.short_name}
|
||||
englishValue={editableEnglish?.short_name || ''}
|
||||
fieldName="short_name"
|
||||
readonly={false}
|
||||
on:change={handleFieldChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field-group">
|
||||
<TranslationFieldComparison
|
||||
label="Description"
|
||||
germanValue={germanData.description}
|
||||
englishValue={editableEnglish?.description || ''}
|
||||
fieldName="description"
|
||||
readonly={false}
|
||||
multiline={true}
|
||||
on:change={handleFieldChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field-group">
|
||||
<TranslationFieldComparison
|
||||
label="Category"
|
||||
germanValue={germanData.category}
|
||||
englishValue={editableEnglish?.category || ''}
|
||||
fieldName="category"
|
||||
readonly={false}
|
||||
on:change={handleFieldChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if editableEnglish?.tags}
|
||||
<div class="field-group">
|
||||
<TranslationFieldComparison
|
||||
label="Tags"
|
||||
germanValue={germanData.tags?.join(', ') || ''}
|
||||
englishValue={editableEnglish.tags.join(', ')}
|
||||
fieldName="tags"
|
||||
readonly={false}
|
||||
on:change={handleFieldChange}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if editableEnglish?.preamble}
|
||||
<div class="field-group">
|
||||
<TranslationFieldComparison
|
||||
label="Preamble"
|
||||
germanValue={germanData.preamble}
|
||||
englishValue={editableEnglish.preamble}
|
||||
fieldName="preamble"
|
||||
readonly={false}
|
||||
multiline={true}
|
||||
on:change={handleFieldChange}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if editableEnglish?.addendum}
|
||||
<div class="field-group">
|
||||
<TranslationFieldComparison
|
||||
label="Addendum"
|
||||
germanValue={germanData.addendum}
|
||||
englishValue={editableEnglish.addendum}
|
||||
fieldName="addendum"
|
||||
readonly={false}
|
||||
multiline={true}
|
||||
on:change={handleFieldChange}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if editableEnglish?.note}
|
||||
<div class="field-group">
|
||||
<TranslationFieldComparison
|
||||
label="Note"
|
||||
germanValue={germanData.note}
|
||||
englishValue={editableEnglish.note}
|
||||
fieldName="note"
|
||||
readonly={false}
|
||||
multiline={true}
|
||||
on:change={handleFieldChange}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if editableEnglish?.ingredients && editableEnglish.ingredients.length > 0}
|
||||
<div class="field-group">
|
||||
<div class="field-label">Ingredients (Editable)</div>
|
||||
<EditableIngredients
|
||||
ingredients={editableEnglish.ingredients}
|
||||
on:change={handleIngredientsChange}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if editableEnglish?.instructions && editableEnglish.instructions.length > 0}
|
||||
<div class="field-group">
|
||||
<div class="field-label">Instructions (Editable)</div>
|
||||
<EditableInstructions
|
||||
instructions={editableEnglish.instructions}
|
||||
on:change={handleInstructionsChange}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
{#if translationState !== 'approved'}
|
||||
<button class="btn-danger" on:click={handleCancel}>
|
||||
Cancel
|
||||
</button>
|
||||
<button class="btn-secondary" on:click={handleAutoTranslate}>
|
||||
Re-translate
|
||||
</button>
|
||||
<button class="btn-primary" on:click={handleApprove}>
|
||||
Approve Translation
|
||||
</button>
|
||||
{:else}
|
||||
<span style="color: var(--nord14); font-weight: 700;">✓ Translation Approved</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
142
src/lib/components/TranslationFieldComparison.svelte
Normal file
142
src/lib/components/TranslationFieldComparison.svelte
Normal file
@@ -0,0 +1,142 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
export let label: string;
|
||||
export let germanValue: string;
|
||||
export let englishValue: string;
|
||||
export let fieldName: string;
|
||||
export let readonly: boolean = false;
|
||||
export let multiline: boolean = false;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
function handleInput(event: Event) {
|
||||
const target = event.target as HTMLInputElement | HTMLTextAreaElement;
|
||||
dispatch('change', {
|
||||
field: fieldName,
|
||||
value: target.value
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.field-comparison {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
font-weight: 600;
|
||||
color: var(--nord4);
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
@media(prefers-color-scheme: light) {
|
||||
.field-label {
|
||||
color: var(--nord2);
|
||||
}
|
||||
}
|
||||
|
||||
.field-value {
|
||||
padding: 0.75rem;
|
||||
background: var(--nord0);
|
||||
border-radius: 4px;
|
||||
color: var(--nord6);
|
||||
border: 1px solid var(--nord3);
|
||||
min-height: 3rem;
|
||||
}
|
||||
|
||||
@media(prefers-color-scheme: light) {
|
||||
.field-value {
|
||||
background: var(--nord5);
|
||||
color: var(--nord0);
|
||||
border-color: var(--nord3);
|
||||
}
|
||||
}
|
||||
|
||||
.field-value.readonly {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
input.field-value,
|
||||
textarea.field-value {
|
||||
width: 100%;
|
||||
font-family: inherit;
|
||||
font-size: 1rem;
|
||||
box-sizing: border-box;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
input.field-value:focus,
|
||||
textarea.field-value:focus {
|
||||
outline: 2px solid var(--nord14);
|
||||
border-color: var(--nord14);
|
||||
}
|
||||
|
||||
textarea.field-value {
|
||||
min-height: 6rem;
|
||||
}
|
||||
|
||||
.readonly-text {
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
:global(.readonly-text strong) {
|
||||
display: block;
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--nord8);
|
||||
}
|
||||
|
||||
:global(.readonly-text strong:first-child) {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
:global(.readonly-text ul),
|
||||
:global(.readonly-text ol) {
|
||||
margin: 0.5rem 0;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
:global(.readonly-text li) {
|
||||
margin: 0.25rem 0;
|
||||
color: var(--nord4);
|
||||
}
|
||||
|
||||
@media(prefers-color-scheme: light) {
|
||||
:global(.readonly-text strong) {
|
||||
color: var(--nord10);
|
||||
}
|
||||
|
||||
:global(.readonly-text li) {
|
||||
color: var(--nord2);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="field-comparison">
|
||||
<div class="field-label">{label}</div>
|
||||
{#if readonly}
|
||||
<div class="field-value readonly readonly-text">
|
||||
{germanValue || '(empty)'}
|
||||
</div>
|
||||
{:else if multiline}
|
||||
<textarea
|
||||
class="field-value"
|
||||
value={englishValue}
|
||||
on:input={handleInput}
|
||||
placeholder="Enter {label.toLowerCase()}..."
|
||||
/>
|
||||
{:else}
|
||||
<input
|
||||
type="text"
|
||||
class="field-value"
|
||||
value={englishValue}
|
||||
on:input={handleInput}
|
||||
placeholder="Enter {label.toLowerCase()}..."
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -39,7 +39,53 @@ const RecipeSchema = new mongoose.Schema(
|
||||
steps: [String]}],
|
||||
preamble : String,
|
||||
addendum : String,
|
||||
|
||||
// English translations
|
||||
translations: {
|
||||
en: {
|
||||
short_name: {type: String}, // English slug for URLs
|
||||
name: {type: String},
|
||||
description: {type: String},
|
||||
preamble: {type: String},
|
||||
addendum: {type: String},
|
||||
note: {type: String},
|
||||
category: {type: String},
|
||||
tags: [String],
|
||||
ingredients: [{
|
||||
name: {type: String, default: ""},
|
||||
list: [{
|
||||
name: {type: String, default: ""},
|
||||
unit: String,
|
||||
amount: String,
|
||||
}]
|
||||
}],
|
||||
instructions: [{
|
||||
name: {type: String, default: ""},
|
||||
steps: [String]
|
||||
}],
|
||||
images: [{
|
||||
alt: String,
|
||||
caption: String,
|
||||
}],
|
||||
translationStatus: {
|
||||
type: String,
|
||||
enum: ['pending', 'approved', 'needs_update'],
|
||||
default: 'pending'
|
||||
},
|
||||
lastTranslated: {type: Date},
|
||||
changedFields: [String],
|
||||
}
|
||||
},
|
||||
|
||||
// Translation metadata for tracking changes
|
||||
translationMetadata: {
|
||||
lastModifiedGerman: {type: Date},
|
||||
fieldsModifiedSinceTranslation: [String],
|
||||
},
|
||||
}, {timestamps: true}
|
||||
);
|
||||
|
||||
// Indexes for efficient querying
|
||||
RecipeSchema.index({ "translations.en.short_name": 1 });
|
||||
|
||||
export const Recipe = mongoose.model("Recipe", RecipeSchema);
|
||||
|
||||
96
src/routes/api/recipes/items/[name]/+server.ts
Normal file
96
src/routes/api/recipes/items/[name]/+server.ts
Normal file
@@ -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');
|
||||
}
|
||||
};
|
||||
31
src/routes/api/recipes/items/all_brief/+server.ts
Normal file
31
src/routes/api/recipes/items/all_brief/+server.ts
Normal file
@@ -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))));
|
||||
};
|
||||
35
src/routes/api/recipes/items/category/[category]/+server.ts
Normal file
35
src/routes/api/recipes/items/category/[category]/+server.ts
Normal file
@@ -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))));
|
||||
};
|
||||
35
src/routes/api/recipes/items/icon/[icon]/+server.ts
Normal file
35
src/routes/api/recipes/items/icon/[icon]/+server.ts
Normal file
@@ -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))));
|
||||
};
|
||||
35
src/routes/api/recipes/items/in_season/[month]/+server.ts
Normal file
35
src/routes/api/recipes/items/in_season/[month]/+server.ts
Normal file
@@ -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))));
|
||||
};
|
||||
35
src/routes/api/recipes/items/tag/[tag]/+server.ts
Normal file
35
src/routes/api/recipes/items/tag/[tag]/+server.ts
Normal file
@@ -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))));
|
||||
};
|
||||
88
src/routes/api/rezepte/translate/+server.ts
Normal file
88
src/routes/api/rezepte/translate/+server.ts
Normal file
@@ -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 });
|
||||
}
|
||||
};
|
||||
7
src/routes/recipes/+layout.server.ts
Normal file
7
src/routes/recipes/+layout.server.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { LayoutServerLoad } from "./$types"
|
||||
|
||||
export const load : LayoutServerLoad = async ({locals}) => {
|
||||
return {
|
||||
session: await locals.auth()
|
||||
}
|
||||
};
|
||||
25
src/routes/recipes/+layout.svelte
Normal file
25
src/routes/recipes/+layout.svelte
Normal file
@@ -0,0 +1,25 @@
|
||||
<script>
|
||||
import Header from '$lib/components/Header.svelte'
|
||||
import UserHeader from '$lib/components/UserHeader.svelte';
|
||||
export let data
|
||||
let user;
|
||||
if(data.session){
|
||||
user = data.session.user
|
||||
}
|
||||
</script>
|
||||
|
||||
<Header>
|
||||
<ul class=site_header slot=links>
|
||||
<li><a href="/recipes">All Recipes</a></li>
|
||||
{#if user}
|
||||
<li><a href="/rezepte/favorites">Favorites</a></li>
|
||||
{/if}
|
||||
<li><a href="/recipes/season">In Season</a></li>
|
||||
<li><a href="/recipes/category">Category</a></li>
|
||||
<li><a href="/recipes/icon">Icon</a></li>
|
||||
<li><a href="/recipes/tag">Keywords</a></li>
|
||||
<li><a href="/rezepte/tips-and-tricks">Tips</a></li>
|
||||
</ul>
|
||||
<UserHeader slot=right_side {user}></UserHeader>
|
||||
<slot></slot>
|
||||
</Header>
|
||||
22
src/routes/recipes/+page.server.ts
Normal file
22
src/routes/recipes/+page.server.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { PageServerLoad } from "./$types";
|
||||
import { getUserFavorites, addFavoriteStatusToRecipes } from "$lib/server/favorites";
|
||||
|
||||
export const load: PageServerLoad = async ({ fetch, locals }) => {
|
||||
let current_month = new Date().getMonth() + 1
|
||||
const res_season = await fetch(`/api/recipes/items/in_season/` + current_month);
|
||||
const res_all_brief = await fetch(`/api/recipes/items/all_brief`);
|
||||
const item_season = await res_season.json();
|
||||
const item_all_brief = await res_all_brief.json();
|
||||
|
||||
// Get user favorites and session
|
||||
const [userFavorites, session] = await Promise.all([
|
||||
getUserFavorites(fetch, locals),
|
||||
locals.auth()
|
||||
]);
|
||||
|
||||
return {
|
||||
season: addFavoriteStatusToRecipes(item_season, userFavorites),
|
||||
all_brief: addFavoriteStatusToRecipes(item_all_brief, userFavorites),
|
||||
session
|
||||
};
|
||||
};
|
||||
50
src/routes/recipes/+page.svelte
Normal file
50
src/routes/recipes/+page.svelte
Normal file
@@ -0,0 +1,50 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
import MediaScroller from '$lib/components/MediaScroller.svelte';
|
||||
import AddButton from '$lib/components/AddButton.svelte';
|
||||
import Card from '$lib/components/Card.svelte';
|
||||
import Search from '$lib/components/Search.svelte';
|
||||
export let data: PageData;
|
||||
export let current_month = new Date().getMonth() + 1
|
||||
const categories = ["Main Course", "Pasta", "Bread", "Dessert", "Soup", "Side Dish", "Salad", "Cake", "Breakfast", "Sauce", "Ingredient", "Drink", "Spread", "Cookie", "Snack"]
|
||||
</script>
|
||||
<style>
|
||||
h1{
|
||||
text-align: center;
|
||||
margin-bottom: 0;
|
||||
font-size: 4rem;
|
||||
}
|
||||
.subheading{
|
||||
text-align: center;
|
||||
margin-top: 0;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
</style>
|
||||
<svelte:head>
|
||||
<title>Bocken Recipes</title>
|
||||
<meta name="description" content="A constantly growing collection of recipes from Bocken's kitchen." />
|
||||
<meta property="og:image" content="https://bocken.org/static/rezepte/thumb/ragu_aus_rindsrippen.webp" />
|
||||
<meta property="og:image:secure_url" content="https://bocken.org/static/rezepte/thumb/ragu_aus_rindsrippen.webp" />
|
||||
<meta property="og:image:type" content="image/webp" />
|
||||
<meta property="og:image:alt" content="Pasta al Ragu with Linguine" />
|
||||
</svelte:head>
|
||||
|
||||
<h1>Recipes</h1>
|
||||
<p class=subheading>{data.all_brief.length} recipes and constantly growing...</p>
|
||||
|
||||
<Search></Search>
|
||||
|
||||
<MediaScroller title="In Season">
|
||||
{#each data.season as recipe}
|
||||
<Card {recipe} {current_month} loading_strat={"eager"} do_margin_right={true} isFavorite={recipe.isFavorite} showFavoriteIndicator={!!data.session?.user} routePrefix="/recipes"></Card>
|
||||
{/each}
|
||||
</MediaScroller>
|
||||
|
||||
{#each categories as category}
|
||||
<MediaScroller title={category}>
|
||||
{#each data.all_brief.filter(recipe => recipe.category == category) as recipe}
|
||||
<Card {recipe} {current_month} do_margin_right={true} isFavorite={recipe.isFavorite} showFavoriteIndicator={!!data.session?.user} routePrefix="/recipes"></Card>
|
||||
{/each}
|
||||
</MediaScroller>
|
||||
{/each}
|
||||
<AddButton href="/rezepte/add"></AddButton>
|
||||
352
src/routes/recipes/[name]/+page.svelte
Normal file
352
src/routes/recipes/[name]/+page.svelte
Normal file
@@ -0,0 +1,352 @@
|
||||
<script lang="ts">
|
||||
import { writable } from 'svelte/store';
|
||||
export const multiplier = writable(0);
|
||||
|
||||
import type { PageData } from './$types';
|
||||
import "$lib/css/nordtheme.css"
|
||||
import EditButton from '$lib/components/EditButton.svelte';
|
||||
import InstructionsPage from '$lib/components/InstructionsPage.svelte';
|
||||
import IngredientsPage from '$lib/components/IngredientsPage.svelte';
|
||||
import TitleImgParallax from '$lib/components/TitleImgParallax.svelte';
|
||||
import { afterNavigate } from '$app/navigation';
|
||||
import {season} from '$lib/js/season_store';
|
||||
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;
|
||||
|
||||
// Use German short_name for images (they're the same for both languages)
|
||||
let hero_img_src = "https://bocken.org/static/rezepte/full/" + data.germanShortName + ".webp?v=" + data.dateModified
|
||||
let placeholder_src = "https://bocken.org/static/rezepte/placeholder/" + data.germanShortName + ".webp?v=" + data.dateModified
|
||||
export let months = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]
|
||||
|
||||
function season_intervals() {
|
||||
let interval_arr = []
|
||||
|
||||
let start_i = 0
|
||||
for(var i = 12; i > 0; i--){
|
||||
if(data.season.includes(i)){
|
||||
start_i = data.season.indexOf(i);
|
||||
}
|
||||
else{
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
var start = data.season[start_i]
|
||||
var end_i
|
||||
const len = data.season.length
|
||||
for(var i = 0; i < len -1; i++){
|
||||
if(data.season.includes((start + i) %12 + 1)){
|
||||
end_i = (start_i + i + 1) % len
|
||||
}
|
||||
else{
|
||||
interval_arr.push([start, data.season[end_i]])
|
||||
start = data.season[(start + i + 1) % len]
|
||||
}
|
||||
|
||||
}
|
||||
if(interval_arr.length == 0){
|
||||
interval_arr.push([start, data.season[end_i]])
|
||||
}
|
||||
|
||||
return interval_arr
|
||||
}
|
||||
export let season_iv = season_intervals();
|
||||
|
||||
afterNavigate(() => {
|
||||
hero_img_src = "https://bocken.org/static/rezepte/full/" + data.germanShortName + ".webp"
|
||||
placeholder_src = "https://bocken.org/static/rezepte/placeholder/" + data.germanShortName + ".webp"
|
||||
season_iv = season_intervals();
|
||||
})
|
||||
let display_date = new Date(data.dateCreated);
|
||||
if (data.updatedAt){
|
||||
display_date = new Date(data.updatedAt);
|
||||
}
|
||||
const options = {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
};
|
||||
const formatted_display_date = display_date.toLocaleDateString('en-US', options)
|
||||
</script>
|
||||
<style>
|
||||
*{
|
||||
font-family: sans-serif;
|
||||
}
|
||||
h1{
|
||||
text-align: center;
|
||||
padding-block: 0.5em;
|
||||
border-radius: 10000px;
|
||||
margin:0;
|
||||
font-size: 3rem;
|
||||
overflow-wrap: break-word;
|
||||
hyphens: auto;
|
||||
text-wrap: balance;
|
||||
}
|
||||
.category{
|
||||
--size: 1.75rem;
|
||||
position: absolute;
|
||||
top: calc(-1* var(--size) );
|
||||
left:calc(-3/2 * var(--size));
|
||||
background-color: var(--nord0);
|
||||
color: var(--nord6);
|
||||
text-decoration: none;
|
||||
font-size: var(--size);
|
||||
padding: calc(var(--size) * 2/3);
|
||||
border-radius: 1000px;
|
||||
transition: 100ms;
|
||||
box-shadow: 0em 0em 1em 0.3em rgba(0,0,0,0.4);
|
||||
}
|
||||
.category:hover,
|
||||
.category:focus-visible{
|
||||
background-color: var(--nord1);
|
||||
scale: 1.1;
|
||||
}
|
||||
.tags{
|
||||
margin-block: 1rem;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 1em;
|
||||
}
|
||||
.center{
|
||||
justify-content: center;
|
||||
}
|
||||
.tag{
|
||||
all:unset;
|
||||
color: var(--nord0);
|
||||
font-size: 1.1rem;
|
||||
background-color: var(--nord5);
|
||||
border-radius: 10000px;
|
||||
padding: 0.25em 1em;
|
||||
transition: 100ms;
|
||||
box-shadow: 0em 0em 0.5em 0.05em rgba(0,0,0,0.3);
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.tag{
|
||||
background-color: var(--nord0);
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
.tag:hover,
|
||||
.tag:focus-visible
|
||||
{
|
||||
cursor: pointer;
|
||||
transform: scale(1.1,1.1);
|
||||
background-color: var(--orange);
|
||||
box-shadow: 0.1em 0.1em 0.5em 0.1em rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
.wrapper_wrapper{
|
||||
background-color: #fbf9f3;
|
||||
padding-top: 10rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-bottom: 3rem;
|
||||
transform: translateY(-7rem);
|
||||
z-index: -2;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.wrapper_wrapper{
|
||||
background-color: var(--background-dark);
|
||||
}
|
||||
}
|
||||
|
||||
.wrapper{
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
max-width: 1000px;
|
||||
justify-content: center;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 700px){
|
||||
.wrapper{
|
||||
flex-direction:column;
|
||||
}
|
||||
}
|
||||
.title{
|
||||
position: relative;
|
||||
width: min(800px, 80vw);
|
||||
margin-inline: auto;
|
||||
background-color: var(--nord6);
|
||||
padding: 1rem 2rem;
|
||||
translate: 0 1px; /*bruh*/
|
||||
z-index: 1;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.title{
|
||||
background-color: var(--nord6-dark);
|
||||
}
|
||||
}
|
||||
|
||||
.icon{
|
||||
font-family: "Noto Color Emoji", emoji;
|
||||
position: absolute;
|
||||
top: -1em;
|
||||
right: -0.75em;
|
||||
text-decoration: unset;
|
||||
background-color: #FAFAFE;
|
||||
padding: 0.5em;
|
||||
font-size: 1.5rem;
|
||||
border-radius: 100000px;
|
||||
transition: 100ms;
|
||||
box-shadow: 0em 0em 1em 0.3em rgba(0,0,0,0.4);
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.icon{
|
||||
background-color: var(--accent-dark);
|
||||
}
|
||||
}
|
||||
|
||||
.icon:hover,
|
||||
.icon:focus-visible{
|
||||
scale: 1.2 1.2;
|
||||
animation: shake 0.5s ease forwards;
|
||||
}
|
||||
|
||||
h4{
|
||||
margin-block: 0;
|
||||
}
|
||||
.addendum{
|
||||
max-width: 800px;
|
||||
margin-inline: auto;
|
||||
padding-inline: 2rem;
|
||||
}
|
||||
@media screen and (max-width: 800px){
|
||||
.title{
|
||||
width: 100%;
|
||||
}
|
||||
.icon{
|
||||
right: 1rem;
|
||||
top: -1.75rem;
|
||||
}
|
||||
.category{
|
||||
left: 1rem;
|
||||
top: calc(var(--size) * -1.5);
|
||||
}
|
||||
}
|
||||
@keyframes shake{
|
||||
0%{
|
||||
transform: rotate(0)
|
||||
scale(1,1);
|
||||
}
|
||||
25%{
|
||||
box-shadow: 0em 0em 1em 0.2em rgba(0, 0, 0, 0.6);
|
||||
transform: rotate(var(--angle))
|
||||
scale(1.2,1.2)
|
||||
;
|
||||
}
|
||||
50%{
|
||||
|
||||
box-shadow: 0em 0em 1em 0.2em rgba(0, 0, 0, 0.6);
|
||||
transform: rotate(calc(-1* var(--angle)))
|
||||
scale(1.2,1.2);
|
||||
}
|
||||
74%{
|
||||
|
||||
box-shadow: 0em 0em 1em 0.2em rgba(0, 0, 0, 0.6);
|
||||
transform: rotate(var(--angle))
|
||||
scale(1.2, 1.2);
|
||||
}
|
||||
100%{
|
||||
transform: rotate(0)
|
||||
scale(1.2,1.2);
|
||||
}
|
||||
}
|
||||
|
||||
.description{
|
||||
text-align: center;
|
||||
margin-bottom: 2em;
|
||||
margin-top: -0.5em;
|
||||
}
|
||||
.date{
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
</style>
|
||||
<svelte:head>
|
||||
<title>{stripHtmlTags(data.name)} - Bocken's Recipes</title>
|
||||
<meta name="description" content="{stripHtmlTags(data.description)}" />
|
||||
<meta property="og:image" content="https://bocken.org/static/rezepte/thumb/{data.germanShortName}.webp" />
|
||||
<meta property="og:image:secure_url" content="https://bocken.org/static/rezepte/thumb/{data.germanShortName}.webp" />
|
||||
<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.germanShortName}" />
|
||||
<link rel="alternate" hreflang="en" href="https://bocken.org/recipes/{data.short_name}" />
|
||||
<link rel="alternate" hreflang="x-default" href="https://bocken.org/rezepte/{data.germanShortName}" />
|
||||
</svelte:head>
|
||||
|
||||
<RecipeLanguageSwitcher
|
||||
germanUrl="/rezepte/{data.germanShortName}"
|
||||
englishUrl="/recipes/{data.short_name}"
|
||||
currentLang="en"
|
||||
hasTranslation={true}
|
||||
/>
|
||||
|
||||
<TitleImgParallax src={hero_img_src} {placeholder_src}>
|
||||
<div class=title>
|
||||
<a class="category" href='/recipes/category/{data.category}'>{data.category}</a>
|
||||
<a class="icon" href='/recipes/icon/{data.icon}'>{data.icon}</a>
|
||||
<h1>{@html data.name}</h1>
|
||||
{#if data.description && ! data.preamble}
|
||||
<p class=description>{data.description}</p>
|
||||
{/if}
|
||||
{#if data.preamble}
|
||||
<p>{@html data.preamble}</p>
|
||||
{/if}
|
||||
<div class=tags>
|
||||
<h4>Season:</h4>
|
||||
{#each season_iv as season}
|
||||
<a class=tag href="/recipes/season/{season[0]}">
|
||||
{#if season[0]}
|
||||
{months[season[0] - 1]}
|
||||
{/if}
|
||||
{#if season[1]}
|
||||
- {months[season[1] - 1]}
|
||||
{/if}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
<h4>Keywords:</h4>
|
||||
<div class="tags center">
|
||||
{#each data.tags as tag}
|
||||
<a class=tag href="/recipes/tag/{tag}">{tag}</a>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<FavoriteButton
|
||||
recipeId={data.germanShortName}
|
||||
isFavorite={data.isFavorite || false}
|
||||
isLoggedIn={!!data.session?.user}
|
||||
/>
|
||||
|
||||
{#if data.note}
|
||||
<RecipeNote note={data.note}></RecipeNote>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class=wrapper_wrapper>
|
||||
<div class=wrapper>
|
||||
<IngredientsPage {data}></IngredientsPage>
|
||||
<InstructionsPage {data}></InstructionsPage>
|
||||
</div>
|
||||
<div class=addendum>
|
||||
{#if data.addendum}
|
||||
{@html data.addendum}
|
||||
{/if}
|
||||
</div>
|
||||
<p class=date>Last modified: {formatted_display_date}</p>
|
||||
</div>
|
||||
</TitleImgParallax>
|
||||
|
||||
<EditButton href="/rezepte/edit/{data.germanShortName}"></EditButton>
|
||||
102
src/routes/recipes/[name]/+page.ts
Normal file
102
src/routes/recipes/[name]/+page.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { error } from "@sveltejs/kit";
|
||||
import { generateRecipeJsonLd } from '$lib/js/recipeJsonLd';
|
||||
|
||||
export async function load({ fetch, params, url}) {
|
||||
const res = await fetch(`/api/recipes/items/${params.name}`);
|
||||
let item = await res.json();
|
||||
if(!res.ok){
|
||||
throw error(res.status, item.message)
|
||||
}
|
||||
|
||||
// Check if this recipe is favorited by the user
|
||||
let isFavorite = false;
|
||||
try {
|
||||
const favRes = await fetch(`/api/rezepte/favorites/check/${item.germanShortName}`);
|
||||
if (favRes.ok) {
|
||||
const favData = await favRes.json();
|
||||
isFavorite = favData.isFavorite;
|
||||
}
|
||||
} catch (e) {
|
||||
// Silently fail if not authenticated or other error
|
||||
}
|
||||
|
||||
// Get multiplier from URL parameters
|
||||
const multiplier = parseFloat(url.searchParams.get('multiplier') || '1');
|
||||
|
||||
// Handle yeast swapping from URL parameters
|
||||
if (item.ingredients) {
|
||||
let yeastCounter = 0;
|
||||
|
||||
for (let listIndex = 0; listIndex < item.ingredients.length; listIndex++) {
|
||||
const list = item.ingredients[listIndex];
|
||||
if (list.list) {
|
||||
for (let ingredientIndex = 0; ingredientIndex < list.list.length; ingredientIndex++) {
|
||||
const ingredient = list.list[ingredientIndex];
|
||||
|
||||
// Check for English yeast names
|
||||
if (ingredient.name === "Fresh Yeast" || ingredient.name === "Dry Yeast") {
|
||||
const yeastParam = `y${yeastCounter}`;
|
||||
const isToggled = url.searchParams.has(yeastParam);
|
||||
|
||||
if (isToggled) {
|
||||
const originalName = ingredient.name;
|
||||
const originalAmount = parseFloat(ingredient.amount);
|
||||
const originalUnit = ingredient.unit;
|
||||
|
||||
let newName: string, newAmount: string, newUnit: string;
|
||||
|
||||
if (originalName === "Fresh Yeast") {
|
||||
newName = "Dry Yeast";
|
||||
|
||||
if (originalUnit === "Pinch") {
|
||||
newAmount = ingredient.amount;
|
||||
newUnit = "Pinch";
|
||||
} else if (originalUnit === "g" && originalAmount === 1) {
|
||||
newAmount = "1";
|
||||
newUnit = "Pinch";
|
||||
} else {
|
||||
newAmount = (originalAmount / 3).toString();
|
||||
newUnit = "g";
|
||||
}
|
||||
} else if (originalName === "Dry Yeast") {
|
||||
newName = "Fresh Yeast";
|
||||
|
||||
if (originalUnit === "Pinch") {
|
||||
newAmount = "1";
|
||||
newUnit = "g";
|
||||
} else {
|
||||
newAmount = (originalAmount * 3).toString();
|
||||
newUnit = "g";
|
||||
}
|
||||
} else {
|
||||
newName = originalName;
|
||||
newAmount = ingredient.amount;
|
||||
newUnit = originalUnit;
|
||||
}
|
||||
|
||||
item.ingredients[listIndex].list[ingredientIndex] = {
|
||||
...item.ingredients[listIndex].list[ingredientIndex],
|
||||
name: newName,
|
||||
amount: newAmount,
|
||||
unit: newUnit
|
||||
};
|
||||
}
|
||||
|
||||
yeastCounter++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Generate JSON-LD with English data and language tag
|
||||
const recipeJsonLd = generateRecipeJsonLd({ ...item, inLanguage: 'en' });
|
||||
|
||||
return {
|
||||
...item,
|
||||
isFavorite,
|
||||
multiplier,
|
||||
recipeJsonLd,
|
||||
lang: 'en', // Mark as English page
|
||||
};
|
||||
}
|
||||
19
src/routes/recipes/category/[category]/+page.server.ts
Normal file
19
src/routes/recipes/category/[category]/+page.server.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { PageServerLoad } from "./$types";
|
||||
import { getUserFavorites, addFavoriteStatusToRecipes } from "$lib/server/favorites";
|
||||
|
||||
export const load: PageServerLoad = async ({ fetch, locals, params }) => {
|
||||
const res = await fetch(`/api/recipes/items/category/${params.category}`);
|
||||
const items = await res.json();
|
||||
|
||||
// Get user favorites and session
|
||||
const [userFavorites, session] = await Promise.all([
|
||||
getUserFavorites(fetch, locals),
|
||||
locals.auth()
|
||||
]);
|
||||
|
||||
return {
|
||||
category: params.category,
|
||||
recipes: addFavoriteStatusToRecipes(items, userFavorites),
|
||||
session
|
||||
};
|
||||
};
|
||||
24
src/routes/recipes/category/[category]/+page.svelte
Normal file
24
src/routes/recipes/category/[category]/+page.svelte
Normal file
@@ -0,0 +1,24 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
import Recipes from '$lib/components/Recipes.svelte';
|
||||
import Search from '$lib/components/Search.svelte';
|
||||
export let data: PageData;
|
||||
export let current_month = new Date().getMonth() + 1;
|
||||
import Card from '$lib/components/Card.svelte'
|
||||
import { rand_array } from '$lib/js/randomize';
|
||||
</script>
|
||||
<style>
|
||||
h1 {
|
||||
text-align: center;
|
||||
font-size: 3em;
|
||||
}
|
||||
</style>
|
||||
<h1>Recipes in Category <q>{data.category}</q>:</h1>
|
||||
<Search category={data.category}></Search>
|
||||
<section>
|
||||
<Recipes>
|
||||
{#each rand_array(data.recipes) as recipe}
|
||||
<Card {recipe} {current_month} isFavorite={recipe.isFavorite} showFavoriteIndicator={!!data.session?.user} routePrefix="/recipes"></Card>
|
||||
{/each}
|
||||
</Recipes>
|
||||
</section>
|
||||
22
src/routes/recipes/icon/[icon]/+page.server.ts
Normal file
22
src/routes/recipes/icon/[icon]/+page.server.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { PageServerLoad } from "./$types";
|
||||
import { getUserFavorites, addFavoriteStatusToRecipes } from "$lib/server/favorites";
|
||||
|
||||
export const load: PageServerLoad = async ({ fetch, locals, params }) => {
|
||||
const res_season = await fetch(`/api/recipes/items/icon/` + params.icon);
|
||||
const res_icons = await fetch(`/api/rezepte/items/icon`); // Icons are shared across languages
|
||||
const icons = await res_icons.json();
|
||||
const item_season = await res_season.json();
|
||||
|
||||
// Get user favorites and session
|
||||
const [userFavorites, session] = await Promise.all([
|
||||
getUserFavorites(fetch, locals),
|
||||
locals.auth()
|
||||
]);
|
||||
|
||||
return {
|
||||
icons: icons,
|
||||
icon: params.icon,
|
||||
season: addFavoriteStatusToRecipes(item_season, userFavorites),
|
||||
session
|
||||
};
|
||||
};
|
||||
17
src/routes/recipes/icon/[icon]/+page.svelte
Normal file
17
src/routes/recipes/icon/[icon]/+page.svelte
Normal file
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
import Recipes from '$lib/components/Recipes.svelte';
|
||||
import IconLayout from '$lib/components/IconLayout.svelte';
|
||||
import MediaScroller from '$lib/components/MediaScroller.svelte';
|
||||
import Card from '$lib/components/Card.svelte';
|
||||
import Search from '$lib/components/Search.svelte';
|
||||
export let data: PageData;
|
||||
import { rand_array } from '$lib/js/randomize';
|
||||
</script>
|
||||
<IconLayout icons={data.icons} active_icon={data.icon} >
|
||||
<Recipes slot=recipes>
|
||||
{#each rand_array(data.season) as recipe}
|
||||
<Card {recipe} icon_override=true isFavorite={recipe.isFavorite} showFavoriteIndicator={!!data.session?.user} routePrefix="/recipes"></Card>
|
||||
{/each}
|
||||
</Recipes>
|
||||
</IconLayout>
|
||||
19
src/routes/recipes/season/[month]/+page.server.ts
Normal file
19
src/routes/recipes/season/[month]/+page.server.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { PageServerLoad } from "./$types";
|
||||
import { getUserFavorites, addFavoriteStatusToRecipes } from "$lib/server/favorites";
|
||||
|
||||
export const load: PageServerLoad = async ({ fetch, locals, params }) => {
|
||||
const res_season = await fetch(`/api/recipes/items/in_season/` + params.month);
|
||||
const item_season = await res_season.json();
|
||||
|
||||
// Get user favorites and session
|
||||
const [userFavorites, session] = await Promise.all([
|
||||
getUserFavorites(fetch, locals),
|
||||
locals.auth()
|
||||
]);
|
||||
|
||||
return {
|
||||
month: params.month,
|
||||
season: addFavoriteStatusToRecipes(item_season, userFavorites),
|
||||
session
|
||||
};
|
||||
};
|
||||
18
src/routes/recipes/season/[month]/+page.svelte
Normal file
18
src/routes/recipes/season/[month]/+page.svelte
Normal file
@@ -0,0 +1,18 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
import Recipes from '$lib/components/Recipes.svelte';
|
||||
import SeasonLayout from '$lib/components/SeasonLayout.svelte';
|
||||
import MediaScroller from '$lib/components/MediaScroller.svelte';
|
||||
import Card from '$lib/components/Card.svelte';
|
||||
import Search from '$lib/components/Search.svelte';
|
||||
export let data: PageData;
|
||||
let months = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]
|
||||
import { rand_array } from '$lib/js/randomize';
|
||||
</script>
|
||||
<SeasonLayout active_index={data.month -1}>
|
||||
<Recipes slot=recipes>
|
||||
{#each rand_array(data.season) as recipe}
|
||||
<Card {recipe} icon_override=true isFavorite={recipe.isFavorite} showFavoriteIndicator={!!data.session?.user} routePrefix="/recipes"></Card>
|
||||
{/each}
|
||||
</Recipes>
|
||||
</SeasonLayout>
|
||||
19
src/routes/recipes/tag/[tag]/+page.server.ts
Normal file
19
src/routes/recipes/tag/[tag]/+page.server.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { PageServerLoad } from "./$types";
|
||||
import { getUserFavorites, addFavoriteStatusToRecipes } from "$lib/server/favorites";
|
||||
|
||||
export const load: PageServerLoad = async ({ fetch, locals, params }) => {
|
||||
const res_tag = await fetch(`/api/recipes/items/tag/${params.tag}`);
|
||||
const items_tag = await res_tag.json();
|
||||
|
||||
// Get user favorites and session
|
||||
const [userFavorites, session] = await Promise.all([
|
||||
getUserFavorites(fetch, locals),
|
||||
locals.auth()
|
||||
]);
|
||||
|
||||
return {
|
||||
tag: params.tag,
|
||||
recipes: addFavoriteStatusToRecipes(items_tag, userFavorites),
|
||||
session
|
||||
};
|
||||
};
|
||||
24
src/routes/recipes/tag/[tag]/+page.svelte
Normal file
24
src/routes/recipes/tag/[tag]/+page.svelte
Normal file
@@ -0,0 +1,24 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
import Recipes from '$lib/components/Recipes.svelte';
|
||||
export let data: PageData;
|
||||
export let current_month = new Date().getMonth() + 1;
|
||||
import Card from '$lib/components/Card.svelte'
|
||||
import Search from '$lib/components/Search.svelte';
|
||||
import { rand_array } from '$lib/js/randomize';
|
||||
</script>
|
||||
<style>
|
||||
h1 {
|
||||
text-align: center;
|
||||
font-size: 2em;
|
||||
}
|
||||
</style>
|
||||
<h1>Recipes with Keyword <q>{data.tag}</q>:</h1>
|
||||
<Search tag={data.tag}></Search>
|
||||
<section>
|
||||
<Recipes>
|
||||
{#each rand_array(data.recipes) as recipe}
|
||||
<Card {recipe} {current_month} isFavorite={recipe.isFavorite} showFavoriteIndicator={!!data.session?.user} routePrefix="/recipes"></Card>
|
||||
{/each}
|
||||
</Recipes>
|
||||
</section>
|
||||
@@ -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>
|
||||
|
||||
@@ -105,10 +105,16 @@ 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,17 +103,12 @@
|
||||
});
|
||||
}
|
||||
|
||||
async function doPost () {
|
||||
|
||||
upload_img()
|
||||
console.log(add_info.total_time)
|
||||
const res = await fetch('/api/rezepte/add', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
recipe: {
|
||||
// Prepare the German recipe data
|
||||
function getGermanRecipeData() {
|
||||
return {
|
||||
...card_data,
|
||||
...add_info,
|
||||
images: {mediapath: short_name.trim() + '.webp', alt: "", caption: ""}, // TODO
|
||||
images: {mediapath: short_name.trim() + '.webp', alt: "", caption: ""},
|
||||
season: season_local,
|
||||
short_name : short_name.trim(),
|
||||
portions: portions_local,
|
||||
@@ -118,12 +118,74 @@
|
||||
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: 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,31 +281,35 @@
|
||||
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('/');
|
||||
url.splice(url.length -2, 2);
|
||||
@@ -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}
|
||||
|
||||
@@ -1,3 +1,44 @@
|
||||
// Translation status enum
|
||||
export type TranslationStatus = 'pending' | 'approved' | 'needs_update';
|
||||
|
||||
// Translation metadata for tracking changes
|
||||
export type TranslationMetadata = {
|
||||
lastModifiedGerman?: Date;
|
||||
fieldsModifiedSinceTranslation?: string[];
|
||||
};
|
||||
|
||||
// Translated recipe type (English version)
|
||||
export type TranslatedRecipeType = {
|
||||
short_name: string;
|
||||
name: string;
|
||||
description: string;
|
||||
preamble?: string;
|
||||
addendum?: string;
|
||||
note?: string;
|
||||
category: string;
|
||||
tags?: string[];
|
||||
ingredients?: [{
|
||||
name?: string;
|
||||
list: [{
|
||||
name: string;
|
||||
unit: string;
|
||||
amount: string;
|
||||
}]
|
||||
}];
|
||||
instructions?: [{
|
||||
name?: string;
|
||||
steps: string[];
|
||||
}];
|
||||
images?: [{
|
||||
alt: string;
|
||||
caption?: string;
|
||||
}];
|
||||
translationStatus: TranslationStatus;
|
||||
lastTranslated?: Date;
|
||||
changedFields?: string[];
|
||||
};
|
||||
|
||||
// Full recipe model with translations
|
||||
export type RecipeModelType = {
|
||||
_id: string;
|
||||
short_name: string;
|
||||
@@ -41,6 +82,10 @@ export type RecipeModelType = {
|
||||
}]
|
||||
preamble?: String
|
||||
addendum?: string
|
||||
translations?: {
|
||||
en?: TranslatedRecipeType;
|
||||
};
|
||||
translationMetadata?: TranslationMetadata;
|
||||
};
|
||||
|
||||
export type BriefRecipeType = {
|
||||
|
||||
426
src/utils/translation.ts
Normal file
426
src/utils/translation.ts
Normal file
@@ -0,0 +1,426 @@
|
||||
import { DEEPL_API_KEY, DEEPL_API_URL } from '$env/static/private';
|
||||
|
||||
// Category translation dictionary for consistency
|
||||
const CATEGORY_TRANSLATIONS: Record<string, string> = {
|
||||
"Brot": "Bread",
|
||||
"Kuchen": "Cake",
|
||||
"Suppe": "Soup",
|
||||
"Salat": "Salad",
|
||||
"Hauptgericht": "Main Course",
|
||||
"Beilage": "Side Dish",
|
||||
"Dessert": "Dessert",
|
||||
"Getränk": "Beverage",
|
||||
"Frühstück": "Breakfast",
|
||||
"Snack": "Snack"
|
||||
};
|
||||
|
||||
interface DeepLResponse {
|
||||
translations: Array<{
|
||||
detected_source_language: string;
|
||||
text: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface TranslationResult {
|
||||
text: string;
|
||||
detectedSourceLang: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* DeepL Translation Service
|
||||
* Handles all translation operations using the DeepL API
|
||||
*/
|
||||
class DeepLTranslationService {
|
||||
private apiKey: string;
|
||||
private apiUrl: string;
|
||||
|
||||
constructor() {
|
||||
this.apiKey = DEEPL_API_KEY || '';
|
||||
this.apiUrl = DEEPL_API_URL || 'https://api-free.deepl.com/v2/translate';
|
||||
|
||||
if (!this.apiKey) {
|
||||
console.warn('DEEPL_API_KEY not found in environment variables');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate a single text string
|
||||
* @param text - The text to translate
|
||||
* @param targetLang - Target language code (default: 'EN')
|
||||
* @param preserveFormatting - Whether to preserve HTML/formatting
|
||||
* @returns Translated text
|
||||
*/
|
||||
async translateText(
|
||||
text: string | null | undefined,
|
||||
targetLang: string = 'EN',
|
||||
preserveFormatting: boolean = false
|
||||
): Promise<string> {
|
||||
// Return empty string for null, undefined, or empty strings
|
||||
if (!text || text.trim() === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (!this.apiKey) {
|
||||
throw new Error('DeepL API key not configured');
|
||||
}
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
auth_key: this.apiKey,
|
||||
text: text,
|
||||
target_lang: targetLang,
|
||||
...(preserveFormatting && { tag_handling: 'xml' })
|
||||
});
|
||||
|
||||
const response = await fetch(this.apiUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: params.toString()
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`DeepL API error: ${response.status} - ${errorText}`);
|
||||
}
|
||||
|
||||
const data: DeepLResponse = await response.json();
|
||||
return data.translations[0]?.text || '';
|
||||
} catch (error) {
|
||||
console.error('Translation error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate multiple texts in a single batch request
|
||||
* More efficient than individual calls
|
||||
* @param texts - Array of texts to translate
|
||||
* @param targetLang - Target language code
|
||||
* @returns Array of translated texts (preserves empty strings in original positions)
|
||||
*/
|
||||
async translateBatch(
|
||||
texts: string[],
|
||||
targetLang: string = 'EN'
|
||||
): Promise<string[]> {
|
||||
if (!texts.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!this.apiKey) {
|
||||
throw new Error('DeepL API key not configured');
|
||||
}
|
||||
|
||||
// Track which indices have non-empty text
|
||||
const nonEmptyIndices: number[] = [];
|
||||
const nonEmptyTexts: string[] = [];
|
||||
|
||||
texts.forEach((text, index) => {
|
||||
if (text && text.trim()) {
|
||||
nonEmptyIndices.push(index);
|
||||
nonEmptyTexts.push(text);
|
||||
}
|
||||
});
|
||||
|
||||
// If all texts are empty, return array of empty strings
|
||||
if (nonEmptyTexts.length === 0) {
|
||||
return texts.map(() => '');
|
||||
}
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
auth_key: this.apiKey,
|
||||
target_lang: targetLang,
|
||||
});
|
||||
|
||||
// Add each non-empty text as a separate 'text' parameter
|
||||
nonEmptyTexts.forEach(text => {
|
||||
params.append('text', text);
|
||||
});
|
||||
|
||||
const response = await fetch(this.apiUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: params.toString()
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`DeepL API error: ${response.status} - ${errorText}`);
|
||||
}
|
||||
|
||||
const data: DeepLResponse = await response.json();
|
||||
const translatedTexts = data.translations.map(t => t.text);
|
||||
|
||||
// Map translated texts back to original positions, preserving empty strings
|
||||
const result: string[] = [];
|
||||
let translatedIndex = 0;
|
||||
|
||||
for (let i = 0; i < texts.length; i++) {
|
||||
if (nonEmptyIndices.includes(i)) {
|
||||
result.push(translatedTexts[translatedIndex]);
|
||||
translatedIndex++;
|
||||
} else {
|
||||
result.push(''); // Keep empty string
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Batch translation error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate a complete recipe object
|
||||
* @param recipe - The recipe object to translate
|
||||
* @returns Translated recipe data
|
||||
*/
|
||||
async translateRecipe(recipe: any): Promise<any> {
|
||||
try {
|
||||
// Translate category using dictionary first, fallback to DeepL
|
||||
const translatedCategory = CATEGORY_TRANSLATIONS[recipe.category]
|
||||
|| await this.translateText(recipe.category);
|
||||
|
||||
// Collect all texts to translate in batch
|
||||
const textsToTranslate: string[] = [
|
||||
recipe.name,
|
||||
recipe.description,
|
||||
recipe.preamble || '',
|
||||
recipe.addendum || '',
|
||||
recipe.note || '',
|
||||
];
|
||||
|
||||
// Add tags
|
||||
const tags = recipe.tags || [];
|
||||
textsToTranslate.push(...tags);
|
||||
|
||||
// Add ingredient names and list items
|
||||
const ingredients = recipe.ingredients || [];
|
||||
ingredients.forEach((ing: any) => {
|
||||
textsToTranslate.push(ing.name || '');
|
||||
(ing.list || []).forEach((item: any) => {
|
||||
textsToTranslate.push(item.name || '');
|
||||
});
|
||||
});
|
||||
|
||||
// Add instruction names and steps
|
||||
const instructions = recipe.instructions || [];
|
||||
instructions.forEach((inst: any) => {
|
||||
textsToTranslate.push(inst.name || '');
|
||||
(inst.steps || []).forEach((step: string) => {
|
||||
textsToTranslate.push(step || '');
|
||||
});
|
||||
});
|
||||
|
||||
// Add image alt and caption texts
|
||||
const images = recipe.images || [];
|
||||
images.forEach((img: any) => {
|
||||
textsToTranslate.push(img.alt || '');
|
||||
textsToTranslate.push(img.caption || '');
|
||||
});
|
||||
|
||||
// Batch translate all texts
|
||||
const translated = await this.translateBatch(textsToTranslate);
|
||||
|
||||
// Reconstruct translated recipe
|
||||
let index = 0;
|
||||
const translatedRecipe = {
|
||||
short_name: this.generateEnglishSlug(recipe.name),
|
||||
name: translated[index++],
|
||||
description: translated[index++],
|
||||
preamble: translated[index++],
|
||||
addendum: translated[index++],
|
||||
note: translated[index++],
|
||||
category: translatedCategory,
|
||||
tags: tags.map(() => translated[index++]),
|
||||
ingredients: ingredients.map((ing: any) => ({
|
||||
name: translated[index++],
|
||||
list: (ing.list || []).map((item: any) => ({
|
||||
name: translated[index++],
|
||||
unit: item.unit,
|
||||
amount: item.amount,
|
||||
}))
|
||||
})),
|
||||
instructions: instructions.map((inst: any) => ({
|
||||
name: translated[index++],
|
||||
steps: (inst.steps || []).map(() => translated[index++])
|
||||
})),
|
||||
images: images.map((img: any) => ({
|
||||
alt: translated[index++],
|
||||
caption: translated[index++],
|
||||
})),
|
||||
translationStatus: 'pending' as const,
|
||||
lastTranslated: new Date(),
|
||||
changedFields: [],
|
||||
};
|
||||
|
||||
return translatedRecipe;
|
||||
} catch (error) {
|
||||
console.error('Recipe translation error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect which fields have changed between old and new recipe
|
||||
* Used to determine what needs re-translation
|
||||
* @param oldRecipe - Original recipe
|
||||
* @param newRecipe - Modified recipe
|
||||
* @returns Array of changed field names
|
||||
*/
|
||||
detectChangedFields(oldRecipe: any, newRecipe: any): string[] {
|
||||
const fieldsToCheck = [
|
||||
'name',
|
||||
'description',
|
||||
'preamble',
|
||||
'addendum',
|
||||
'note',
|
||||
'category',
|
||||
'tags',
|
||||
'ingredients',
|
||||
'instructions',
|
||||
];
|
||||
|
||||
const changed: string[] = [];
|
||||
|
||||
for (const field of fieldsToCheck) {
|
||||
const oldValue = JSON.stringify(oldRecipe[field] || '');
|
||||
const newValue = JSON.stringify(newRecipe[field] || '');
|
||||
|
||||
if (oldValue !== newValue) {
|
||||
changed.push(field);
|
||||
}
|
||||
}
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate URL-friendly English slug from German name
|
||||
* Ensures uniqueness by checking against existing recipes
|
||||
* @param germanName - The German recipe name
|
||||
* @returns URL-safe English slug
|
||||
*/
|
||||
generateEnglishSlug(germanName: string): string {
|
||||
// This will be translated name, so we just need to slugify it
|
||||
const slug = germanName
|
||||
.toLowerCase()
|
||||
.replace(/ä/g, 'ae')
|
||||
.replace(/ö/g, 'oe')
|
||||
.replace(/ü/g, 'ue')
|
||||
.replace(/ß/g, 'ss')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
|
||||
return slug;
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate only specific fields of a recipe
|
||||
* Used when only some fields have changed
|
||||
* @param recipe - The recipe object
|
||||
* @param fields - Array of field names to translate
|
||||
* @returns Partial translated recipe with only specified fields
|
||||
*/
|
||||
async translateFields(recipe: any, fields: string[]): Promise<any> {
|
||||
const result: any = {};
|
||||
|
||||
for (const field of fields) {
|
||||
switch (field) {
|
||||
case 'name':
|
||||
result.name = await this.translateText(recipe.name);
|
||||
result.short_name = this.generateEnglishSlug(result.name);
|
||||
break;
|
||||
case 'description':
|
||||
result.description = await this.translateText(recipe.description);
|
||||
break;
|
||||
case 'preamble':
|
||||
result.preamble = await this.translateText(recipe.preamble || '', 'EN', true);
|
||||
break;
|
||||
case 'addendum':
|
||||
result.addendum = await this.translateText(recipe.addendum || '', 'EN', true);
|
||||
break;
|
||||
case 'note':
|
||||
result.note = await this.translateText(recipe.note || '');
|
||||
break;
|
||||
case 'category':
|
||||
result.category = CATEGORY_TRANSLATIONS[recipe.category]
|
||||
|| await this.translateText(recipe.category);
|
||||
break;
|
||||
case 'tags':
|
||||
result.tags = await this.translateBatch(recipe.tags || []);
|
||||
break;
|
||||
case 'ingredients':
|
||||
// This would be complex - for now, re-translate all ingredients
|
||||
result.ingredients = await this._translateIngredients(recipe.ingredients || []);
|
||||
break;
|
||||
case 'instructions':
|
||||
// This would be complex - for now, re-translate all instructions
|
||||
result.instructions = await this._translateInstructions(recipe.instructions || []);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
result.lastTranslated = new Date();
|
||||
result.changedFields = [];
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Translate ingredients array
|
||||
*/
|
||||
private async _translateIngredients(ingredients: any[]): Promise<any[]> {
|
||||
const allTexts: string[] = [];
|
||||
ingredients.forEach(ing => {
|
||||
allTexts.push(ing.name || '');
|
||||
(ing.list || []).forEach((item: any) => {
|
||||
allTexts.push(item.name || '');
|
||||
});
|
||||
});
|
||||
|
||||
const translated = await this.translateBatch(allTexts);
|
||||
let index = 0;
|
||||
|
||||
return ingredients.map(ing => ({
|
||||
name: translated[index++],
|
||||
list: (ing.list || []).map((item: any) => ({
|
||||
name: translated[index++],
|
||||
unit: item.unit,
|
||||
amount: item.amount,
|
||||
}))
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Translate instructions array
|
||||
*/
|
||||
private async _translateInstructions(instructions: any[]): Promise<any[]> {
|
||||
const allTexts: string[] = [];
|
||||
instructions.forEach(inst => {
|
||||
allTexts.push(inst.name || '');
|
||||
(inst.steps || []).forEach((step: string) => {
|
||||
allTexts.push(step || '');
|
||||
});
|
||||
});
|
||||
|
||||
const translated = await this.translateBatch(allTexts);
|
||||
let index = 0;
|
||||
|
||||
return instructions.map(inst => ({
|
||||
name: translated[index++],
|
||||
steps: (inst.steps || []).map(() => translated[index++])
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const translationService = new DeepLTranslationService();
|
||||
|
||||
// Export class for testing
|
||||
export { DeepLTranslationService };
|
||||
Reference in New Issue
Block a user