Compare commits
5 Commits
731adda897
...
3215c87fad
| Author | SHA1 | Date | |
|---|---|---|---|
|
3215c87fad
|
|||
|
715f86d26d
|
|||
|
442b2a3145
|
|||
|
6de3d76504
|
|||
|
36a7fac39a
|
28
.env.example
Normal file
28
.env.example
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# Database Configuration
|
||||||
|
MONGO_URL="mongodb://user:password@host:port/database?authSource=admin"
|
||||||
|
|
||||||
|
# Authentication Secrets (runtime only - not embedded in build)
|
||||||
|
AUTHENTIK_ID="your-authentik-client-id"
|
||||||
|
AUTHENTIK_SECRET="your-authentik-client-secret"
|
||||||
|
|
||||||
|
# Static Configuration (embedded in build - OK to be public)
|
||||||
|
AUTHENTIK_ISSUER="https://sso.example.com/application/o/your-app/"
|
||||||
|
|
||||||
|
# File Storage
|
||||||
|
IMAGE_DIR="/path/to/static/files"
|
||||||
|
|
||||||
|
# Optional: Development Settings
|
||||||
|
# DEV_DISABLE_AUTH="true"
|
||||||
|
# ORIGIN="http://127.0.0.1:3000"
|
||||||
|
|
||||||
|
# Optional: Additional Configuration
|
||||||
|
# BEARER_TOKEN="your-bearer-token"
|
||||||
|
# COOKIE_SECRET="your-cookie-secret"
|
||||||
|
# PEPPER="your-pepper-value"
|
||||||
|
# ALLOW_REGISTRATION="1"
|
||||||
|
# AUTH_SECRET="your-auth-secret"
|
||||||
|
# USDA_API_KEY="your-usda-api-key"
|
||||||
|
|
||||||
|
# Translation Service (DeepL API)
|
||||||
|
DEEPL_API_KEY="your-deepl-api-key"
|
||||||
|
DEEPL_API_URL="https://api-free.deepl.com/v2/translate" # Use https://api.deepl.com/v2/translate for Pro
|
||||||
13
.mcp.json
Normal file
13
.mcp.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"svelte": {
|
||||||
|
"type": "stdio",
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"-y",
|
||||||
|
"@sveltejs/mcp"
|
||||||
|
],
|
||||||
|
"env": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
15
.mcp.json.bak
Normal file
15
.mcp.json.bak
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"svelte": {
|
||||||
|
"type": "stdio",
|
||||||
|
"command": "npx",
|
||||||
|
"env": {
|
||||||
|
|
||||||
|
},
|
||||||
|
"args": [
|
||||||
|
"-y",
|
||||||
|
"@sveltejs/mcp"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
57
dove.svg
Normal file
57
dove.svg
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<svg
|
||||||
|
version="1.1"
|
||||||
|
viewBox="-10 0 2058 2048"
|
||||||
|
id="svg1"
|
||||||
|
sodipodi:docname="dove.svg"
|
||||||
|
inkscape:version="1.4.2 (ebf0e940d0, 2025-05-08)"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg">
|
||||||
|
<defs
|
||||||
|
id="defs1" />
|
||||||
|
<sodipodi:namedview
|
||||||
|
id="namedview1"
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#000000"
|
||||||
|
borderopacity="0.25"
|
||||||
|
inkscape:showpageshadow="2"
|
||||||
|
inkscape:pageopacity="0.0"
|
||||||
|
inkscape:pagecheckerboard="0"
|
||||||
|
inkscape:deskcolor="#d1d1d1"
|
||||||
|
inkscape:zoom="0.20494477"
|
||||||
|
inkscape:cx="875.84571"
|
||||||
|
inkscape:cy="3122.7925"
|
||||||
|
inkscape:window-width="1436"
|
||||||
|
inkscape:window-height="1749"
|
||||||
|
inkscape:window-x="1440"
|
||||||
|
inkscape:window-y="47"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="svg1" />
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M1935 90q0 32 -38 91q-21 29 -56 90q-20 55 -63 164q-35 86 -95 143q-22 -21 -43 -45q51 -49 85 -139q49 -130 61 -152q-126 48 -152 63q-76 46 -95 128q-27 -18 -58 -25q28 -104 97 -149q31 -20 138 -52q90 -28 137 -74l29 -39q22 -30 32 -30q21 0 21 26zM1714 653 q-90 30 -113 43q-65 36 -65 90q0 19 20 119q23 116 23 247q0 169 -103 299q-111 141 -275 141q-254 0 -283 87q-16 104 -31 207q-27 162 -76 162q-21 0 -41 -20q-16 -19 -32 -37q-10 3 -33 22q-18 15 -39 15q-28 0 -50 -44.5t-30 -44.5q-10 0 -35.5 11.5t-41.5 11.5 q-47 0 -58.5 -45.5t-21.5 -45.5t-29.5 2.5t-29.5 2.5q-46 0 -46 -30q0 -16 14 -44.5t14 -44.5q0 -8 -46.5 -25.5t-46.5 -48.5q0 -34 35.5 -52t99.5 -31q91 -19 103 -22q113 -32 171 -93q37 -39 105 -165q34 -64 43 -82q26 -53 31 -85q-129 -67 -224 -76q-33 0 -96 -11 q-36 -13 -36 -41q0 -7 2 -19.5t2 -19.5q0 -20 -67.5 -42t-67.5 -64q0 -11 8.5 -30t8.5 -30q0 -15 -79 -39t-79 -63q0 -16 9 -45t9 -45q0 -20 -29 -43q-23 -17 -46 -33q-49 -44 -49 -215q0 -8 1 -15q91 53 194 68l282 16q202 12 304 59q143 65 143 210q0 15 -2 44t-2 44 q0 122 78 122q73 0 108 -133q16 -70 32 -139q21 -81 57 -119q46 -51 130 -51q71 0 122 61q90 107 154 149zM1597 636q-25 -22 -77 -91q-30 -40 -75 -40q-91 0 -131 115q-30 106 -59 213q-44 115 -144 115q-146 0 -146 -180q0 -16 2.5 -46.5t2.5 -46.5q0 -62 -19 -87 q-70 -92 -303 -115q-173 -9 -347 -18q-55 -6 -116 -30v34q0 27 57.5 73.5t57.5 91.5q0 16 -10.5 45t-10.5 44q1 1 7 1q3 0 7 1q146 36 146 105q0 13 -8.5 32.5t-8.5 27.5h10q5 0 9 1q61 15 86 36q32 28 28 85q173 15 372 107q-7 77 -80 215q-67 128 -127 195 q-67 74 -169 104q-96 24 -193 47q-10 3 -29 13q86 18 86 70q0 19 -19 62q15 -5 33 -5q42 0 59 26q8 11 22 61l-1 3q10 0 34.5 -11.5t42.5 -11.5q55 0 88 84q38 -32 64 -32q37 0 66 41q25 -53 33 -151q10 -112 23 -154q43 -136 337 -136q116 0 215 -108q105 -114 105 -277 q0 -23 -12 -112l-28 -207q-4 -30 -4 -42q0 -97 124 -147zM1506 605q0 38 -38 38q-39 0 -39 -38t39 -38q38 0 38 38z"
|
||||||
|
id="path1" />
|
||||||
|
<path
|
||||||
|
d="m 1724.44,1054.6641 c -31.1769,-18 -37.7653,-42.5884 -19.7653,-73.76528 5.3333,-9.2376 12.354,-16.7312 21.0621,-22.4808 6.2201,-4.1068 44.7886,-7.2427 115.7055,-9.4077 70.9168,-2.1649 110.128,-1.0807 117.6336,3.2526 30.0222,17.3334 35.5333,42.45448 16.5333,75.36348 -7.3333,12.7017 -16.1754,20.6833 -26.5263,23.9448 -24.5645,1.2137 -56.7805,3.0135 -96.648,5.3994 -72.6282,5.7957 -115.2931,5.0269 -127.9949,-2.3065 z"
|
||||||
|
style="fill:currentColor"
|
||||||
|
id="path1-2"
|
||||||
|
sodipodi:nodetypes="ssssssccs" />
|
||||||
|
<path
|
||||||
|
d="m 386.57764,1262.0569 c 53.44793,-14.3214 85.17574,-2.8075 95.18337,34.5417 9.83517,36.7052 -12.29319,62.3047 -66.38503,76.7986 l -82.1037,21.9996 c -54.09184,14.4939 -86.05533,3.3882 -95.89047,-33.317 -10.00766,-37.3491 12.67841,-63.4432 68.05807,-78.2821 z"
|
||||||
|
style="fill:currentColor"
|
||||||
|
id="path1-7" />
|
||||||
|
<path
|
||||||
|
d="m 1115.7599,372.22724 c 14.3213,53.44793 2.8073,85.17581 -34.5418,95.18323 -36.705,9.83527 -62.3047,-12.29323 -76.7986,-66.38485 l -21.99962,-82.10394 c -14.4939,-54.09162 -3.3882,-86.05531 33.31712,-95.89019 37.349,-10.00765 63.4431,12.67818 78.2821,68.05802 z"
|
||||||
|
style="fill:currentColor"
|
||||||
|
id="path1-7-6" />
|
||||||
|
<path
|
||||||
|
d="m 1184.6228,1956.284 c -4.807,-8.0003 -6.8298,-42.7561 -6.0684,-104.2674 0.7614,-61.5113 2.7093,-100.0139 5.8437,-115.508 3.1343,-15.4941 11.8445,-27.5329 26.1306,-36.117 30.2866,-18.198 54.7006,-11.868 73.242,18.99 5.4937,9.1432 8.145,43.3269 7.9537,102.5512 -0.081,52.9359 -1.4296,89.5231 -4.0464,109.7617 -2.276,16.9226 -11.1284,30.0192 -26.5575,39.29 -33.1439,19.9148 -58.643,15.0146 -76.4977,-14.7005 z"
|
||||||
|
style="fill:currentColor"
|
||||||
|
id="path1-6" />
|
||||||
|
<path
|
||||||
|
d="m 1773.3127,1737.6952 c -9.0153,-2.4157 -34.6139,-26.0118 -76.7955,-70.7882 -42.1816,-44.7764 -67.5266,-73.826 -76.035,-87.1489 -8.5084,-13.3228 -10.6057,-28.0334 -6.2922,-44.1323 9.145,-34.1293 31.1041,-46.5353 65.8774,-37.2179 10.3033,2.7609 35.9565,25.5088 76.9595,68.2441 36.7142,38.1352 61.1596,65.3907 73.3362,81.7668 10.1182,13.7541 12.8479,29.3245 8.1892,46.7113 -10.0077,37.3492 -31.7542,51.5375 -65.2396,42.5651 z"
|
||||||
|
style="fill:currentColor"
|
||||||
|
id="path1-6-9" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.9 KiB |
38
extract_crown.py
Normal file
38
extract_crown.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Extract crown emoji from Symbola font as SVG."""
|
||||||
|
|
||||||
|
import fontforge
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# Path to Symbola font
|
||||||
|
font_path = "/usr/share/fonts/TTF/Symbola.ttf"
|
||||||
|
|
||||||
|
# Dove emoji Unicode codepoint
|
||||||
|
dove_codepoint = 0x1F54A # U+1F54A 🕊️
|
||||||
|
|
||||||
|
# Output SVG file
|
||||||
|
output_path = "dove.svg"
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Open the font
|
||||||
|
font = fontforge.open(font_path)
|
||||||
|
|
||||||
|
# Select the dove glyph by Unicode codepoint
|
||||||
|
if dove_codepoint in font:
|
||||||
|
glyph = font[dove_codepoint]
|
||||||
|
|
||||||
|
# Export as SVG
|
||||||
|
glyph.export(output_path)
|
||||||
|
|
||||||
|
print(f"✓ Successfully extracted dove emoji to {output_path}")
|
||||||
|
print(f" Glyph name: {glyph.glyphname}")
|
||||||
|
print(f" Unicode: U+{dove_codepoint:04X}")
|
||||||
|
else:
|
||||||
|
print(f"✗ Dove emoji (U+{dove_codepoint:04X}) not found in font")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
font.close()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"✗ Error: {e}")
|
||||||
|
sys.exit(1)
|
||||||
@@ -11,6 +11,8 @@ export let isFavorite = false;
|
|||||||
export let showFavoriteIndicator = false;
|
export let showFavoriteIndicator = false;
|
||||||
// to manually override lazy loading for top cards
|
// to manually override lazy loading for top cards
|
||||||
export let loading_strat : "lazy" | "eager" | undefined;
|
export let loading_strat : "lazy" | "eager" | undefined;
|
||||||
|
// route prefix for language support (/rezepte or /recipes)
|
||||||
|
export let routePrefix = '/rezepte';
|
||||||
if(loading_strat === undefined){
|
if(loading_strat === undefined){
|
||||||
loading_strat = "lazy"
|
loading_strat = "lazy"
|
||||||
}
|
}
|
||||||
@@ -27,7 +29,9 @@ onMount(() => {
|
|||||||
isloaded = document.querySelector("img")?.complete ? true : false
|
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>
|
</script>
|
||||||
<style>
|
<style>
|
||||||
.card_anchor{
|
.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_anchor class:search_me={search} data-tags=[{recipe.tags}]>
|
||||||
<div class="card" class:margin_right={do_margin_right}>
|
<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>
|
<span class="visually-hidden">View recipe: {recipe.name}</span>
|
||||||
</a>
|
</a>
|
||||||
<div class=div_div_image >
|
<div class=div_div_image >
|
||||||
@@ -261,24 +265,24 @@ const img_name=recipe.short_name + ".webp?v=" + recipe.dateModified
|
|||||||
<noscript>
|
<noscript>
|
||||||
<img id=image class="backdrop_blur" src="https://bocken.org/static/rezepte/thumb/{img_name}" loading={loading_strat} alt="{recipe.alt}"/>
|
<img id=image class="backdrop_blur" src="https://bocken.org/static/rezepte/thumb/{img_name}" loading={loading_strat} alt="{recipe.alt}"/>
|
||||||
</noscript>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
{#if showFavoriteIndicator && isFavorite}
|
{#if showFavoriteIndicator && isFavorite}
|
||||||
<div class="favorite-indicator">❤️</div>
|
<div class="favorite-indicator">❤️</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if icon_override || recipe.season.includes(current_month)}
|
{#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}
|
{/if}
|
||||||
<div class="card_title">
|
<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>
|
||||||
<div class=name>{@html recipe.name}</div>
|
<div class=name>{@html recipe.name}</div>
|
||||||
<div class=description>{@html recipe.description}</div>
|
<div class=description>{@html recipe.description}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class=tags>
|
<div class=tags>
|
||||||
{#each recipe.tags as tag}
|
{#each recipe.tags as tag}
|
||||||
<a href="/rezepte/tag/{tag}" class=tag>{tag}</a>
|
<a href="{routePrefix}/tag/{tag}" class=tag>{tag}</a>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
@@ -3,15 +3,18 @@
|
|||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
|
|
||||||
export let item;
|
let { item, multiplier = 1, yeastId = 0, lang = 'de' } = $props();
|
||||||
export let multiplier = 1;
|
|
||||||
export let yeastId = 0;
|
|
||||||
|
|
||||||
const dispatch = createEventDispatcher();
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
|
const isEnglish = $derived(lang === 'en');
|
||||||
|
const toggleTitle = $derived(isEnglish
|
||||||
|
? 'Switch between fresh yeast and dry yeast'
|
||||||
|
: 'Zwischen Frischhefe und Trockenhefe wechseln');
|
||||||
|
|
||||||
// Get all current URL parameters to preserve state
|
// Get all current URL parameters to preserve state
|
||||||
$: currentParams = browser ? new URLSearchParams(window.location.search) : $page.url.searchParams;
|
const currentParams = $derived(browser ? new URLSearchParams(window.location.search) : $page.url.searchParams);
|
||||||
|
|
||||||
function toggleHefe(event) {
|
function toggleHefe(event) {
|
||||||
// If JavaScript is available, prevent form submission and handle client-side
|
// If JavaScript is available, prevent form submission and handle client-side
|
||||||
@@ -54,7 +57,7 @@
|
|||||||
{#each Array.from(currentParams.entries()) as [key, value]}
|
{#each Array.from(currentParams.entries()) as [key, value]}
|
||||||
<input type="hidden" name="currentParam_{key}" value={value} />
|
<input type="hidden" name="currentParam_{key}" value={value} />
|
||||||
{/each}
|
{/each}
|
||||||
<button type="submit" on:click={toggleHefe} title="Zwischen Frischhefe und Trockenhefe wechseln">
|
<button type="submit" on:click={toggleHefe} title={toggleTitle}>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--!Font Awesome Free 6.6.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.--><path d="M105.1 202.6c7.7-21.8 20.2-42.3 37.8-59.8c62.5-62.5 163.8-62.5 226.3 0L386.3 160 352 160c-17.7 0-32 14.3-32 32s14.3 32 32 32l111.5 0c0 0 0 0 0 0l.4 0c17.7 0 32-14.3 32-32l0-112c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 35.2L414.4 97.6c-87.5-87.5-229.3-87.5-316.8 0C73.2 122 55.6 150.7 44.8 181.4c-5.9 16.7 2.9 34.9 19.5 40.8s34.9-2.9 40.8-19.5zM39 289.3c-5 1.5-9.8 4.2-13.7 8.2c-4 4-6.7 8.8-8.1 14c-.3 1.2-.6 2.5-.8 3.8c-.3 1.7-.4 3.4-.4 5.1L16 432c0 17.7 14.3 32 32 32s32-14.3 32-32l0-35.1 17.6 17.5c0 0 0 0 0 0c87.5 87.4 229.3 87.4 316.7 0c24.4-24.4 42.1-53.1 52.9-83.8c5.9-16.7-2.9-34.9-19.5-40.8s-34.9 2.9-40.8 19.5c-7.7 21.8-20.2 42.3-37.8 59.8c-62.5 62.5-163.8 62.5-226.3 0l-.1-.1L125.6 352l34.4 0c17.7 0 32-14.3 32-32s-14.3-32-32-32L48.4 288c-1.6 0-3.2 .1-4.8 .3s-3.1 .5-4.6 1z"/></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--!Font Awesome Free 6.6.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.--><path d="M105.1 202.6c7.7-21.8 20.2-42.3 37.8-59.8c62.5-62.5 163.8-62.5 226.3 0L386.3 160 352 160c-17.7 0-32 14.3-32 32s14.3 32 32 32l111.5 0c0 0 0 0 0 0l.4 0c17.7 0 32-14.3 32-32l0-112c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 35.2L414.4 97.6c-87.5-87.5-229.3-87.5-316.8 0C73.2 122 55.6 150.7 44.8 181.4c-5.9 16.7 2.9 34.9 19.5 40.8s34.9-2.9 40.8-19.5zM39 289.3c-5 1.5-9.8 4.2-13.7 8.2c-4 4-6.7 8.8-8.1 14c-.3 1.2-.6 2.5-.8 3.8c-.3 1.7-.4 3.4-.4 5.1L16 432c0 17.7 14.3 32 32 32s32-14.3 32-32l0-35.1 17.6 17.5c0 0 0 0 0 0c87.5 87.4 229.3 87.4 316.7 0c24.4-24.4 42.1-53.1 52.9-83.8c5.9-16.7-2.9-34.9-19.5-40.8s-34.9 2.9-40.8 19.5c-7.7 21.8-20.2 42.3-37.8 59.8c-62.5 62.5-163.8 62.5-226.3 0l-.1-.1L125.6 352l34.4 0c17.7 0 32-14.3 32-32s-14.3-32-32-32L48.4 288c-1.6 0-3.2 .1-4.8 .3s-3.1 .5-4.6 1z"/></svg>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
import Search from './Search.svelte';
|
import Search from './Search.svelte';
|
||||||
export let icons
|
export let icons
|
||||||
export let active_icon
|
export let active_icon
|
||||||
|
export let routePrefix = '/rezepte'
|
||||||
|
export let lang = 'de'
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -68,11 +70,11 @@
|
|||||||
|
|
||||||
<div class=flex>
|
<div class=flex>
|
||||||
{#each icons as icon, i}
|
{#each icons as icon, i}
|
||||||
<a class:active={active_icon == icon} href="/rezepte/icon/{icon}">{icon}</a>
|
<a class:active={active_icon == icon} href="{routePrefix}/icon/{icon}">{icon}</a>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
<section>
|
<section>
|
||||||
<Search icon={active_icon}></Search>
|
<Search icon={active_icon} {lang}></Search>
|
||||||
</section>
|
</section>
|
||||||
<section>
|
<section>
|
||||||
<slot name=recipes></slot>
|
<slot name=recipes></slot>
|
||||||
|
|||||||
@@ -4,13 +4,20 @@ import { onNavigate } from "$app/navigation";
|
|||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import HefeSwapper from './HefeSwapper.svelte';
|
import HefeSwapper from './HefeSwapper.svelte';
|
||||||
export let data
|
|
||||||
let multiplier = data.multiplier || 1;
|
let { data } = $props();
|
||||||
|
let multiplier = $state(data.multiplier || 1);
|
||||||
|
|
||||||
|
const isEnglish = $derived(data.lang === 'en');
|
||||||
|
const labels = $derived({
|
||||||
|
portions: isEnglish ? 'Portions:' : 'Portionen:',
|
||||||
|
adjustAmount: isEnglish ? 'Adjust Amount:' : 'Menge anpassen:',
|
||||||
|
ingredients: isEnglish ? 'Ingredients' : 'Zutaten'
|
||||||
|
});
|
||||||
|
|
||||||
// Calculate yeast IDs for each yeast ingredient
|
// Calculate yeast IDs for each yeast ingredient
|
||||||
let yeastIds = {};
|
const yeastIds = $derived.by(() => {
|
||||||
$: {
|
const ids = {};
|
||||||
yeastIds = {};
|
|
||||||
let yeastCounter = 0;
|
let yeastCounter = 0;
|
||||||
if (data.ingredients) {
|
if (data.ingredients) {
|
||||||
for (let listIndex = 0; listIndex < data.ingredients.length; listIndex++) {
|
for (let listIndex = 0; listIndex < data.ingredients.length; listIndex++) {
|
||||||
@@ -18,17 +25,20 @@ $: {
|
|||||||
if (list.list) {
|
if (list.list) {
|
||||||
for (let ingredientIndex = 0; ingredientIndex < list.list.length; ingredientIndex++) {
|
for (let ingredientIndex = 0; ingredientIndex < list.list.length; ingredientIndex++) {
|
||||||
const ingredient = list.list[ingredientIndex];
|
const ingredient = list.list[ingredientIndex];
|
||||||
if (ingredient.name === "Frischhefe" || ingredient.name === "Trockenhefe") {
|
const nameLower = ingredient.name.toLowerCase();
|
||||||
yeastIds[`${listIndex}-${ingredientIndex}`] = yeastCounter++;
|
if (nameLower === "frischhefe" || nameLower === "trockenhefe" ||
|
||||||
|
nameLower === "fresh yeast" || nameLower === "dry yeast") {
|
||||||
|
ids[`${listIndex}-${ingredientIndex}`] = yeastCounter++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
return ids;
|
||||||
|
});
|
||||||
|
|
||||||
// Get all current URL parameters to preserve state in multiplier forms
|
// Get all current URL parameters to preserve state in multiplier forms
|
||||||
$: currentParams = browser ? new URLSearchParams(window.location.search) : $page.url.searchParams;
|
const currentParams = $derived(browser ? new URLSearchParams(window.location.search) : $page.url.searchParams);
|
||||||
|
|
||||||
// Progressive enhancement - use JS if available
|
// Progressive enhancement - use JS if available
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
@@ -318,11 +328,11 @@ span
|
|||||||
{#if data.ingredients}
|
{#if data.ingredients}
|
||||||
<div class=ingredients>
|
<div class=ingredients>
|
||||||
{#if data.portions}
|
{#if data.portions}
|
||||||
<h3>Portionen:</h3>
|
<h3>{labels.portions}</h3>
|
||||||
{@html convertFloatsToFractions(multiplyFirstAndSecondNumbers(data.portions, multiplier))}
|
{@html convertFloatsToFractions(multiplyFirstAndSecondNumbers(data.portions, multiplier))}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<h3>Menge anpassen:</h3>
|
<h3>{labels.adjustAmount}</h3>
|
||||||
<div class=multipliers>
|
<div class=multipliers>
|
||||||
<form method="get" style="display: inline;">
|
<form method="get" style="display: inline;">
|
||||||
<input type="hidden" name="multiplier" value="0.5" />
|
<input type="hidden" name="multiplier" value="0.5" />
|
||||||
@@ -389,7 +399,7 @@ span
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2>Zutaten</h2>
|
<h2>{labels.ingredients}</h2>
|
||||||
{#each data.ingredients as list, listIndex}
|
{#each data.ingredients as list, listIndex}
|
||||||
{#if list.name}
|
{#if list.name}
|
||||||
<h3>{list.name}</h3>
|
<h3>{list.name}</h3>
|
||||||
@@ -399,9 +409,9 @@ span
|
|||||||
<div class=amount>{@html adjust_amount(item.amount, multiplier)} {item.unit}</div>
|
<div class=amount>{@html adjust_amount(item.amount, multiplier)} {item.unit}</div>
|
||||||
<div class=name>
|
<div class=name>
|
||||||
{@html item.name.replace("{{multiplier}}", isNaN(parseFloat(item.amount)) ? multiplier : multiplier * parseFloat(item.amount))}
|
{@html item.name.replace("{{multiplier}}", isNaN(parseFloat(item.amount)) ? multiplier : multiplier * parseFloat(item.amount))}
|
||||||
{#if item.name === "Frischhefe" || item.name === "Trockenhefe"}
|
{#if item.name.toLowerCase() === "frischhefe" || item.name.toLowerCase() === "trockenhefe" || item.name.toLowerCase() === "fresh yeast" || item.name.toLowerCase() === "dry yeast"}
|
||||||
{@const yeastId = yeastIds[`${listIndex}-${ingredientIndex}`] ?? 0}
|
{@const yeastId = yeastIds[`${listIndex}-${ingredientIndex}`] ?? 0}
|
||||||
<HefeSwapper {item} {multiplier} {yeastId} />
|
<HefeSwapper {item} {multiplier} {yeastId} lang={data.lang} />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -1,5 +1,17 @@
|
|||||||
<script>
|
<script>
|
||||||
export let data
|
let { data } = $props();
|
||||||
|
|
||||||
|
const isEnglish = $derived(data.lang === 'en');
|
||||||
|
const labels = $derived({
|
||||||
|
preparation: isEnglish ? 'Preparation:' : 'Vorbereitung:',
|
||||||
|
bulkFermentation: isEnglish ? 'Bulk Fermentation:' : 'Stockgare:',
|
||||||
|
finalProof: isEnglish ? 'Final Proof:' : 'Stückgare:',
|
||||||
|
baking: isEnglish ? 'Baking:' : 'Backen:',
|
||||||
|
cooking: isEnglish ? 'Cooking:' : 'Kochen:',
|
||||||
|
onThePlate: isEnglish ? 'On the Plate:' : 'Auf dem Teller:',
|
||||||
|
instructions: isEnglish ? 'Instructions' : 'Zubereitung',
|
||||||
|
at: isEnglish ? 'at' : 'bei'
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
<style>
|
<style>
|
||||||
*{
|
*{
|
||||||
@@ -59,35 +71,35 @@ h4{
|
|||||||
<div class=instructions>
|
<div class=instructions>
|
||||||
<div class=additional_info>
|
<div class=additional_info>
|
||||||
{#if data.preparation}
|
{#if data.preparation}
|
||||||
<div><h4>Vorbereitung:</h4>{data.preparation}</div>
|
<div><h4>{labels.preparation}</h4>{data.preparation}</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|
||||||
{#if data.fermentation}
|
{#if data.fermentation}
|
||||||
{#if data.fermentation.bulk}
|
{#if data.fermentation.bulk}
|
||||||
<div><h4>Stockgare:</h4>{data.fermentation.bulk}</div>
|
<div><h4>{labels.bulkFermentation}</h4>{data.fermentation.bulk}</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if data.fermentation.final}
|
{#if data.fermentation.final}
|
||||||
<div><h4>Stückgare:</h4> {data.fermentation.final}</div>
|
<div><h4>{labels.finalProof}</h4> {data.fermentation.final}</div>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if data.baking.temperature}
|
{#if data.baking.temperature}
|
||||||
<div><h4>Backen:</h4> {data.baking.length} bei {data.baking.temperature} °C {data.baking.mode}</div>
|
<div><h4>{labels.baking}</h4> {data.baking.length} {labels.at} {data.baking.temperature} °C {data.baking.mode}</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if data.cooking}
|
{#if data.cooking}
|
||||||
<div><h4>Kochen:</h4>{data.cooking}</div>
|
<div><h4>{labels.cooking}</h4>{data.cooking}</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if data.total_time}
|
{#if data.total_time}
|
||||||
<div><h4>Auf dem Teller:</h4>{data.total_time}</div>
|
<div><h4>{labels.onThePlate}</h4>{data.total_time}</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if data.instructions}
|
{#if data.instructions}
|
||||||
<h2>Zubereitung</h2>
|
<h2>{labels.instructions}</h2>
|
||||||
{#each data.instructions as list}
|
{#each data.instructions as list}
|
||||||
{#if list.name}
|
{#if list.name}
|
||||||
<h3>{list.name}</h3>
|
<h3>{list.name}</h3>
|
||||||
|
|||||||
135
src/lib/components/RecipeLanguageSwitcher.svelte
Normal file
135
src/lib/components/RecipeLanguageSwitcher.svelte
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
export let germanUrl: string;
|
||||||
|
export let englishUrl: string;
|
||||||
|
export let currentLang: 'de' | 'en' = 'de';
|
||||||
|
export let hasTranslation: boolean = true;
|
||||||
|
|
||||||
|
function setLanguagePreference(lang: 'de' | 'en') {
|
||||||
|
if (typeof localStorage !== 'undefined') {
|
||||||
|
localStorage.setItem('preferredLanguage', lang);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</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"
|
||||||
|
onclick={() => setLanguagePreference('de')}
|
||||||
|
>
|
||||||
|
<span class="flag">🇩🇪</span>
|
||||||
|
<span class="label">DE</span>
|
||||||
|
</a>
|
||||||
|
{#if hasTranslation}
|
||||||
|
<a
|
||||||
|
href={englishUrl}
|
||||||
|
class:active={currentLang === 'en'}
|
||||||
|
aria-label="Switch to English"
|
||||||
|
onclick={() => setLanguagePreference('en')}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
@@ -2,16 +2,19 @@
|
|||||||
import {onMount} from "svelte";
|
import {onMount} from "svelte";
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import "$lib/css/nordtheme.css";
|
import "$lib/css/nordtheme.css";
|
||||||
|
|
||||||
// Filter props for different contexts
|
// Filter props for different contexts
|
||||||
export let category = null;
|
let { category = null, tag = null, icon = null, season = null, favoritesOnly = false, lang = 'de' } = $props();
|
||||||
export let tag = null;
|
|
||||||
export let icon = null;
|
const isEnglish = $derived(lang === 'en');
|
||||||
export let season = null;
|
const searchResultsUrl = $derived(isEnglish ? '/recipes/search' : '/rezepte/search');
|
||||||
export let favoritesOnly = false;
|
const labels = $derived({
|
||||||
export let searchResultsUrl = '/rezepte/search';
|
placeholder: isEnglish ? 'Search...' : 'Suche...',
|
||||||
|
searchTitle: isEnglish ? 'Search' : 'Suchen',
|
||||||
let searchQuery = '';
|
clearTitle: isEnglish ? 'Clear search' : 'Sucheintrag löschen'
|
||||||
|
});
|
||||||
|
|
||||||
|
let searchQuery = $state('');
|
||||||
|
|
||||||
// Build search URL with current filters
|
// Build search URL with current filters
|
||||||
function buildSearchUrl(query) {
|
function buildSearchUrl(query) {
|
||||||
@@ -190,15 +193,15 @@ scale: 0.8 0.8;
|
|||||||
{#if season}<input type="hidden" name="season" value={season} />{/if}
|
{#if season}<input type="hidden" name="season" value={season} />{/if}
|
||||||
{#if favoritesOnly}<input type="hidden" name="favorites" value="true" />{/if}
|
{#if favoritesOnly}<input type="hidden" name="favorites" value="true" />{/if}
|
||||||
|
|
||||||
<input type="text" id="search" name="q" placeholder="Suche..." bind:value={searchQuery}>
|
<input type="text" id="search" name="q" placeholder={labels.placeholder} bind:value={searchQuery}>
|
||||||
|
|
||||||
<!-- Submit button (visible by default, hidden when JS loads) -->
|
<!-- Submit button (visible by default, hidden when JS loads) -->
|
||||||
<button type="submit" id="submit-search" class="search-button" style="display: flex;">
|
<button type="submit" id="submit-search" class="search-button" style="display: flex;">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512" style="width: 100%; height: 100%;"><title>Suchen</title><path d="M221.09 64a157.09 157.09 0 10157.09 157.09A157.1 157.1 0 00221.09 64z" fill="none" stroke="currentColor" stroke-miterlimit="10" stroke-width="32"></path><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-miterlimit="10" stroke-width="32" d="m338.29 338.29 105.25 105.25"></path></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512" style="width: 100%; height: 100%;"><title>{labels.searchTitle}</title><path d="M221.09 64a157.09 157.09 0 10157.09 157.09A157.1 157.1 0 00221.09 64z" fill="none" stroke="currentColor" stroke-miterlimit="10" stroke-width="32"></path><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-miterlimit="10" stroke-width="32" d="m338.29 338.29 105.25 105.25"></path></svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Clear button (hidden by default, shown when JS loads) -->
|
<!-- Clear button (hidden by default, shown when JS loads) -->
|
||||||
<button type="button" id="clear-search" class="search-button js-only" style="display: none;" on:click={clearSearch}>
|
<button type="button" id="clear-search" class="search-button js-only" style="display: none;" on:click={clearSearch}>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><title>Sucheintrag löschen</title><path d="M135.19 390.14a28.79 28.79 0 0021.68 9.86h246.26A29 29 0 00432 371.13V140.87A29 29 0 00403.13 112H156.87a28.84 28.84 0 00-21.67 9.84v0L46.33 256l88.86 134.11z" fill="none" stroke="currentColor" stroke-linejoin="round" stroke-width="32"></path><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="32" d="M336.67 192.33L206.66 322.34M336.67 322.34L206.66 192.33M336.67 192.33L206.66 322.34M336.67 322.34L206.66 192.33"></path></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><title>{labels.clearTitle}</title><path d="M135.19 390.14a28.79 28.79 0 0021.68 9.86h246.26A29 29 0 00432 371.13V140.87A29 29 0 00403.13 112H156.87a28.84 28.84 0 00-21.67 9.84v0L46.33 256l88.86 134.11z" fill="none" stroke="currentColor" stroke-linejoin="round" stroke-width="32"></path><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="32" d="M336.67 192.33L206.66 322.34M336.67 322.34L206.66 192.33M336.67 192.33L206.66 322.34M336.67 322.34L206.66 192.33"></path></svg>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -2,9 +2,11 @@
|
|||||||
import '$lib/css/nordtheme.css';
|
import '$lib/css/nordtheme.css';
|
||||||
import Recipes from '$lib/components/Recipes.svelte';
|
import Recipes from '$lib/components/Recipes.svelte';
|
||||||
import Search from './Search.svelte';
|
import Search from './Search.svelte';
|
||||||
let months = ["Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember"]
|
export let months = ["Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember"]
|
||||||
let month : number;
|
let month : number;
|
||||||
export let active_index;
|
export let active_index;
|
||||||
|
export let routePrefix = '/rezepte';
|
||||||
|
export let lang = 'de';
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
<style>
|
<style>
|
||||||
@@ -37,11 +39,11 @@ a.month:hover,
|
|||||||
|
|
||||||
<div class=months>
|
<div class=months>
|
||||||
{#each months as month, i}
|
{#each months as month, i}
|
||||||
<a class:active={i == active_index} class=month href="/rezepte/season/{i+1}">{month}</a>
|
<a class:active={i == active_index} class=month href="{routePrefix}/season/{i+1}">{month}</a>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
<section>
|
<section>
|
||||||
<Search season={active_index + 1}></Search>
|
<Search season={active_index + 1} {lang}></Search>
|
||||||
</section>
|
</section>
|
||||||
<section>
|
<section>
|
||||||
<slot name=recipes></slot>
|
<slot name=recipes></slot>
|
||||||
|
|||||||
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>
|
||||||
@@ -1,17 +1,88 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
export let user;
|
import { goto } from '$app/navigation';
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { recipeTranslationStore } from '$lib/stores/recipeTranslation';
|
||||||
|
|
||||||
|
let { user, showLanguageSelector = false } = $props();
|
||||||
|
|
||||||
|
let currentLang = $state('de');
|
||||||
|
let currentPath = $state('');
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
// Update current language and path when page changes
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const path = window.location.pathname;
|
||||||
|
currentPath = path;
|
||||||
|
if (path.startsWith('/recipes')) {
|
||||||
|
currentLang = 'en';
|
||||||
|
} else if (path.startsWith('/rezepte')) {
|
||||||
|
currentLang = 'de';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
function toggle_options(){
|
function toggle_options(){
|
||||||
const el = document.querySelector("#options")
|
const el = document.querySelector("#options")
|
||||||
el.hidden = !el.hidden
|
el.hidden = !el.hidden
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggle_language_options(){
|
||||||
|
const el = document.querySelector("#language-options")
|
||||||
|
el.hidden = !el.hidden
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchLanguage(lang: 'de' | 'en') {
|
||||||
|
// Update the current language state immediately
|
||||||
|
currentLang = lang;
|
||||||
|
|
||||||
|
// Store preference
|
||||||
|
if (typeof localStorage !== 'undefined') {
|
||||||
|
localStorage.setItem('preferredLanguage', lang);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the current path directly from window
|
||||||
|
const path = typeof window !== 'undefined' ? window.location.pathname : currentPath;
|
||||||
|
|
||||||
|
// If we have recipe translation data from store, use the correct short names
|
||||||
|
const recipeData = $recipeTranslationStore;
|
||||||
|
if (recipeData) {
|
||||||
|
if (lang === 'en' && recipeData.englishShortName) {
|
||||||
|
goto(`/recipes/${recipeData.englishShortName}`);
|
||||||
|
return;
|
||||||
|
} else if (lang === 'de' && recipeData.germanShortName) {
|
||||||
|
goto(`/rezepte/${recipeData.germanShortName}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert current path to target language (for non-recipe pages)
|
||||||
|
let newPath = path;
|
||||||
|
if (lang === 'en' && path.startsWith('/rezepte')) {
|
||||||
|
newPath = path.replace('/rezepte', '/recipes');
|
||||||
|
} else if (lang === 'de' && path.startsWith('/recipes')) {
|
||||||
|
newPath = path.replace('/recipes', '/rezepte');
|
||||||
|
} else if (path === '/' || (!path.startsWith('/rezepte') && !path.startsWith('/recipes'))) {
|
||||||
|
// On main page or other pages, go to recipe home
|
||||||
|
newPath = lang === 'en' ? '/recipes' : '/rezepte';
|
||||||
|
}
|
||||||
|
|
||||||
|
goto(newPath);
|
||||||
|
}
|
||||||
|
|
||||||
onMount( () => {
|
onMount( () => {
|
||||||
document.addEventListener("click", (e) => {
|
document.addEventListener("click", (e) => {
|
||||||
const el = document.querySelector("#button")
|
const userButton = document.querySelector("#button")
|
||||||
if(!el.contains(e.target)){
|
const langButton = document.querySelector("#language-button")
|
||||||
document.querySelector("#options").hidden = true
|
|
||||||
|
if(userButton && !userButton.contains(e.target)){
|
||||||
|
const options = document.querySelector("#options");
|
||||||
|
if (options) options.hidden = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(langButton && !langButton.contains(e.target)){
|
||||||
|
const langOptions = document.querySelector("#language-options");
|
||||||
|
if (langOptions) langOptions.hidden = true;
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -68,6 +139,59 @@
|
|||||||
background-position: center;
|
background-position: center;
|
||||||
background-size: contain;
|
background-size: contain;
|
||||||
}
|
}
|
||||||
|
.language-selector{
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
#language-button{
|
||||||
|
width: auto;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
background-color: var(--nord3);
|
||||||
|
color: white;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 100ms;
|
||||||
|
}
|
||||||
|
#language-button:hover{
|
||||||
|
background-color: var(--nord2);
|
||||||
|
}
|
||||||
|
#language-options{
|
||||||
|
--bg_color: var(--nord3);
|
||||||
|
box-sizing: border-box;
|
||||||
|
border-radius: 5px;
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: calc(100% + 10px);
|
||||||
|
background-color: var(--bg_color);
|
||||||
|
width: 10ch;
|
||||||
|
padding: 0.5rem;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
#language-options button{
|
||||||
|
width: 100%;
|
||||||
|
background-color: transparent;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 0.5rem;
|
||||||
|
margin: 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
text-align: left;
|
||||||
|
transition: background-color 100ms;
|
||||||
|
}
|
||||||
|
#language-options button:hover{
|
||||||
|
background-color: var(--nord2);
|
||||||
|
}
|
||||||
|
#language-options button.active{
|
||||||
|
background-color: var(--nord14);
|
||||||
|
}
|
||||||
|
.header-right{
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
#options{
|
#options{
|
||||||
--bg_color: var(--nord3);
|
--bg_color: var(--nord3);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
@@ -132,17 +256,41 @@ h2 + p{
|
|||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
{#if user}
|
<div class="header-right">
|
||||||
<button on:click={toggle_options} style="background-image: url(https://bocken.org/static/user/thumb/{user.nickname}.webp)" id=button>
|
{#if showLanguageSelector}
|
||||||
<div id=options class="speech top" hidden>
|
<div class="language-selector">
|
||||||
<h2>{user.name}</h2>
|
<button on:click={toggle_language_options} id="language-button">
|
||||||
<p>({user.nickname})</p>
|
{currentLang.toUpperCase()}
|
||||||
<ul>
|
</button>
|
||||||
<li><a href="https://sso.bocken.org/if/user/#/settings" >Einstellungen</a></li>
|
<div id="language-options" hidden>
|
||||||
<li><a href="/logout" >Log Out</a></li>
|
<button
|
||||||
</ul>
|
class:active={currentLang === 'de'}
|
||||||
|
on:click={() => switchLanguage('de')}
|
||||||
|
>
|
||||||
|
DE
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class:active={currentLang === 'en'}
|
||||||
|
on:click={() => switchLanguage('en')}
|
||||||
|
>
|
||||||
|
EN
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
{/if}
|
||||||
{:else}
|
|
||||||
<a class=entry href=/login>Log In</a>
|
{#if user}
|
||||||
{/if}
|
<button on:click={toggle_options} style="background-image: url(https://bocken.org/static/user/thumb/{user.nickname}.webp)" id=button>
|
||||||
|
<div id=options class="speech top" hidden>
|
||||||
|
<h2>{user.name}</h2>
|
||||||
|
<p>({user.nickname})</p>
|
||||||
|
<ul>
|
||||||
|
<li><a href="https://sso.bocken.org/if/user/#/settings" >Einstellungen</a></li>
|
||||||
|
<li><a href="/logout" >Log Out</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<a class=entry href=/login>Log In</a>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|||||||
9
src/lib/stores/recipeTranslation.ts
Normal file
9
src/lib/stores/recipeTranslation.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { writable } from 'svelte/store';
|
||||||
|
|
||||||
|
interface RecipeTranslationData {
|
||||||
|
germanShortName: string;
|
||||||
|
englishShortName?: string;
|
||||||
|
hasEnglishTranslation: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const recipeTranslationStore = writable<RecipeTranslationData | null>(null);
|
||||||
@@ -39,7 +39,53 @@ const RecipeSchema = new mongoose.Schema(
|
|||||||
steps: [String]}],
|
steps: [String]}],
|
||||||
preamble : String,
|
preamble : String,
|
||||||
addendum : 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}
|
}, {timestamps: true}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Indexes for efficient querying
|
||||||
|
RecipeSchema.index({ "translations.en.short_name": 1 });
|
||||||
|
|
||||||
export const Recipe = mongoose.model("Recipe", RecipeSchema);
|
export const Recipe = mongoose.model("Recipe", RecipeSchema);
|
||||||
|
|||||||
@@ -1,7 +1,20 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import "$lib/css/nordtheme.css";
|
import "$lib/css/nordtheme.css";
|
||||||
import LinksGrid from "$lib/components/LinksGrid.svelte";
|
import LinksGrid from "$lib/components/LinksGrid.svelte";
|
||||||
export let data;
|
import { onMount } from 'svelte';
|
||||||
|
let { data } = $props();
|
||||||
|
|
||||||
|
let recipesUrl = $state('/rezepte');
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
// Check localStorage for preferred language
|
||||||
|
const preferredLanguage = localStorage.getItem('preferredLanguage');
|
||||||
|
if (preferredLanguage === 'en') {
|
||||||
|
recipesUrl = '/recipes';
|
||||||
|
} else {
|
||||||
|
recipesUrl = '/rezepte';
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
<style>
|
<style>
|
||||||
.hero{
|
.hero{
|
||||||
@@ -80,7 +93,7 @@ section h2{
|
|||||||
|
|
||||||
<h2>Seiten</h2>
|
<h2>Seiten</h2>
|
||||||
<LinksGrid>
|
<LinksGrid>
|
||||||
<a href="rezepte">
|
<a href={recipesUrl}>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><!--!Font Awesome Free 6.5.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.--><path d="M240 144A96 96 0 1 0 48 144a96 96 0 1 0 192 0zm44.4 32C269.9 240.1 212.5 288 144 288C64.5 288 0 223.5 0 144S64.5 0 144 0c68.5 0 125.9 47.9 140.4 112h71.8c8.8-9.8 21.6-16 35.8-16H496c26.5 0 48 21.5 48 48s-21.5 48-48 48H392c-14.2 0-27-6.2-35.8-16H284.4zM144 80a64 64 0 1 1 0 128 64 64 0 1 1 0-128zM400 240c13.3 0 24 10.7 24 24v8h96c13.3 0 24 10.7 24 24s-10.7 24-24 24H280c-13.3 0-24-10.7-24-24s10.7-24 24-24h96v-8c0-13.3 10.7-24 24-24zM288 464V352H512V464c0 26.5-21.5 48-48 48H336c-26.5 0-48-21.5-48-48zM48 320h80 16 32c26.5 0 48 21.5 48 48s-21.5 48-48 48H160c0 17.7-14.3 32-32 32H64c-17.7 0-32-14.3-32-32V336c0-8.8 7.2-16 16-16zm128 64c8.8 0 16-7.2 16-16s-7.2-16-16-16H160v32h16zM24 464H200c13.3 0 24 10.7 24 24s-10.7 24-24 24H24c-13.3 0-24-10.7-24-24s10.7-24 24-24z"/></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><!--!Font Awesome Free 6.5.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.--><path d="M240 144A96 96 0 1 0 48 144a96 96 0 1 0 192 0zm44.4 32C269.9 240.1 212.5 288 144 288C64.5 288 0 223.5 0 144S64.5 0 144 0c68.5 0 125.9 47.9 140.4 112h71.8c8.8-9.8 21.6-16 35.8-16H496c26.5 0 48 21.5 48 48s-21.5 48-48 48H392c-14.2 0-27-6.2-35.8-16H284.4zM144 80a64 64 0 1 1 0 128 64 64 0 1 1 0-128zM400 240c13.3 0 24 10.7 24 24v8h96c13.3 0 24 10.7 24 24s-10.7 24-24 24H280c-13.3 0-24-10.7-24-24s10.7-24 24-24h96v-8c0-13.3 10.7-24 24-24zM288 464V352H512V464c0 26.5-21.5 48-48 48H336c-26.5 0-48-21.5-48-48zM48 320h80 16 32c26.5 0 48 21.5 48 48s-21.5 48-48 48H160c0 17.7-14.3 32-32 32H64c-17.7 0-32-14.3-32-32V336c0-8.8 7.2-16 16-16zm128 64c8.8 0 16-7.2 16-16s-7.2-16-16-16H160v32h16zM24 464H200c13.3 0 24 10.7 24 24s-10.7 24-24 24H24c-13.3 0-24-10.7-24-24s10.7-24 24-24z"/></svg>
|
||||||
<h3>Rezepte</h3>
|
<h3>Rezepte</h3>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
17
src/routes/[recipeLang]/+layout.server.ts
Normal file
17
src/routes/[recipeLang]/+layout.server.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import type { LayoutServerLoad } from "./$types"
|
||||||
|
import { error } from "@sveltejs/kit";
|
||||||
|
|
||||||
|
export const load : LayoutServerLoad = async ({locals, params}) => {
|
||||||
|
// Validate recipeLang parameter
|
||||||
|
if (params.recipeLang !== 'rezepte' && params.recipeLang !== 'recipes') {
|
||||||
|
throw error(404, 'Not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const lang = params.recipeLang === 'recipes' ? 'en' : 'de';
|
||||||
|
|
||||||
|
return {
|
||||||
|
session: await locals.auth(),
|
||||||
|
lang,
|
||||||
|
recipeLang: params.recipeLang
|
||||||
|
}
|
||||||
|
};
|
||||||
34
src/routes/[recipeLang]/+layout.svelte
Normal file
34
src/routes/[recipeLang]/+layout.svelte
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<script>
|
||||||
|
import Header from '$lib/components/Header.svelte'
|
||||||
|
import UserHeader from '$lib/components/UserHeader.svelte';
|
||||||
|
let { data } = $props();
|
||||||
|
|
||||||
|
let user = $derived(data.session?.user);
|
||||||
|
|
||||||
|
const isEnglish = $derived(data.lang === 'en');
|
||||||
|
const labels = $derived({
|
||||||
|
allRecipes: isEnglish ? 'All Recipes' : 'Alle Rezepte',
|
||||||
|
favorites: isEnglish ? 'Favorites' : 'Favoriten',
|
||||||
|
inSeason: isEnglish ? 'In Season' : 'In Saison',
|
||||||
|
category: isEnglish ? 'Category' : 'Kategorie',
|
||||||
|
icon: 'Icon',
|
||||||
|
keywords: isEnglish ? 'Keywords' : 'Stichwörter',
|
||||||
|
tips: isEnglish ? 'Tips' : 'Tipps'
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Header>
|
||||||
|
<ul class=site_header slot=links>
|
||||||
|
<li><a href="/{data.recipeLang}">{labels.allRecipes}</a></li>
|
||||||
|
{#if user}
|
||||||
|
<li><a href="/{data.recipeLang}/favorites">{labels.favorites}</a></li>
|
||||||
|
{/if}
|
||||||
|
<li><a href="/{data.recipeLang}/season">{labels.inSeason}</a></li>
|
||||||
|
<li><a href="/{data.recipeLang}/category">{labels.category}</a></li>
|
||||||
|
<li><a href="/{data.recipeLang}/icon">{labels.icon}</a></li>
|
||||||
|
<li><a href="/{data.recipeLang}/tag">{labels.keywords}</a></li>
|
||||||
|
<li><a href="/rezepte/tips-and-tricks">{labels.tips}</a></li>
|
||||||
|
</ul>
|
||||||
|
<UserHeader slot=right_side {user} showLanguageSelector={true}></UserHeader>
|
||||||
|
<slot></slot>
|
||||||
|
</Header>
|
||||||
@@ -1,19 +1,22 @@
|
|||||||
import type { PageServerLoad } from "./$types";
|
import type { PageServerLoad } from "./$types";
|
||||||
import { getUserFavorites, addFavoriteStatusToRecipes } from "$lib/server/favorites";
|
import { getUserFavorites, addFavoriteStatusToRecipes } from "$lib/server/favorites";
|
||||||
|
|
||||||
export async function load({ fetch, locals }) {
|
export const load: PageServerLoad = async ({ fetch, locals, params }) => {
|
||||||
|
const isEnglish = params.recipeLang === 'recipes';
|
||||||
|
const apiBase = isEnglish ? '/api/recipes' : '/api/rezepte';
|
||||||
|
|
||||||
let current_month = new Date().getMonth() + 1
|
let current_month = new Date().getMonth() + 1
|
||||||
const res_season = await fetch(`/api/rezepte/items/in_season/` + current_month);
|
const res_season = await fetch(`${apiBase}/items/in_season/` + current_month);
|
||||||
const res_all_brief = await fetch(`/api/rezepte/items/all_brief`);
|
const res_all_brief = await fetch(`${apiBase}/items/all_brief`);
|
||||||
const item_season = await res_season.json();
|
const item_season = await res_season.json();
|
||||||
const item_all_brief = await res_all_brief.json();
|
const item_all_brief = await res_all_brief.json();
|
||||||
|
|
||||||
// Get user favorites and session
|
// Get user favorites and session
|
||||||
const [userFavorites, session] = await Promise.all([
|
const [userFavorites, session] = await Promise.all([
|
||||||
getUserFavorites(fetch, locals),
|
getUserFavorites(fetch, locals),
|
||||||
locals.auth()
|
locals.auth()
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
season: addFavoriteStatusToRecipes(item_season, userFavorites),
|
season: addFavoriteStatusToRecipes(item_season, userFavorites),
|
||||||
all_brief: addFavoriteStatusToRecipes(item_all_brief, userFavorites),
|
all_brief: addFavoriteStatusToRecipes(item_all_brief, userFavorites),
|
||||||
69
src/routes/[recipeLang]/+page.svelte
Normal file
69
src/routes/[recipeLang]/+page.svelte
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
<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';
|
||||||
|
let { data }: { data: PageData } = $props();
|
||||||
|
let current_month = new Date().getMonth() + 1
|
||||||
|
|
||||||
|
const isEnglish = $derived(data.lang === 'en');
|
||||||
|
const categories = $derived(isEnglish
|
||||||
|
? ["Main Course", "Pasta", "Bread", "Dessert", "Soup", "Side Dish", "Salad", "Cake", "Breakfast", "Sauce", "Ingredient", "Drink", "Spread", "Cookie", "Snack"]
|
||||||
|
: ["Hauptspeise", "Nudel", "Brot", "Dessert", "Suppe", "Beilage", "Salat", "Kuchen", "Frühstück", "Sauce", "Zutat", "Getränk", "Aufstrich", "Guetzli", "Snack"]);
|
||||||
|
|
||||||
|
const labels = $derived({
|
||||||
|
title: isEnglish ? 'Recipes' : 'Rezepte',
|
||||||
|
subheading: isEnglish
|
||||||
|
? `${data.all_brief.length} recipes and constantly growing...`
|
||||||
|
: `${data.all_brief.length} Rezepte und stetig wachsend...`,
|
||||||
|
inSeason: isEnglish ? 'In Season' : 'In Saison',
|
||||||
|
metaTitle: isEnglish ? 'Bocken Recipes' : 'Bocken Rezepte',
|
||||||
|
metaDescription: isEnglish
|
||||||
|
? "A constantly growing collection of recipes from Bocken's kitchen."
|
||||||
|
: "Eine stetig wachsende Ansammlung an Rezepten aus der Bockenschen Küche.",
|
||||||
|
metaAlt: isEnglish ? 'Pasta al Ragu with Linguine' : 'Pasta al Ragu mit Linguine'
|
||||||
|
});
|
||||||
|
</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>{labels.metaTitle}</title>
|
||||||
|
<meta name="description" content="{labels.metaDescription}" />
|
||||||
|
<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="{labels.metaAlt}" />
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<h1>{labels.title}</h1>
|
||||||
|
<p class=subheading>{labels.subheading}</p>
|
||||||
|
|
||||||
|
<Search lang={data.lang}></Search>
|
||||||
|
|
||||||
|
<MediaScroller title={labels.inSeason}>
|
||||||
|
{#each data.season as recipe}
|
||||||
|
<Card {recipe} {current_month} loading_strat={"eager"} do_margin_right={true} isFavorite={recipe.isFavorite} showFavoriteIndicator={!!data.session?.user} routePrefix="/{data.recipeLang}"></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="/{data.recipeLang}"></Card>
|
||||||
|
{/each}
|
||||||
|
</MediaScroller>
|
||||||
|
{/each}
|
||||||
|
{#if !isEnglish}
|
||||||
|
<AddButton href="/rezepte/add"></AddButton>
|
||||||
|
{/if}
|
||||||
@@ -13,16 +13,40 @@
|
|||||||
import RecipeNote from '$lib/components/RecipeNote.svelte';
|
import RecipeNote from '$lib/components/RecipeNote.svelte';
|
||||||
import {stripHtmlTags} from '$lib/js/stripHtmlTags';
|
import {stripHtmlTags} from '$lib/js/stripHtmlTags';
|
||||||
import FavoriteButton from '$lib/components/FavoriteButton.svelte';
|
import FavoriteButton from '$lib/components/FavoriteButton.svelte';
|
||||||
|
import RecipeLanguageSwitcher from '$lib/components/RecipeLanguageSwitcher.svelte';
|
||||||
|
import { onMount, onDestroy } from 'svelte';
|
||||||
|
import { recipeTranslationStore } from '$lib/stores/recipeTranslation';
|
||||||
|
|
||||||
export let data: PageData;
|
let { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
|
// Set store for recipe translation data so UserHeader can access it
|
||||||
|
onMount(() => {
|
||||||
|
recipeTranslationStore.set({
|
||||||
|
germanShortName: data.germanShortName || data.short_name,
|
||||||
|
englishShortName: data.englishShortName,
|
||||||
|
hasEnglishTranslation: data.hasEnglishTranslation || false
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear store when leaving recipe page
|
||||||
|
onDestroy(() => {
|
||||||
|
recipeTranslationStore.set(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
const isEnglish = $derived(data.lang === 'en');
|
||||||
|
|
||||||
|
// Use German short_name for images (they're the same for both languages)
|
||||||
|
const imageShortName = $derived(data.germanShortName || data.short_name);
|
||||||
|
const hero_img_src = $derived("https://bocken.org/static/rezepte/full/" + imageShortName + ".webp?v=" + data.dateModified);
|
||||||
|
const placeholder_src = $derived("https://bocken.org/static/rezepte/placeholder/" + imageShortName + ".webp?v=" + data.dateModified);
|
||||||
|
|
||||||
|
const months = $derived(isEnglish
|
||||||
|
? ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]
|
||||||
|
: ["Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember"]);
|
||||||
|
|
||||||
let hero_img_src = "https://bocken.org/static/rezepte/full/" + data.short_name + ".webp?v=" + data.dateModified
|
|
||||||
let placeholder_src = "https://bocken.org/static/rezepte/placeholder/" + data.short_name + ".webp?v=" + data.dateModified
|
|
||||||
export let months = ["Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember"]
|
|
||||||
function season_intervals() {
|
function season_intervals() {
|
||||||
let interval_arr = []
|
let interval_arr = []
|
||||||
|
|
||||||
|
|
||||||
let start_i = 0
|
let start_i = 0
|
||||||
for(var i = 12; i > 0; i--){
|
for(var i = 12; i > 0; i--){
|
||||||
if(data.season.includes(i)){
|
if(data.season.includes(i)){
|
||||||
@@ -52,25 +76,24 @@
|
|||||||
|
|
||||||
return interval_arr
|
return interval_arr
|
||||||
}
|
}
|
||||||
export let season_iv = season_intervals();
|
const season_iv = $derived(season_intervals());
|
||||||
|
|
||||||
afterNavigate(() => {
|
const display_date = $derived(data.updatedAt ? new Date(data.updatedAt) : new Date(data.dateCreated));
|
||||||
hero_img_src = "https://bocken.org/static/rezepte/full/" + data.short_name + ".webp"
|
|
||||||
placeholder_src = "https://bocken.org/static/rezepte/placeholder/" + data.short_name + ".webp"
|
|
||||||
season_iv = season_intervals();
|
|
||||||
})
|
|
||||||
let display_date = new Date(data.dateCreated);
|
|
||||||
if (data.updatedAt){
|
|
||||||
display_date = new Date(data.updatedAt);
|
|
||||||
}
|
|
||||||
const options = {
|
const options = {
|
||||||
day: '2-digit',
|
day: '2-digit',
|
||||||
month: 'short', // German abbreviation for the month
|
month: 'short',
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
};
|
};
|
||||||
const formatted_display_date = display_date.toLocaleDateString('de-DE', options)
|
const formatted_display_date = $derived(display_date.toLocaleDateString(isEnglish ? 'en-US' : 'de-DE', options));
|
||||||
|
|
||||||
|
const labels = $derived({
|
||||||
|
season: isEnglish ? 'Season:' : 'Saison:',
|
||||||
|
keywords: isEnglish ? 'Keywords:' : 'Stichwörter:',
|
||||||
|
lastModified: isEnglish ? 'Last modified:' : 'Letzte Änderung:',
|
||||||
|
title: isEnglish ? "Bocken's Recipes" : "Bocken'sche Rezepte"
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
<style>
|
<style>
|
||||||
*{
|
*{
|
||||||
@@ -271,19 +294,25 @@ h4{
|
|||||||
|
|
||||||
</style>
|
</style>
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>{stripHtmlTags(data.name)} - Bocken'sche Rezepte</title>
|
<title>{stripHtmlTags(data.name)} - {labels.title}</title>
|
||||||
<meta name="description" content="{stripHtmlTags(data.description)}" />
|
<meta name="description" content="{stripHtmlTags(data.description)}" />
|
||||||
<meta property="og:image" content="https://bocken.org/static/rezepte/thumb/{data.short_name}.webp" />
|
<meta property="og:image" content="https://bocken.org/static/rezepte/thumb/{imageShortName}.webp" />
|
||||||
<meta property="og:image:secure_url" content="https://bocken.org/static/rezepte/thumb/{data.short_name}.webp" />
|
<meta property="og:image:secure_url" content="https://bocken.org/static/rezepte/thumb/{imageShortName}.webp" />
|
||||||
<meta property="og:image:type" content="image/webp" />
|
<meta property="og:image:type" content="image/webp" />
|
||||||
<meta property="og:image:alt" content="{stripHtmlTags(data.name)}" />
|
<meta property="og:image:alt" content="{stripHtmlTags(data.name)}" />
|
||||||
{@html `<script type="application/ld+json">${JSON.stringify(data.recipeJsonLd)}</script>`}
|
{@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}" />
|
||||||
|
{#if isEnglish || data.hasEnglishTranslation}
|
||||||
|
<link rel="alternate" hreflang="en" href="https://bocken.org/recipes/{isEnglish ? data.short_name : data.englishShortName}" />
|
||||||
|
{/if}
|
||||||
|
<link rel="alternate" hreflang="x-default" href="https://bocken.org/rezepte/{data.germanShortName}" />
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<TitleImgParallax src={hero_img_src} {placeholder_src}>
|
<TitleImgParallax src={hero_img_src} {placeholder_src}>
|
||||||
<div class=title>
|
<div class=title>
|
||||||
<a class="category" href='/rezepte/category/{data.category}'>{data.category}</a>
|
<a class="category" href='/{data.recipeLang}/category/{data.category}'>{data.category}</a>
|
||||||
<a class="icon" href='/rezepte/icon/{data.icon}'>{data.icon}</a>
|
<a class="icon" href='/{data.recipeLang}/icon/{data.icon}'>{data.icon}</a>
|
||||||
<h1>{@html data.name}</h1>
|
<h1>{@html data.name}</h1>
|
||||||
{#if data.description && ! data.preamble}
|
{#if data.description && ! data.preamble}
|
||||||
<p class=description>{data.description}</p>
|
<p class=description>{data.description}</p>
|
||||||
@@ -292,9 +321,9 @@ h4{
|
|||||||
<p>{@html data.preamble}</p>
|
<p>{@html data.preamble}</p>
|
||||||
{/if}
|
{/if}
|
||||||
<div class=tags>
|
<div class=tags>
|
||||||
<h4>Saison:</h4>
|
<h4>{labels.season}</h4>
|
||||||
{#each season_iv as season}
|
{#each season_iv as season}
|
||||||
<a class=tag href="/rezepte/season/{season[0]}">
|
<a class=tag href="/{data.recipeLang}/season/{season[0]}">
|
||||||
{#if season[0]}
|
{#if season[0]}
|
||||||
{months[season[0] - 1]}
|
{months[season[0] - 1]}
|
||||||
{/if}
|
{/if}
|
||||||
@@ -304,19 +333,19 @@ h4{
|
|||||||
</a>
|
</a>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
<h4>Stichwörter:</h4>
|
<h4>{labels.keywords}</h4>
|
||||||
<div class="tags center">
|
<div class="tags center">
|
||||||
{#each data.tags as tag}
|
{#each data.tags as tag}
|
||||||
<a class=tag href="/rezepte/tag/{tag}">{tag}</a>
|
<a class=tag href="/{data.recipeLang}/tag/{tag}">{tag}</a>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FavoriteButton
|
<FavoriteButton
|
||||||
recipeId={data.short_name}
|
recipeId={data.germanShortName}
|
||||||
isFavorite={data.isFavorite || false}
|
isFavorite={data.isFavorite || false}
|
||||||
isLoggedIn={!!data.session?.user}
|
isLoggedIn={!!data.session?.user}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{#if data.note}
|
{#if data.note}
|
||||||
<RecipeNote note={data.note}></RecipeNote>
|
<RecipeNote note={data.note}></RecipeNote>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -332,8 +361,8 @@ h4{
|
|||||||
{@html data.addendum}
|
{@html data.addendum}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<p class=date>Letzte Änderung: {formatted_display_date}</p>
|
<p class=date>{labels.lastModified} {formatted_display_date}</p>
|
||||||
</div>
|
</div>
|
||||||
</TitleImgParallax>
|
</TitleImgParallax>
|
||||||
|
|
||||||
<EditButton href="/rezepte/edit/{data.short_name}"></EditButton>
|
<EditButton href="/rezepte/edit/{data.germanShortName}"></EditButton>
|
||||||
@@ -2,12 +2,15 @@ import { error } from "@sveltejs/kit";
|
|||||||
import { generateRecipeJsonLd } from '$lib/js/recipeJsonLd';
|
import { generateRecipeJsonLd } from '$lib/js/recipeJsonLd';
|
||||||
|
|
||||||
export async function load({ fetch, params, url}) {
|
export async function load({ fetch, params, url}) {
|
||||||
const res = await fetch(`/api/rezepte/items/${params.name}`);
|
const isEnglish = params.recipeLang === 'recipes';
|
||||||
|
const apiBase = isEnglish ? '/api/recipes' : '/api/rezepte';
|
||||||
|
|
||||||
|
const res = await fetch(`${apiBase}/items/${params.name}`);
|
||||||
let item = await res.json();
|
let item = await res.json();
|
||||||
if(!res.ok){
|
if(!res.ok){
|
||||||
throw error(res.status, item.message)
|
throw error(res.status, item.message)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if this recipe is favorited by the user
|
// Check if this recipe is favorited by the user
|
||||||
let isFavorite = false;
|
let isFavorite = false;
|
||||||
try {
|
try {
|
||||||
@@ -35,23 +38,27 @@ export async function load({ fetch, params, url}) {
|
|||||||
for (let ingredientIndex = 0; ingredientIndex < list.list.length; ingredientIndex++) {
|
for (let ingredientIndex = 0; ingredientIndex < list.list.length; ingredientIndex++) {
|
||||||
const ingredient = list.list[ingredientIndex];
|
const ingredient = list.list[ingredientIndex];
|
||||||
|
|
||||||
// Check if this is a yeast ingredient
|
// Check if this is a yeast ingredient (both German and English names, case-insensitive)
|
||||||
if (ingredient.name === "Frischhefe" || ingredient.name === "Trockenhefe") {
|
const nameLower = ingredient.name.toLowerCase();
|
||||||
|
const isFreshYeast = nameLower === "frischhefe" || nameLower === "fresh yeast";
|
||||||
|
const isDryYeast = nameLower === "trockenhefe" || nameLower === "dry yeast";
|
||||||
|
|
||||||
|
if (isFreshYeast || isDryYeast) {
|
||||||
// Check if this yeast should be toggled
|
// Check if this yeast should be toggled
|
||||||
const yeastParam = `y${yeastCounter}`;
|
const yeastParam = `y${yeastCounter}`;
|
||||||
const isToggled = url.searchParams.has(yeastParam);
|
const isToggled = url.searchParams.has(yeastParam);
|
||||||
|
|
||||||
if (isToggled) {
|
if (isToggled) {
|
||||||
// Perform yeast conversion from original recipe data
|
// Perform yeast conversion from original recipe data
|
||||||
const originalName = ingredient.name;
|
const originalName = ingredient.name;
|
||||||
const originalAmount = parseFloat(ingredient.amount);
|
const originalAmount = parseFloat(ingredient.amount);
|
||||||
const originalUnit = ingredient.unit;
|
const originalUnit = ingredient.unit;
|
||||||
|
|
||||||
let newName: string, newAmount: string, newUnit: string;
|
let newName: string, newAmount: string, newUnit: string;
|
||||||
|
|
||||||
if (originalName === "Frischhefe") {
|
if (isFreshYeast) {
|
||||||
// Convert fresh yeast to dry yeast
|
// Convert fresh yeast to dry yeast
|
||||||
newName = "Trockenhefe";
|
newName = isEnglish ? "Dry yeast" : "Trockenhefe";
|
||||||
|
|
||||||
if (originalUnit === "Prise") {
|
if (originalUnit === "Prise") {
|
||||||
// "1 Prise Frischhefe" → "1 Prise Trockenhefe"
|
// "1 Prise Frischhefe" → "1 Prise Trockenhefe"
|
||||||
@@ -66,9 +73,9 @@ export async function load({ fetch, params, url}) {
|
|||||||
newAmount = (originalAmount / 3).toString();
|
newAmount = (originalAmount / 3).toString();
|
||||||
newUnit = "g";
|
newUnit = "g";
|
||||||
}
|
}
|
||||||
} else if (originalName === "Trockenhefe") {
|
} else if (isDryYeast) {
|
||||||
// Convert dry yeast to fresh yeast
|
// Convert dry yeast to fresh yeast
|
||||||
newName = "Frischhefe";
|
newName = isEnglish ? "Fresh yeast" : "Frischhefe";
|
||||||
|
|
||||||
if (originalUnit === "Prise") {
|
if (originalUnit === "Prise") {
|
||||||
// "1 Prise Trockenhefe" → "1 g Frischhefe"
|
// "1 Prise Trockenhefe" → "1 g Frischhefe"
|
||||||
@@ -104,11 +111,20 @@ export async function load({ fetch, params, url}) {
|
|||||||
|
|
||||||
// Generate JSON-LD server-side
|
// Generate JSON-LD server-side
|
||||||
const recipeJsonLd = generateRecipeJsonLd(item);
|
const recipeJsonLd = generateRecipeJsonLd(item);
|
||||||
|
|
||||||
|
// For German page: check if English translation exists
|
||||||
|
// For English page: germanShortName is already in item (from API)
|
||||||
|
const hasEnglishTranslation = !isEnglish && !!(item.translations?.en?.short_name);
|
||||||
|
const englishShortName = !isEnglish ? (item.translations?.en?.short_name || '') : '';
|
||||||
|
const germanShortName = isEnglish ? (item.germanShortName || '') : item.short_name;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...item,
|
...item,
|
||||||
isFavorite,
|
isFavorite,
|
||||||
multiplier,
|
multiplier,
|
||||||
recipeJsonLd
|
recipeJsonLd,
|
||||||
|
hasEnglishTranslation,
|
||||||
|
englishShortName,
|
||||||
|
germanShortName,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
13
src/routes/[recipeLang]/add/+page.server.ts
Normal file
13
src/routes/[recipeLang]/add/+page.server.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { redirect } from "@sveltejs/kit";
|
||||||
|
|
||||||
|
export async function load({locals, params}) {
|
||||||
|
// Add is German-only - redirect to German version
|
||||||
|
if (params.recipeLang === 'recipes') {
|
||||||
|
throw redirect(301, '/rezepte/add');
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await locals.auth();
|
||||||
|
return {
|
||||||
|
user: session?.user
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,12 +1,17 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Check from '$lib/assets/icons/Check.svelte';
|
import Check from '$lib/assets/icons/Check.svelte';
|
||||||
import SeasonSelect from '$lib/components/SeasonSelect.svelte';
|
import SeasonSelect from '$lib/components/SeasonSelect.svelte';
|
||||||
|
import TranslationApproval from '$lib/components/TranslationApproval.svelte';
|
||||||
import '$lib/css/action_button.css'
|
import '$lib/css/action_button.css'
|
||||||
import '$lib/css/nordtheme.css'
|
import '$lib/css/nordtheme.css'
|
||||||
|
|
||||||
let preamble = ""
|
let preamble = ""
|
||||||
let addendum = ""
|
let addendum = ""
|
||||||
|
|
||||||
|
// Translation workflow state
|
||||||
|
let showTranslationWorkflow = false;
|
||||||
|
let translationData: any = null;
|
||||||
|
|
||||||
import { season } from '$lib/js/season_store';
|
import { season } from '$lib/js/season_store';
|
||||||
import { portions } from '$lib/js/portions_store';
|
import { portions } from '$lib/js/portions_store';
|
||||||
import { img } from '$lib/js/img_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()
|
upload_img()
|
||||||
console.log(add_info.total_time)
|
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', {
|
const res = await fetch('/api/rezepte/add', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
recipe: {
|
recipe: recipeData,
|
||||||
...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,
|
|
||||||
},
|
|
||||||
headers: {
|
headers: {
|
||||||
'content-type': 'application/json',
|
'content-type': 'application/json',
|
||||||
}
|
}
|
||||||
})
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if(res.status === 200){
|
if(res.status === 200){
|
||||||
const url = location.href.split('/')
|
const url = location.href.split('/')
|
||||||
url.splice(url.length -1, 1);
|
url.splice(url.length -1, 1);
|
||||||
@@ -282,6 +344,19 @@ button.action_button{
|
|||||||
<div class=addendum bind:innerText={addendum} contenteditable></div>
|
<div class=addendum bind:innerText={addendum} contenteditable></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if !showTranslationWorkflow}
|
||||||
<div class=submit_buttons>
|
<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>
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if showTranslationWorkflow}
|
||||||
|
<div id="translation-section">
|
||||||
|
<TranslationApproval
|
||||||
|
germanData={getGermanRecipeData()}
|
||||||
|
on:approved={handleTranslationApproved}
|
||||||
|
on:skipped={handleTranslationSkipped}
|
||||||
|
on:cancelled={handleTranslationCancelled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -1,9 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
import "$lib/css/nordtheme.css";
|
import "$lib/css/nordtheme.css";
|
||||||
export let data: PageData;
|
let { data }: { data: PageData } = $props();
|
||||||
import TagCloud from '$lib/components/TagCloud.svelte';
|
import TagCloud from '$lib/components/TagCloud.svelte';
|
||||||
import TagBall from '$lib/components/TagBall.svelte';
|
import TagBall from '$lib/components/TagBall.svelte';
|
||||||
|
|
||||||
|
const isEnglish = $derived(data.lang === 'en');
|
||||||
|
const labels = $derived({
|
||||||
|
title: isEnglish ? 'Categories' : 'Kategorien'
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
<style>
|
<style>
|
||||||
h1 {
|
h1 {
|
||||||
@@ -11,11 +16,11 @@
|
|||||||
font-size: 3rem;
|
font-size: 3rem;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<h1>Kategorien</h1>
|
<h1>{labels.title}</h1>
|
||||||
<section>
|
<section>
|
||||||
<TagCloud>
|
<TagCloud>
|
||||||
{#each data.categories as tag}
|
{#each data.categories as tag}
|
||||||
<TagBall {tag} ref="/rezepte/category">
|
<TagBall {tag} ref="/{data.recipeLang}/category">
|
||||||
</TagBall>
|
</TagBall>
|
||||||
{/each}
|
{/each}
|
||||||
</TagCloud>
|
</TagCloud>
|
||||||
10
src/routes/[recipeLang]/category/+page.ts
Normal file
10
src/routes/[recipeLang]/category/+page.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import type { PageLoad } from "./$types";
|
||||||
|
|
||||||
|
export async function load({ fetch, params}) {
|
||||||
|
const isEnglish = params.recipeLang === 'recipes';
|
||||||
|
const apiBase = isEnglish ? '/api/recipes' : '/api/rezepte';
|
||||||
|
|
||||||
|
const res = await fetch(`${apiBase}/items/category`);
|
||||||
|
const categories= await res.json();
|
||||||
|
return {categories}
|
||||||
|
};
|
||||||
@@ -2,15 +2,18 @@ import type { PageServerLoad } from "./$types";
|
|||||||
import { getUserFavorites, addFavoriteStatusToRecipes } from "$lib/server/favorites";
|
import { getUserFavorites, addFavoriteStatusToRecipes } from "$lib/server/favorites";
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ fetch, locals, params }) => {
|
export const load: PageServerLoad = async ({ fetch, locals, params }) => {
|
||||||
const res = await fetch(`/api/rezepte/items/category/${params.category}`);
|
const isEnglish = params.recipeLang === 'recipes';
|
||||||
|
const apiBase = isEnglish ? '/api/recipes' : '/api/rezepte';
|
||||||
|
|
||||||
|
const res = await fetch(`${apiBase}/items/category/${params.category}`);
|
||||||
const items = await res.json();
|
const items = await res.json();
|
||||||
|
|
||||||
// Get user favorites and session
|
// Get user favorites and session
|
||||||
const [userFavorites, session] = await Promise.all([
|
const [userFavorites, session] = await Promise.all([
|
||||||
getUserFavorites(fetch, locals),
|
getUserFavorites(fetch, locals),
|
||||||
locals.auth()
|
locals.auth()
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
category: params.category,
|
category: params.category,
|
||||||
recipes: addFavoriteStatusToRecipes(items, userFavorites),
|
recipes: addFavoriteStatusToRecipes(items, userFavorites),
|
||||||
@@ -2,10 +2,13 @@
|
|||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
import Recipes from '$lib/components/Recipes.svelte';
|
import Recipes from '$lib/components/Recipes.svelte';
|
||||||
import Search from '$lib/components/Search.svelte';
|
import Search from '$lib/components/Search.svelte';
|
||||||
export let data: PageData;
|
let { data }: { data: PageData } = $props();
|
||||||
export let current_month = new Date().getMonth() + 1;
|
let current_month = new Date().getMonth() + 1;
|
||||||
import Card from '$lib/components/Card.svelte'
|
import Card from '$lib/components/Card.svelte'
|
||||||
import { rand_array } from '$lib/js/randomize';
|
import { rand_array } from '$lib/js/randomize';
|
||||||
|
|
||||||
|
const isEnglish = $derived(data.lang === 'en');
|
||||||
|
const label = $derived(isEnglish ? 'Recipes in Category' : 'Rezepte in Kategorie');
|
||||||
</script>
|
</script>
|
||||||
<style>
|
<style>
|
||||||
h1 {
|
h1 {
|
||||||
@@ -13,12 +16,12 @@
|
|||||||
font-size: 3em;
|
font-size: 3em;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<h1>Rezepte in Kategorie <q>{data.category}</q>:</h1>
|
<h1>{label} <q>{data.category}</q>:</h1>
|
||||||
<Search category={data.category}></Search>
|
<Search category={data.category} lang={data.lang}></Search>
|
||||||
<section>
|
<section>
|
||||||
<Recipes>
|
<Recipes>
|
||||||
{#each rand_array(data.recipes) as recipe}
|
{#each rand_array(data.recipes) as recipe}
|
||||||
<Card {recipe} {current_month} isFavorite={recipe.isFavorite} showFavoriteIndicator={!!data.session?.user}></Card>
|
<Card {recipe} {current_month} isFavorite={recipe.isFavorite} showFavoriteIndicator={!!data.session?.user} routePrefix="/{data.recipeLang}"></Card>
|
||||||
{/each}
|
{/each}
|
||||||
</Recipes>
|
</Recipes>
|
||||||
</section>
|
</section>
|
||||||
24
src/routes/[recipeLang]/edit/[name]/+page.server.ts
Normal file
24
src/routes/[recipeLang]/edit/[name]/+page.server.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import type { PageServerLoad } from "./$types";
|
||||||
|
import { redirect } from "@sveltejs/kit";
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ fetch, params, locals}) => {
|
||||||
|
// Edit is German-only - redirect to German version
|
||||||
|
if (params.recipeLang === 'recipes') {
|
||||||
|
// We need to get the German short_name first
|
||||||
|
const res = await fetch(`/api/recipes/items/${params.name}`);
|
||||||
|
if (res.ok) {
|
||||||
|
const recipe = await res.json();
|
||||||
|
throw redirect(301, `/rezepte/edit/${recipe.germanShortName}`);
|
||||||
|
}
|
||||||
|
// If recipe not found, redirect to German recipes list
|
||||||
|
throw redirect(301, '/rezepte');
|
||||||
|
}
|
||||||
|
|
||||||
|
let current_month = new Date().getMonth() + 1
|
||||||
|
const apiRes = await fetch(`/api/rezepte/items/${params.name}`);
|
||||||
|
const recipe = await apiRes.json();
|
||||||
|
const session = await locals.auth();
|
||||||
|
return {recipe: recipe,
|
||||||
|
user: session?.user
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
import Check from '$lib/assets/icons/Check.svelte';
|
import Check from '$lib/assets/icons/Check.svelte';
|
||||||
import Cross from '$lib/assets/icons/Cross.svelte';
|
import Cross from '$lib/assets/icons/Cross.svelte';
|
||||||
import SeasonSelect from '$lib/components/SeasonSelect.svelte';
|
import SeasonSelect from '$lib/components/SeasonSelect.svelte';
|
||||||
|
import TranslationApproval from '$lib/components/TranslationApproval.svelte';
|
||||||
import '$lib/css/action_button.css'
|
import '$lib/css/action_button.css'
|
||||||
import '$lib/css/nordtheme.css'
|
import '$lib/css/nordtheme.css'
|
||||||
import { redirect } from '@sveltejs/kit';
|
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 image_preview_url="https://bocken.org/static/rezepte/thumb/" + data.recipe.short_name + ".webp?v=" + data.recipe.dateModified;
|
||||||
let note = data.recipe.note
|
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 { season } from '$lib/js/season_store';
|
||||||
import { portions } from '$lib/js/portions_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(){
|
async function doDelete(){
|
||||||
const response = confirm("Bist du dir sicher, dass du das Rezept löschen willst?")
|
const response = confirm("Bist du dir sicher, dass du das Rezept löschen willst?")
|
||||||
if(!response){
|
if(!response){
|
||||||
@@ -200,30 +281,34 @@
|
|||||||
return
|
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', {
|
const res = await fetch('/api/rezepte/edit', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
recipe: {
|
recipe: recipeData,
|
||||||
...card_data,
|
|
||||||
...add_info,
|
|
||||||
images, // TODO
|
|
||||||
season: season_local,
|
|
||||||
short_name: short_name.trim(),
|
|
||||||
datecreated,
|
|
||||||
portions: portions_local,
|
|
||||||
datemodified,
|
|
||||||
instructions,
|
|
||||||
ingredients,
|
|
||||||
addendum,
|
|
||||||
preamble,
|
|
||||||
note,
|
|
||||||
},
|
|
||||||
old_short_name,
|
old_short_name,
|
||||||
|
old_recipe: originalRecipe, // For change detection in API
|
||||||
|
}),
|
||||||
headers: {
|
headers: {
|
||||||
'content-type': 'application/json',
|
'content-type': 'application/json',
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
}
|
}
|
||||||
})
|
|
||||||
})
|
})
|
||||||
if(res.ok){
|
if(res.ok){
|
||||||
const url = location.href.split('/');
|
const url = location.href.split('/');
|
||||||
@@ -381,7 +466,23 @@ button.action_button{
|
|||||||
<div class=addendum bind:innerText={addendum} contenteditable></div>
|
<div class=addendum bind:innerText={addendum} contenteditable></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if !showTranslationWorkflow}
|
||||||
<div class=submit_buttons>
|
<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={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>
|
</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,24 +1,26 @@
|
|||||||
import type { PageServerLoad } from "./$types";
|
import type { PageServerLoad } from "./$types";
|
||||||
import { redirect } from '@sveltejs/kit';
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ fetch, locals }) => {
|
export const load: PageServerLoad = async ({ fetch, locals, params }) => {
|
||||||
|
const isEnglish = params.recipeLang === 'recipes';
|
||||||
|
const apiBase = isEnglish ? '/api/recipes' : '/api/rezepte';
|
||||||
const session = await locals.auth();
|
const session = await locals.auth();
|
||||||
|
|
||||||
if (!session?.user?.nickname) {
|
if (!session?.user?.nickname) {
|
||||||
throw redirect(302, '/rezepte');
|
throw redirect(302, `/${params.recipeLang}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/rezepte/favorites/recipes');
|
const res = await fetch(`${apiBase}/favorites/recipes`);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
return {
|
return {
|
||||||
favorites: [],
|
favorites: [],
|
||||||
error: 'Failed to load favorites'
|
error: 'Failed to load favorites'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const favorites = await res.json();
|
const favorites = await res.json();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
favorites,
|
favorites,
|
||||||
session
|
session
|
||||||
79
src/routes/[recipeLang]/favorites/+page.svelte
Normal file
79
src/routes/[recipeLang]/favorites/+page.svelte
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { PageData } from './$types';
|
||||||
|
import '$lib/css/nordtheme.css';
|
||||||
|
import Recipes from '$lib/components/Recipes.svelte';
|
||||||
|
import Card from '$lib/components/Card.svelte';
|
||||||
|
import Search from '$lib/components/Search.svelte';
|
||||||
|
let { data }: { data: PageData } = $props();
|
||||||
|
let current_month = new Date().getMonth() + 1;
|
||||||
|
|
||||||
|
const isEnglish = $derived(data.lang === 'en');
|
||||||
|
const labels = $derived({
|
||||||
|
title: isEnglish ? 'Favorites' : 'Favoriten',
|
||||||
|
pageTitle: isEnglish ? 'My Favorites - Bocken Recipes' : 'Meine Favoriten - Bocken Rezepte',
|
||||||
|
metaDescription: isEnglish
|
||||||
|
? 'My favorite recipes from Bocken\'s kitchen.'
|
||||||
|
: 'Meine favorisierten Rezepte aus der Bockenschen Küche.',
|
||||||
|
count: isEnglish
|
||||||
|
? `${data.favorites.length} favorite recipe${data.favorites.length !== 1 ? 's' : ''}`
|
||||||
|
: `${data.favorites.length} favorisierte Rezepte`,
|
||||||
|
noFavorites: isEnglish ? 'No favorites saved yet' : 'Noch keine Favoriten gespeichert',
|
||||||
|
errorLoading: isEnglish ? 'Error loading favorites:' : 'Fehler beim Laden der Favoriten:',
|
||||||
|
emptyState1: isEnglish
|
||||||
|
? 'You haven\'t saved any recipes as favorites yet.'
|
||||||
|
: 'Du hast noch keine Rezepte als Favoriten gespeichert.',
|
||||||
|
emptyState2: isEnglish
|
||||||
|
? 'Visit a recipe and click the heart icon to add it to your favorites.'
|
||||||
|
: 'Besuche ein Rezept und klicke auf das Herz-Symbol, um es zu deinen Favoriten hinzuzufügen.',
|
||||||
|
recipesLink: isEnglish ? 'recipe' : 'Rezept'
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
h1{
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 0;
|
||||||
|
font-size: 4rem;
|
||||||
|
}
|
||||||
|
.subheading{
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
.empty-state{
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 3rem;
|
||||||
|
color: var(--nord3);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{labels.pageTitle}</title>
|
||||||
|
<meta name="description" content={labels.metaDescription} />
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<h1>{labels.title}</h1>
|
||||||
|
<p class=subheading>
|
||||||
|
{#if data.favorites.length > 0}
|
||||||
|
{labels.count}
|
||||||
|
{:else}
|
||||||
|
{labels.noFavorites}
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Search favoritesOnly={true} lang={data.lang}></Search>
|
||||||
|
|
||||||
|
{#if data.error}
|
||||||
|
<p class="empty-state">{labels.errorLoading} {data.error}</p>
|
||||||
|
{:else if data.favorites.length > 0}
|
||||||
|
<Recipes>
|
||||||
|
{#each data.favorites as recipe}
|
||||||
|
<Card {recipe} {current_month} isFavorite={true} showFavoriteIndicator={true} routePrefix="/{data.recipeLang}"></Card>
|
||||||
|
{/each}
|
||||||
|
</Recipes>
|
||||||
|
{:else}
|
||||||
|
<div class="empty-state">
|
||||||
|
<p>{labels.emptyState1}</p>
|
||||||
|
<p><a href="/{data.recipeLang}">{labels.emptyState2}</a></p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
import SeasonLayout from '$lib/components/SeasonLayout.svelte'
|
import SeasonLayout from '$lib/components/SeasonLayout.svelte'
|
||||||
import Card from '$lib/components/Card.svelte';
|
import Card from '$lib/components/Card.svelte';
|
||||||
import Search from '$lib/components/Search.svelte';
|
import Search from '$lib/components/Search.svelte';
|
||||||
export let data: PageData;
|
let { data }: { data: PageData } = $props();
|
||||||
</script>
|
</script>
|
||||||
<style>
|
<style>
|
||||||
a{
|
a{
|
||||||
@@ -75,6 +75,6 @@
|
|||||||
</style>
|
</style>
|
||||||
<div class=flex>
|
<div class=flex>
|
||||||
{#each data.icons as icon}
|
{#each data.icons as icon}
|
||||||
<a href="/rezepte/icon/{icon}">{icon}</a>
|
<a href="/{data.recipeLang}/icon/{icon}">{icon}</a>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@@ -2,17 +2,20 @@ import type { PageServerLoad } from "./$types";
|
|||||||
import { getUserFavorites, addFavoriteStatusToRecipes } from "$lib/server/favorites";
|
import { getUserFavorites, addFavoriteStatusToRecipes } from "$lib/server/favorites";
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ fetch, locals, params }) => {
|
export const load: PageServerLoad = async ({ fetch, locals, params }) => {
|
||||||
const res_season = await fetch(`/api/rezepte/items/icon/` + params.icon);
|
const isEnglish = params.recipeLang === 'recipes';
|
||||||
const res_icons = await fetch(`/api/rezepte/items/icon`);
|
const apiBase = isEnglish ? '/api/recipes' : '/api/rezepte';
|
||||||
|
|
||||||
|
const res_season = await fetch(`${apiBase}/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 icons = await res_icons.json();
|
||||||
const item_season = await res_season.json();
|
const item_season = await res_season.json();
|
||||||
|
|
||||||
// Get user favorites and session
|
// Get user favorites and session
|
||||||
const [userFavorites, session] = await Promise.all([
|
const [userFavorites, session] = await Promise.all([
|
||||||
getUserFavorites(fetch, locals),
|
getUserFavorites(fetch, locals),
|
||||||
locals.auth()
|
locals.auth()
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
icons: icons,
|
icons: icons,
|
||||||
icon: params.icon,
|
icon: params.icon,
|
||||||
@@ -5,13 +5,13 @@
|
|||||||
import MediaScroller from '$lib/components/MediaScroller.svelte';
|
import MediaScroller from '$lib/components/MediaScroller.svelte';
|
||||||
import Card from '$lib/components/Card.svelte';
|
import Card from '$lib/components/Card.svelte';
|
||||||
import Search from '$lib/components/Search.svelte';
|
import Search from '$lib/components/Search.svelte';
|
||||||
export let data: PageData;
|
let { data }: { data: PageData } = $props();
|
||||||
import { rand_array } from '$lib/js/randomize';
|
import { rand_array } from '$lib/js/randomize';
|
||||||
</script>
|
</script>
|
||||||
<IconLayout icons={data.icons} active_icon={data.icon} >
|
<IconLayout icons={data.icons} active_icon={data.icon} routePrefix="/{data.recipeLang}" lang={data.lang}>
|
||||||
<Recipes slot=recipes>
|
<Recipes slot=recipes>
|
||||||
{#each rand_array(data.season) as recipe}
|
{#each rand_array(data.season) as recipe}
|
||||||
<Card {recipe} icon_override=true isFavorite={recipe.isFavorite} showFavoriteIndicator={!!data.session?.user}></Card>
|
<Card {recipe} icon_override=true isFavorite={recipe.isFavorite} showFavoriteIndicator={!!data.session?.user} routePrefix="/{data.recipeLang}"></Card>
|
||||||
{/each}
|
{/each}
|
||||||
</Recipes>
|
</Recipes>
|
||||||
</IconLayout>
|
</IconLayout>
|
||||||
@@ -1,15 +1,18 @@
|
|||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ url, fetch }) => {
|
export const load: PageServerLoad = async ({ url, fetch, params }) => {
|
||||||
|
const isEnglish = params.recipeLang === 'recipes';
|
||||||
|
const apiBase = isEnglish ? '/api/recipes' : '/api/rezepte';
|
||||||
|
|
||||||
const query = url.searchParams.get('q') || '';
|
const query = url.searchParams.get('q') || '';
|
||||||
const category = url.searchParams.get('category');
|
const category = url.searchParams.get('category');
|
||||||
const tag = url.searchParams.get('tag');
|
const tag = url.searchParams.get('tag');
|
||||||
const icon = url.searchParams.get('icon');
|
const icon = url.searchParams.get('icon');
|
||||||
const season = url.searchParams.get('season');
|
const season = url.searchParams.get('season');
|
||||||
const favoritesOnly = url.searchParams.get('favorites') === 'true';
|
const favoritesOnly = url.searchParams.get('favorites') === 'true';
|
||||||
|
|
||||||
// Build API URL with filters
|
// Build API URL with filters
|
||||||
const apiUrl = new URL('/api/rezepte/search', url.origin);
|
const apiUrl = new URL(`${apiBase}/search`, url.origin);
|
||||||
if (query) apiUrl.searchParams.set('q', query);
|
if (query) apiUrl.searchParams.set('q', query);
|
||||||
if (category) apiUrl.searchParams.set('category', category);
|
if (category) apiUrl.searchParams.set('category', category);
|
||||||
if (tag) apiUrl.searchParams.set('tag', tag);
|
if (tag) apiUrl.searchParams.set('tag', tag);
|
||||||
97
src/routes/[recipeLang]/search/+page.svelte
Normal file
97
src/routes/[recipeLang]/search/+page.svelte
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { PageData } from './$types';
|
||||||
|
import Recipes from '$lib/components/Recipes.svelte';
|
||||||
|
import Search from '$lib/components/Search.svelte';
|
||||||
|
import Card from '$lib/components/Card.svelte';
|
||||||
|
let { data }: { data: PageData } = $props();
|
||||||
|
let current_month = new Date().getMonth() + 1;
|
||||||
|
|
||||||
|
const isEnglish = $derived(data.lang === 'en');
|
||||||
|
const labels = $derived({
|
||||||
|
title: isEnglish ? 'Search Results' : 'Suchergebnisse',
|
||||||
|
pageTitle: isEnglish
|
||||||
|
? `Search Results${data.query ? ` for "${data.query}"` : ''} - Bocken Recipes`
|
||||||
|
: `Suchergebnisse${data.query ? ` für "${data.query}"` : ''} - Bocken Rezepte`,
|
||||||
|
metaDescription: isEnglish
|
||||||
|
? 'Search results in Bocken\'s recipes.'
|
||||||
|
: 'Suchergebnisse in den Bockenschen Rezepten.',
|
||||||
|
filteredBy: isEnglish ? 'Filtered by:' : 'Gefiltert nach:',
|
||||||
|
category: isEnglish ? 'Category' : 'Kategorie',
|
||||||
|
keyword: isEnglish ? 'Keyword' : 'Stichwort',
|
||||||
|
icon: 'Icon',
|
||||||
|
season: isEnglish ? 'Season' : 'Saison',
|
||||||
|
favoritesOnly: isEnglish ? 'Favorites only' : 'Nur Favoriten',
|
||||||
|
searchError: isEnglish ? 'Search error:' : 'Fehler bei der Suche:',
|
||||||
|
resultsFor: isEnglish ? 'results for' : 'Ergebnisse für',
|
||||||
|
noResults: isEnglish ? 'No recipes found.' : 'Keine Rezepte gefunden.',
|
||||||
|
tryOther: isEnglish ? 'Try different search terms.' : 'Versuche es mit anderen Suchbegriffen.'
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
h1 {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 3em;
|
||||||
|
}
|
||||||
|
.search-info {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
color: var(--nord3);
|
||||||
|
}
|
||||||
|
.filter-info {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: var(--nord2);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{labels.pageTitle}</title>
|
||||||
|
<meta name="description" content={labels.metaDescription} />
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<h1>{labels.title}</h1>
|
||||||
|
|
||||||
|
{#if data.filters.category || data.filters.tag || data.filters.icon || data.filters.season || data.filters.favoritesOnly}
|
||||||
|
<div class="filter-info">
|
||||||
|
{labels.filteredBy}
|
||||||
|
{#if data.filters.category}{labels.category} "{data.filters.category}"{/if}
|
||||||
|
{#if data.filters.tag}{labels.keyword} "{data.filters.tag}"{/if}
|
||||||
|
{#if data.filters.icon}{labels.icon} "{data.filters.icon}"{/if}
|
||||||
|
{#if data.filters.season}{labels.season} "{data.filters.season}"{/if}
|
||||||
|
{#if data.filters.favoritesOnly}{labels.favoritesOnly}{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<Search
|
||||||
|
category={data.filters.category}
|
||||||
|
tag={data.filters.tag}
|
||||||
|
icon={data.filters.icon}
|
||||||
|
season={data.filters.season}
|
||||||
|
favoritesOnly={data.filters.favoritesOnly}
|
||||||
|
lang={data.lang}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if data.error}
|
||||||
|
<div class="search-info">
|
||||||
|
<p>{labels.searchError} {data.error}</p>
|
||||||
|
</div>
|
||||||
|
{:else if data.query}
|
||||||
|
<div class="search-info">
|
||||||
|
<p>{data.results.length} {labels.resultsFor} "{data.query}"</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if data.results.length > 0}
|
||||||
|
<Recipes>
|
||||||
|
{#each data.results as recipe}
|
||||||
|
<Card {recipe} {current_month} isFavorite={recipe.isFavorite} showFavoriteIndicator={true} routePrefix="/{data.recipeLang}"></Card>
|
||||||
|
{/each}
|
||||||
|
</Recipes>
|
||||||
|
{:else if data.query && !data.error}
|
||||||
|
<div class="search-info">
|
||||||
|
<p>{labels.noResults}</p>
|
||||||
|
<p>{labels.tryOther}</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -1,17 +1,20 @@
|
|||||||
import type { PageServerLoad } from "./$types";
|
import type { PageServerLoad } from "./$types";
|
||||||
import { getUserFavorites, addFavoriteStatusToRecipes } from "$lib/server/favorites";
|
import { getUserFavorites, addFavoriteStatusToRecipes } from "$lib/server/favorites";
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ fetch, locals }) => {
|
export const load: PageServerLoad = async ({ fetch, locals, params }) => {
|
||||||
|
const isEnglish = params.recipeLang === 'recipes';
|
||||||
|
const apiBase = isEnglish ? '/api/recipes' : '/api/rezepte';
|
||||||
|
|
||||||
let current_month = new Date().getMonth() + 1
|
let current_month = new Date().getMonth() + 1
|
||||||
const res_season = await fetch(`/api/rezepte/items/in_season/` + current_month);
|
const res_season = await fetch(`${apiBase}/items/in_season/` + current_month);
|
||||||
const item_season = await res_season.json();
|
const item_season = await res_season.json();
|
||||||
|
|
||||||
// Get user favorites and session
|
// Get user favorites and session
|
||||||
const [userFavorites, session] = await Promise.all([
|
const [userFavorites, session] = await Promise.all([
|
||||||
getUserFavorites(fetch, locals),
|
getUserFavorites(fetch, locals),
|
||||||
locals.auth()
|
locals.auth()
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
season: addFavoriteStatusToRecipes(item_season, userFavorites),
|
season: addFavoriteStatusToRecipes(item_season, userFavorites),
|
||||||
session
|
session
|
||||||
25
src/routes/[recipeLang]/season/+page.svelte
Normal file
25
src/routes/[recipeLang]/season/+page.svelte
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { PageData } from './$types';
|
||||||
|
import '$lib/css/nordtheme.css';
|
||||||
|
import Recipes from '$lib/components/Recipes.svelte';
|
||||||
|
import MediaScroller from '$lib/components/MediaScroller.svelte';
|
||||||
|
import SeasonLayout from '$lib/components/SeasonLayout.svelte'
|
||||||
|
import Card from '$lib/components/Card.svelte';
|
||||||
|
import Search from '$lib/components/Search.svelte';
|
||||||
|
let { data }: { data: PageData } = $props();
|
||||||
|
let current_month = new Date().getMonth() + 1
|
||||||
|
import { rand_array } from '$lib/js/randomize';
|
||||||
|
|
||||||
|
const isEnglish = $derived(data.lang === 'en');
|
||||||
|
const months = $derived(isEnglish
|
||||||
|
? ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]
|
||||||
|
: ["Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember"]);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<SeasonLayout active_index={current_month-1} {months} routePrefix="/{data.recipeLang}" lang={data.lang}>
|
||||||
|
<Recipes slot=recipes>
|
||||||
|
{#each rand_array(data.season) as recipe}
|
||||||
|
<Card {recipe} {current_month} isFavorite={recipe.isFavorite} showFavoriteIndicator={!!data.session?.user} routePrefix="/{data.recipeLang}"></Card>
|
||||||
|
{/each}
|
||||||
|
</Recipes>
|
||||||
|
</SeasonLayout>
|
||||||
@@ -2,15 +2,18 @@ import type { PageServerLoad } from "./$types";
|
|||||||
import { getUserFavorites, addFavoriteStatusToRecipes } from "$lib/server/favorites";
|
import { getUserFavorites, addFavoriteStatusToRecipes } from "$lib/server/favorites";
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ fetch, locals, params }) => {
|
export const load: PageServerLoad = async ({ fetch, locals, params }) => {
|
||||||
const res_season = await fetch(`/api/rezepte/items/in_season/` + params.month);
|
const isEnglish = params.recipeLang === 'recipes';
|
||||||
|
const apiBase = isEnglish ? '/api/recipes' : '/api/rezepte';
|
||||||
|
|
||||||
|
const res_season = await fetch(`${apiBase}/items/in_season/` + params.month);
|
||||||
const item_season = await res_season.json();
|
const item_season = await res_season.json();
|
||||||
|
|
||||||
// Get user favorites and session
|
// Get user favorites and session
|
||||||
const [userFavorites, session] = await Promise.all([
|
const [userFavorites, session] = await Promise.all([
|
||||||
getUserFavorites(fetch, locals),
|
getUserFavorites(fetch, locals),
|
||||||
locals.auth()
|
locals.auth()
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
month: params.month,
|
month: params.month,
|
||||||
season: addFavoriteStatusToRecipes(item_season, userFavorites),
|
season: addFavoriteStatusToRecipes(item_season, userFavorites),
|
||||||
23
src/routes/[recipeLang]/season/[month]/+page.svelte
Normal file
23
src/routes/[recipeLang]/season/[month]/+page.svelte
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<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';
|
||||||
|
let { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
|
const isEnglish = $derived(data.lang === 'en');
|
||||||
|
const months = $derived(isEnglish
|
||||||
|
? ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]
|
||||||
|
: ["Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember"]);
|
||||||
|
|
||||||
|
import { rand_array } from '$lib/js/randomize';
|
||||||
|
</script>
|
||||||
|
<SeasonLayout active_index={data.month -1} {months} routePrefix="/{data.recipeLang}" lang={data.lang}>
|
||||||
|
<Recipes slot=recipes>
|
||||||
|
{#each rand_array(data.season) as recipe}
|
||||||
|
<Card {recipe} icon_override=true isFavorite={recipe.isFavorite} showFavoriteIndicator={!!data.session?.user} routePrefix="/{data.recipeLang}"></Card>
|
||||||
|
{/each}
|
||||||
|
</Recipes>
|
||||||
|
</SeasonLayout>
|
||||||
@@ -1,9 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
export let data: PageData;
|
let { data }: { data: PageData } = $props();
|
||||||
import "$lib/css/nordtheme.css";
|
import "$lib/css/nordtheme.css";
|
||||||
import TagCloud from '$lib/components/TagCloud.svelte';
|
import TagCloud from '$lib/components/TagCloud.svelte';
|
||||||
import TagBall from '$lib/components/TagBall.svelte';
|
import TagBall from '$lib/components/TagBall.svelte';
|
||||||
|
|
||||||
|
const isEnglish = $derived(data.lang === 'en');
|
||||||
|
const labels = $derived({
|
||||||
|
title: isEnglish ? 'Keywords' : 'Stichwörter'
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
<style>
|
<style>
|
||||||
h1 {
|
h1 {
|
||||||
@@ -11,11 +16,11 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<h1>Stichwörter</h1>
|
<h1>{labels.title}</h1>
|
||||||
<section>
|
<section>
|
||||||
<TagCloud>
|
<TagCloud>
|
||||||
{#each data.tags as tag}
|
{#each data.tags as tag}
|
||||||
<TagBall {tag} ref="/rezepte/tag">
|
<TagBall {tag} ref="/{data.recipeLang}/tag">
|
||||||
</TagBall>
|
</TagBall>
|
||||||
{/each}
|
{/each}
|
||||||
</TagCloud>
|
</TagCloud>
|
||||||
10
src/routes/[recipeLang]/tag/+page.ts
Normal file
10
src/routes/[recipeLang]/tag/+page.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import type { PageLoad } from "./$types";
|
||||||
|
|
||||||
|
export async function load({ fetch, params}) {
|
||||||
|
const isEnglish = params.recipeLang === 'recipes';
|
||||||
|
const apiBase = isEnglish ? '/api/recipes' : '/api/rezepte';
|
||||||
|
|
||||||
|
const res = await fetch(`${apiBase}/items/tag`);
|
||||||
|
const tags = await res.json();
|
||||||
|
return {tags}
|
||||||
|
};
|
||||||
@@ -1,16 +1,19 @@
|
|||||||
import type { PageServerLoad } from "./$types";
|
import type { PageServerLoad} from "./$types";
|
||||||
import { getUserFavorites, addFavoriteStatusToRecipes } from "$lib/server/favorites";
|
import { getUserFavorites, addFavoriteStatusToRecipes } from "$lib/server/favorites";
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ fetch, locals, params }) => {
|
export const load: PageServerLoad = async ({ fetch, locals, params }) => {
|
||||||
const res_tag = await fetch(`/api/rezepte/items/tag/${params.tag}`);
|
const isEnglish = params.recipeLang === 'recipes';
|
||||||
|
const apiBase = isEnglish ? '/api/recipes' : '/api/rezepte';
|
||||||
|
|
||||||
|
const res_tag = await fetch(`${apiBase}/items/tag/${params.tag}`);
|
||||||
const items_tag = await res_tag.json();
|
const items_tag = await res_tag.json();
|
||||||
|
|
||||||
// Get user favorites and session
|
// Get user favorites and session
|
||||||
const [userFavorites, session] = await Promise.all([
|
const [userFavorites, session] = await Promise.all([
|
||||||
getUserFavorites(fetch, locals),
|
getUserFavorites(fetch, locals),
|
||||||
locals.auth()
|
locals.auth()
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
tag: params.tag,
|
tag: params.tag,
|
||||||
recipes: addFavoriteStatusToRecipes(items_tag, userFavorites),
|
recipes: addFavoriteStatusToRecipes(items_tag, userFavorites),
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
import Recipes from '$lib/components/Recipes.svelte';
|
import Recipes from '$lib/components/Recipes.svelte';
|
||||||
export let data: PageData;
|
let { data }: { data: PageData } = $props();
|
||||||
export let current_month = new Date().getMonth() + 1;
|
let current_month = new Date().getMonth() + 1;
|
||||||
import Card from '$lib/components/Card.svelte'
|
import Card from '$lib/components/Card.svelte'
|
||||||
import Search from '$lib/components/Search.svelte';
|
import Search from '$lib/components/Search.svelte';
|
||||||
import { rand_array } from '$lib/js/randomize';
|
import { rand_array } from '$lib/js/randomize';
|
||||||
|
|
||||||
|
const isEnglish = $derived(data.lang === 'en');
|
||||||
|
const label = $derived(isEnglish ? 'Recipes with Keyword' : 'Rezepte mit Stichwort');
|
||||||
</script>
|
</script>
|
||||||
<style>
|
<style>
|
||||||
h1 {
|
h1 {
|
||||||
@@ -13,12 +16,12 @@
|
|||||||
font-size: 2em;
|
font-size: 2em;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<h1>Rezepte mit Stichwort <q>{data.tag}</q>:</h1>
|
<h1>{label} <q>{data.tag}</q>:</h1>
|
||||||
<Search tag={data.tag}></Search>
|
<Search tag={data.tag} lang={data.lang}></Search>
|
||||||
<section>
|
<section>
|
||||||
<Recipes>
|
<Recipes>
|
||||||
{#each rand_array(data.recipes) as recipe}
|
{#each rand_array(data.recipes) as recipe}
|
||||||
<Card {recipe} {current_month} isFavorite={recipe.isFavorite} showFavoriteIndicator={!!data.session?.user}></Card>
|
<Card {recipe} {current_month} isFavorite={recipe.isFavorite} showFavoriteIndicator={!!data.session?.user} routePrefix="/{data.recipeLang}"></Card>
|
||||||
{/each}
|
{/each}
|
||||||
</Recipes>
|
</Recipes>
|
||||||
</section>
|
</section>
|
||||||
70
src/routes/api/recipes/favorites/recipes/+server.ts
Normal file
70
src/routes/api/recipes/favorites/recipes/+server.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { json, type RequestHandler } from '@sveltejs/kit';
|
||||||
|
import { UserFavorites } from '../../../../../models/UserFavorites';
|
||||||
|
import { Recipe } from '../../../../../models/Recipe';
|
||||||
|
import { dbConnect } from '../../../../../utils/db';
|
||||||
|
import type { RecipeModelType } from '../../../../../types/types';
|
||||||
|
import { error } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ locals }) => {
|
||||||
|
const session = await locals.auth();
|
||||||
|
|
||||||
|
if (!session?.user?.nickname) {
|
||||||
|
throw error(401, 'Authentication required');
|
||||||
|
}
|
||||||
|
|
||||||
|
await dbConnect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const userFavorites = await UserFavorites.findOne({
|
||||||
|
username: session.user.nickname
|
||||||
|
}).lean();
|
||||||
|
|
||||||
|
if (!userFavorites?.favorites?.length) {
|
||||||
|
return json([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get recipes that are favorited AND have English translations
|
||||||
|
let recipes = await Recipe.find({
|
||||||
|
_id: { $in: userFavorites.favorites },
|
||||||
|
'translations.en': { $exists: true }
|
||||||
|
}).lean();
|
||||||
|
|
||||||
|
// Transform to English format
|
||||||
|
const englishRecipes = recipes.map(recipe => ({
|
||||||
|
_id: recipe._id,
|
||||||
|
short_name: recipe.translations.en.short_name,
|
||||||
|
name: recipe.translations.en.name,
|
||||||
|
category: recipe.translations.en.category,
|
||||||
|
icon: recipe.icon,
|
||||||
|
dateCreated: recipe.dateCreated,
|
||||||
|
dateModified: recipe.dateModified,
|
||||||
|
images: recipe.images?.map((img, idx) => ({
|
||||||
|
mediapath: img.mediapath,
|
||||||
|
alt: recipe.translations.en.images?.[idx]?.alt || img.alt,
|
||||||
|
caption: recipe.translations.en.images?.[idx]?.caption || img.caption,
|
||||||
|
})),
|
||||||
|
description: recipe.translations.en.description,
|
||||||
|
note: recipe.translations.en.note,
|
||||||
|
tags: recipe.translations.en.tags || [],
|
||||||
|
season: recipe.season,
|
||||||
|
baking: recipe.baking,
|
||||||
|
preparation: recipe.preparation,
|
||||||
|
fermentation: recipe.fermentation,
|
||||||
|
portions: recipe.portions,
|
||||||
|
cooking: recipe.cooking,
|
||||||
|
total_time: recipe.total_time,
|
||||||
|
ingredients: recipe.translations.en.ingredients || [],
|
||||||
|
instructions: recipe.translations.en.instructions || [],
|
||||||
|
preamble: recipe.translations.en.preamble,
|
||||||
|
addendum: recipe.translations.en.addendum,
|
||||||
|
germanShortName: recipe.short_name,
|
||||||
|
translationStatus: recipe.translations.en.translationStatus
|
||||||
|
}));
|
||||||
|
|
||||||
|
const result = JSON.parse(JSON.stringify(englishRecipes));
|
||||||
|
|
||||||
|
return json(result);
|
||||||
|
} catch (e) {
|
||||||
|
throw error(500, 'Failed to fetch favorite recipes');
|
||||||
|
}
|
||||||
|
};
|
||||||
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))));
|
||||||
|
};
|
||||||
14
src/routes/api/recipes/items/category/+server.ts
Normal file
14
src/routes/api/recipes/items/category/+server.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { json, type RequestHandler } from '@sveltejs/kit';
|
||||||
|
import { Recipe } from '../../../../../models/Recipe';
|
||||||
|
import { dbConnect } from '../../../../../utils/db';
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({params}) => {
|
||||||
|
await dbConnect();
|
||||||
|
|
||||||
|
// Get distinct categories from English translations
|
||||||
|
const categories = await Recipe.distinct('translations.en.category', {
|
||||||
|
'translations.en': { $exists: true }
|
||||||
|
}).lean();
|
||||||
|
|
||||||
|
return json(JSON.parse(JSON.stringify(categories)));
|
||||||
|
};
|
||||||
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))));
|
||||||
|
};
|
||||||
24
src/routes/api/recipes/items/tag/+server.ts
Normal file
24
src/routes/api/recipes/items/tag/+server.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { json, type RequestHandler } from '@sveltejs/kit';
|
||||||
|
import { Recipe } from '../../../../../models/Recipe';
|
||||||
|
import { dbConnect } from '../../../../../utils/db';
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({params}) => {
|
||||||
|
await dbConnect();
|
||||||
|
|
||||||
|
// Get all recipes with English translations
|
||||||
|
const recipes = await Recipe.find({
|
||||||
|
'translations.en': { $exists: true }
|
||||||
|
}, 'translations.en.tags').lean();
|
||||||
|
|
||||||
|
// Extract and flatten all unique tags
|
||||||
|
const tagsSet = new Set<string>();
|
||||||
|
recipes.forEach(recipe => {
|
||||||
|
if (recipe.translations?.en?.tags) {
|
||||||
|
recipe.translations.en.tags.forEach((tag: string) => tagsSet.add(tag));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const tags = Array.from(tagsSet).sort();
|
||||||
|
|
||||||
|
return json(JSON.parse(JSON.stringify(tags)));
|
||||||
|
};
|
||||||
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/recipes/search/+server.ts
Normal file
88
src/routes/api/recipes/search/+server.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { json, type RequestHandler } from '@sveltejs/kit';
|
||||||
|
import type { BriefRecipeType } from '../../../../types/types';
|
||||||
|
import { Recipe } from '../../../../models/Recipe';
|
||||||
|
import { dbConnect } from '../../../../utils/db';
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ url, locals }) => {
|
||||||
|
await dbConnect();
|
||||||
|
|
||||||
|
const query = url.searchParams.get('q')?.toLowerCase().trim() || '';
|
||||||
|
const category = url.searchParams.get('category');
|
||||||
|
const tag = url.searchParams.get('tag');
|
||||||
|
const icon = url.searchParams.get('icon');
|
||||||
|
const season = url.searchParams.get('season');
|
||||||
|
const favoritesOnly = url.searchParams.get('favorites') === 'true';
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Build base query - only recipes with English translations
|
||||||
|
let dbQuery: any = {
|
||||||
|
'translations.en': { $exists: true }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Apply filters based on context
|
||||||
|
if (category) {
|
||||||
|
dbQuery['translations.en.category'] = category;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tag) {
|
||||||
|
dbQuery['translations.en.tags'] = { $in: [tag] };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (icon) {
|
||||||
|
dbQuery.icon = icon; // Icon is the same for both languages
|
||||||
|
}
|
||||||
|
|
||||||
|
if (season) {
|
||||||
|
const seasonNum = parseInt(season);
|
||||||
|
if (!isNaN(seasonNum)) {
|
||||||
|
dbQuery.season = { $in: [seasonNum] }; // Season is the same for both languages
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all recipes matching base filters
|
||||||
|
let recipes = await Recipe.find(dbQuery).lean();
|
||||||
|
|
||||||
|
// Handle favorites filter
|
||||||
|
if (favoritesOnly && locals.session?.user) {
|
||||||
|
const { UserFavorites } = await import('../../../../models/UserFavorites');
|
||||||
|
const userFavorites = await UserFavorites.findOne({ username: locals.session.user.username });
|
||||||
|
if (userFavorites && userFavorites.favorites) {
|
||||||
|
const favoriteIds = userFavorites.favorites;
|
||||||
|
recipes = recipes.filter(recipe => favoriteIds.some(id => id.toString() === recipe._id?.toString()));
|
||||||
|
} else {
|
||||||
|
recipes = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transform to English brief format
|
||||||
|
let briefRecipes: BriefRecipeType[] = recipes.map(recipe => ({
|
||||||
|
_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
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Apply text search if query provided
|
||||||
|
if (query) {
|
||||||
|
const searchTerms = query.normalize('NFD').replace(/\p{Diacritic}/gu, "").split(" ");
|
||||||
|
|
||||||
|
briefRecipes = briefRecipes.filter(recipe => {
|
||||||
|
const searchString = `${recipe.name} ${recipe.description || ''} ${recipe.tags?.join(' ') || ''}`.toLowerCase()
|
||||||
|
.normalize('NFD').replace(/\p{Diacritic}/gu, "").replace(/­|/g, '');
|
||||||
|
|
||||||
|
return searchTerms.every(term => searchString.includes(term));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return json(JSON.parse(JSON.stringify(briefRecipes)));
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
return json({ error: 'Search failed' }, { status: 500 });
|
||||||
|
}
|
||||||
|
};
|
||||||
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 });
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import type { LayoutServerLoad } from "./$types"
|
|
||||||
|
|
||||||
export const load : LayoutServerLoad = async ({locals}) => {
|
|
||||||
return {
|
|
||||||
session: await locals.auth()
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
<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="/rezepte">Alle Rezepte</a></li>
|
|
||||||
{#if user}
|
|
||||||
<li><a href="/rezepte/favorites">Favoriten</a></li>
|
|
||||||
{/if}
|
|
||||||
<li><a href="/rezepte/season">In Saison</a></li>
|
|
||||||
<li><a href="/rezepte/category">Kategorie</a></li>
|
|
||||||
<li><a href="/rezepte/icon">Icon</a></li>
|
|
||||||
<li><a href="/rezepte/tag">Stichwörter</a></li>
|
|
||||||
<li><a href="/rezepte/tips-and-tricks">Tipps</a></li>
|
|
||||||
</ul>
|
|
||||||
<UserHeader slot=right_side {user}></UserHeader>
|
|
||||||
<slot></slot>
|
|
||||||
</Header>
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
<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 = ["Hauptspeise", "Nudel", "Brot", "Dessert", "Suppe", "Beilage", "Salat", "Kuchen", "Frühstück", "Sauce", "Zutat", "Getränk", "Aufstrich", "Guetzli", "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 Rezepte</title>
|
|
||||||
<meta name="description" content="Eine stetig wachsende Ansammlung an Rezepten aus der Bockenschen Küche." />
|
|
||||||
<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 mit Linguine" />
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<h1>Rezepte</h1>
|
|
||||||
<p class=subheading>{data.all_brief.length} Rezepte und stetig wachsend...</p>
|
|
||||||
|
|
||||||
<Search></Search>
|
|
||||||
|
|
||||||
<MediaScroller title="In Saison">
|
|
||||||
{#each data.season as recipe}
|
|
||||||
<Card {recipe} {current_month} loading_strat={"eager"} do_margin_right={true} isFavorite={recipe.isFavorite} showFavoriteIndicator={!!data.session?.user}></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}></Card>
|
|
||||||
{/each}
|
|
||||||
</MediaScroller>
|
|
||||||
{/each}
|
|
||||||
<AddButton href="/rezepte/add"></AddButton>
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
export async function load({locals}) {
|
|
||||||
const session = await locals.auth();
|
|
||||||
return {
|
|
||||||
user: session?.user
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import type { PageLoad } from "./$types";
|
|
||||||
|
|
||||||
export async function load({ fetch}) {
|
|
||||||
const res = await fetch(`/api/rezepte/items/category`);
|
|
||||||
const categories= await res.json();
|
|
||||||
return {categories}
|
|
||||||
};
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import type { PageServerLoad } from "./$types";
|
|
||||||
|
|
||||||
export async function load({ fetch, params, locals}) {
|
|
||||||
let current_month = new Date().getMonth() + 1
|
|
||||||
const res = await fetch(`/api/rezepte/items/${params.name}`);
|
|
||||||
const recipe = await res.json();
|
|
||||||
const session = await locals.auth();
|
|
||||||
return {recipe: recipe,
|
|
||||||
user: session?.user
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type { PageData } from './$types';
|
|
||||||
import '$lib/css/nordtheme.css';
|
|
||||||
import Recipes from '$lib/components/Recipes.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;
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
h1{
|
|
||||||
text-align: center;
|
|
||||||
margin-bottom: 0;
|
|
||||||
font-size: 4rem;
|
|
||||||
}
|
|
||||||
.subheading{
|
|
||||||
text-align: center;
|
|
||||||
margin-top: 0;
|
|
||||||
font-size: 1.5rem;
|
|
||||||
}
|
|
||||||
.empty-state{
|
|
||||||
text-align: center;
|
|
||||||
margin-top: 3rem;
|
|
||||||
color: var(--nord3);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<svelte:head>
|
|
||||||
<title>Meine Favoriten - Bocken Rezepte</title>
|
|
||||||
<meta name="description" content="Meine favorisierten Rezepte aus der Bockenschen Küche." />
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<h1>Favoriten</h1>
|
|
||||||
<p class=subheading>
|
|
||||||
{#if data.favorites.length > 0}
|
|
||||||
{data.favorites.length} favorisierte Rezepte
|
|
||||||
{:else}
|
|
||||||
Noch keine Favoriten gespeichert
|
|
||||||
{/if}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<Search favoritesOnly={true}></Search>
|
|
||||||
|
|
||||||
{#if data.error}
|
|
||||||
<p class="empty-state">Fehler beim Laden der Favoriten: {data.error}</p>
|
|
||||||
{:else if data.favorites.length > 0}
|
|
||||||
<Recipes>
|
|
||||||
{#each data.favorites as recipe}
|
|
||||||
<Card {recipe} {current_month} isFavorite={true} showFavoriteIndicator={true}></Card>
|
|
||||||
{/each}
|
|
||||||
</Recipes>
|
|
||||||
{:else}
|
|
||||||
<div class="empty-state">
|
|
||||||
<p>Du hast noch keine Rezepte als Favoriten gespeichert.</p>
|
|
||||||
<p>Besuche ein <a href="/rezepte">Rezept</a> und klicke auf das Herz-Symbol, um es zu deinen Favoriten hinzuzufügen.</p>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type { PageData } from './$types';
|
|
||||||
import Recipes from '$lib/components/Recipes.svelte';
|
|
||||||
import Search from '$lib/components/Search.svelte';
|
|
||||||
import Card from '$lib/components/Card.svelte';
|
|
||||||
export let data: PageData;
|
|
||||||
export let current_month = new Date().getMonth() + 1;
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
h1 {
|
|
||||||
text-align: center;
|
|
||||||
font-size: 3em;
|
|
||||||
}
|
|
||||||
.search-info {
|
|
||||||
text-align: center;
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
color: var(--nord3);
|
|
||||||
}
|
|
||||||
.filter-info {
|
|
||||||
text-align: center;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
font-size: 0.9em;
|
|
||||||
color: var(--nord2);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<svelte:head>
|
|
||||||
<title>Suchergebnisse{data.query ? ` für "${data.query}"` : ''} - Bocken Rezepte</title>
|
|
||||||
<meta name="description" content="Suchergebnisse in den Bockenschen Rezepten." />
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<h1>Suchergebnisse</h1>
|
|
||||||
|
|
||||||
{#if data.filters.category || data.filters.tag || data.filters.icon || data.filters.season || data.filters.favoritesOnly}
|
|
||||||
<div class="filter-info">
|
|
||||||
Gefiltert nach:
|
|
||||||
{#if data.filters.category}Kategorie "{data.filters.category}"{/if}
|
|
||||||
{#if data.filters.tag}Stichwort "{data.filters.tag}"{/if}
|
|
||||||
{#if data.filters.icon}Icon "{data.filters.icon}"{/if}
|
|
||||||
{#if data.filters.season}Saison "{data.filters.season}"{/if}
|
|
||||||
{#if data.filters.favoritesOnly}Nur Favoriten{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<Search
|
|
||||||
category={data.filters.category}
|
|
||||||
tag={data.filters.tag}
|
|
||||||
icon={data.filters.icon}
|
|
||||||
season={data.filters.season}
|
|
||||||
favoritesOnly={data.filters.favoritesOnly}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{#if data.error}
|
|
||||||
<div class="search-info">
|
|
||||||
<p>Fehler bei der Suche: {data.error}</p>
|
|
||||||
</div>
|
|
||||||
{:else if data.query}
|
|
||||||
<div class="search-info">
|
|
||||||
<p>{data.results.length} Ergebnisse für "{data.query}"</p>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if data.results.length > 0}
|
|
||||||
<Recipes>
|
|
||||||
{#each data.results as recipe}
|
|
||||||
<Card {recipe} {current_month} isFavorite={recipe.isFavorite} showFavoriteIndicator={true}></Card>
|
|
||||||
{/each}
|
|
||||||
</Recipes>
|
|
||||||
{:else if data.query && !data.error}
|
|
||||||
<div class="search-info">
|
|
||||||
<p>Keine Rezepte gefunden.</p>
|
|
||||||
<p>Versuche es mit anderen Suchbegriffen.</p>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type { PageData } from './$types';
|
|
||||||
import '$lib/css/nordtheme.css';
|
|
||||||
import Recipes from '$lib/components/Recipes.svelte';
|
|
||||||
import MediaScroller from '$lib/components/MediaScroller.svelte';
|
|
||||||
import SeasonLayout from '$lib/components/SeasonLayout.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
|
|
||||||
import { rand_array } from '$lib/js/randomize';
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<SeasonLayout active_index={current_month-1}>
|
|
||||||
<Recipes slot=recipes>
|
|
||||||
{#each rand_array(data.season) as recipe}
|
|
||||||
<Card {recipe} {current_month} isFavorite={recipe.isFavorite} showFavoriteIndicator={!!data.session?.user}></Card>
|
|
||||||
{/each}
|
|
||||||
</Recipes>
|
|
||||||
</SeasonLayout>
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
<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 = ["Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember"]
|
|
||||||
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}></Card>
|
|
||||||
{/each}
|
|
||||||
</Recipes>
|
|
||||||
</SeasonLayout>
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import type { PageLoad } from "./$types";
|
|
||||||
|
|
||||||
export async function load({ fetch}) {
|
|
||||||
const res = await fetch(`/api/rezepte/items/tag`);
|
|
||||||
const tags = await res.json();
|
|
||||||
return {tags}
|
|
||||||
};
|
|
||||||
@@ -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 = {
|
export type RecipeModelType = {
|
||||||
_id: string;
|
_id: string;
|
||||||
short_name: string;
|
short_name: string;
|
||||||
@@ -41,6 +82,10 @@ export type RecipeModelType = {
|
|||||||
}]
|
}]
|
||||||
preamble?: String
|
preamble?: String
|
||||||
addendum?: string
|
addendum?: string
|
||||||
|
translations?: {
|
||||||
|
en?: TranslatedRecipeType;
|
||||||
|
};
|
||||||
|
translationMetadata?: TranslationMetadata;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type BriefRecipeType = {
|
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 };
|
||||||
416458
static/allioli.json
Normal file
416458
static/allioli.json
Normal file
File diff suppressed because one or more lines are too long
11
wine_glass.svg
Normal file
11
wine_glass.svg
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" standalone="no"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" >
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" viewBox="-10 0 1332 2048">
|
||||||
|
<path fill="currentColor"
|
||||||
|
d="M1240 488q0 -4 -9 47q-45 252 -164 367q-39 38 -192 103q-31 10 -74 38q-7 11 -9 68q-2 67 -7 202q-15 284 -15 291q0 30 9 45.5t40 28.5q28 6 84 19q209 48 209 159q0 95 -185 136q-116 26 -266 26q-149 0 -266 -26q-185 -41 -185 -136q0 -76 79 -116q41 -21 153 -46
|
||||||
|
q89 -20 100 -43q10 -20 10 -47q0 12 -12 -219t-12 -277q0 -6 1.5 -17t1.5 -17q0 -18 -12 -32t-122 -61t-142 -79q-131 -131 -162 -348q-11 -78 -11 -66q0 3 5 -40q24 -211 111 -341h926q41 46 83 185q11 37 27 156q6 45 6 40zM1153 456q0 -154 -63 -283h-858
|
||||||
|
q-63 132 -63 283q0 134 45 217q160 -21 231 -21h432q102 0 231 21q45 -77 45 -217zM1078 709q-28 -8 -85 -19q-64 -7 -107 -7h-450q-109 0 -192 26q36 8 110 20q109 13 153 13h308q93 0 263 -33zM943 889q-28 2 -82 10q-130 30 -203 30t-113 -7q-14 -2 -79 -20
|
||||||
|
q-49 -13 -81 -13h-7q54 21 107 41q63 28 91 66q12 17 15 68q8 144 16 349q7 179 7 197q0 38 -10 66q-2 6 -19 35.5t-17 31.5v41q33 16 103 16q47 0 83 -16v-42q0 3 -23 -46t-23 -86q0 -13 7 -197q4 -116 15 -349q3 -47 16 -68q22 -35 90 -65q53 -21 107 -42zM1034 1850
|
||||||
|
q0 -32 -102 -71q-91 -35 -133 -35q-10 0 -23 7q3 10 28 20.5t25 29.5q0 49 -168 49t-168 -49q0 -16 45 -37l8 -13q-13 -7 -23 -7q-42 0 -133 35q-102 39 -102 71q0 18 32 34q23 11 47 22l11 -11q-21 -11 -35 -19v-9l2 -2q54 22 139 30q72 6 143 13v14q-15 15 -45 15
|
||||||
|
q-53 0 -118 -16q-73 -18 -61 -18q-6 0 -6 7v4q105 35 264 35q116 0 192 -13q45 -8 103 -30q78 -30 78 -56zM639 1560q-12 -6 -12 -20l-10 -451q-1 -49 -9 -85h21q10 18 10 19v537zM826 1868q-43 22 -165 22q-121 0 -165 -22l7 -12q44 12 107 12h91q69 0 118 -12z" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.7 KiB |
Reference in New Issue
Block a user