add English translation support for recipes with DeepL integration

- Add embedded translations schema to Recipe model with English support
- Create DeepL translation service with batch translation and change detection
- Build translation approval UI with side-by-side editing for all recipe fields
- Integrate translation workflow into add/edit pages with field comparison
- Create complete English recipe routes at /recipes/* mirroring German structure
- Add language switcher component with hreflang SEO tags
- Support image loading from German short_name for English recipes
- Add English API endpoints for all recipe filters (category, tag, icon, season)
- Include layout with English navigation header for all recipe subroutes
This commit is contained in:
2025-12-26 20:28:43 +01:00
parent 731adda897
commit 36a7fac39a
34 changed files with 3061 additions and 44 deletions

View File

@@ -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');
}
};

View 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))));
};

View 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))));
};

View 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))));
};

View 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))));
};

View 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))));
};

View 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 });
}
};

View File

@@ -0,0 +1,7 @@
import type { LayoutServerLoad } from "./$types"
export const load : LayoutServerLoad = async ({locals}) => {
return {
session: await locals.auth()
}
};

View 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>

View 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
};
};

View 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>

View 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>

View 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
};
}

View 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
};
};

View 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>

View 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
};
};

View 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>

View 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
};
};

View 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>

View 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
};
};

View 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>

View File

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

View File

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

View File

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

View File

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