add route matcher to fix /login and /logout routes
Some checks failed
CI / update (push) Failing after 2m52s

Use SvelteKit param matcher to constrain [recipeLang] to only match
'recipes' or 'rezepte', preventing it from catching /login, /logout,
and other non-recipe routes.
This commit is contained in:
2025-12-26 22:48:01 +01:00
parent 9cd990f1b9
commit 3e43b731c9
34 changed files with 5 additions and 0 deletions

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

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

View File

@@ -0,0 +1,25 @@
import type { PageServerLoad } from "./$types";
import { getUserFavorites, addFavoriteStatusToRecipes } from "$lib/server/favorites";
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
const res_season = await fetch(`${apiBase}/items/in_season/` + current_month);
const res_all_brief = await fetch(`${apiBase}/items/all_brief`);
const item_season = await res_season.json();
const item_all_brief = await res_all_brief.json();
// Get user favorites and session
const [userFavorites, session] = await Promise.all([
getUserFavorites(fetch, locals),
locals.auth()
]);
return {
season: addFavoriteStatusToRecipes(item_season, userFavorites),
all_brief: addFavoriteStatusToRecipes(item_all_brief, userFavorites),
session
};
};

View File

@@ -0,0 +1,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}

View File

@@ -0,0 +1,73 @@
import { redirect, error } from '@sveltejs/kit';
export const actions = {
toggleFavorite: async ({ request, locals, url, fetch }) => {
const session = await locals.auth();
if (!session?.user?.nickname) {
throw error(401, 'Authentication required');
}
const formData = await request.formData();
const recipeId = formData.get('recipeId') as string;
const isFavorite = formData.get('isFavorite') === 'true';
if (!recipeId) {
throw error(400, 'Recipe ID required');
}
try {
// Use the existing API endpoint
const method = isFavorite ? 'DELETE' : 'POST';
const response = await fetch('/api/rezepte/favorites', {
method,
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ recipeId }),
});
if (!response.ok) {
const errorData = await response.text();
console.error('API error:', response.status, errorData);
throw error(response.status, `Failed to toggle favorite: ${errorData}`);
}
// Redirect back to the same page to refresh the state
throw redirect(303, url.pathname);
} catch (e) {
// If it's a redirect, let it through
if (e && typeof e === 'object' && 'status' in e && e.status === 303) {
throw e;
}
console.error('Favorite toggle error:', e);
throw error(500, 'Failed to toggle favorite');
}
},
swapYeast: async ({ request, url }) => {
const formData = await request.formData();
const yeastId = parseInt(formData.get('yeastId') as string);
// Build new URL
const newUrl = new URL(url.pathname, url.origin);
// Restore all parameters from the form data (they were submitted as currentParam_*)
for (const [key, value] of formData.entries()) {
if (key.startsWith('currentParam_')) {
const paramName = key.substring('currentParam_'.length);
newUrl.searchParams.set(paramName, value as string);
}
}
// Toggle the yeast flag - if it exists, remove it; if not, add it
const yeastParam = `y${yeastId}`;
if (newUrl.searchParams.has(yeastParam)) {
newUrl.searchParams.delete(yeastParam);
} else {
newUrl.searchParams.set(yeastParam, '1');
}
throw redirect(303, newUrl.toString());
}
};

View File

@@ -0,0 +1,368 @@
<script lang="ts">
import { writable } from 'svelte/store';
export const multiplier = writable(0);
import type { PageData } from './$types';
import "$lib/css/nordtheme.css"
import EditButton from '$lib/components/EditButton.svelte';
import InstructionsPage from '$lib/components/InstructionsPage.svelte';
import IngredientsPage from '$lib/components/IngredientsPage.svelte';
import TitleImgParallax from '$lib/components/TitleImgParallax.svelte';
import { afterNavigate } from '$app/navigation';
import {season} from '$lib/js/season_store';
import RecipeNote from '$lib/components/RecipeNote.svelte';
import {stripHtmlTags} from '$lib/js/stripHtmlTags';
import FavoriteButton from '$lib/components/FavoriteButton.svelte';
import RecipeLanguageSwitcher from '$lib/components/RecipeLanguageSwitcher.svelte';
import { onMount, onDestroy } from 'svelte';
import { recipeTranslationStore } from '$lib/stores/recipeTranslation';
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"]);
function season_intervals() {
let interval_arr = []
let start_i = 0
for(var i = 12; i > 0; i--){
if(data.season.includes(i)){
start_i = data.season.indexOf(i);
}
else{
break
}
}
var start = data.season[start_i]
var end_i
const len = data.season.length
for(var i = 0; i < len -1; i++){
if(data.season.includes((start + i) %12 + 1)){
end_i = (start_i + i + 1) % len
}
else{
interval_arr.push([start, data.season[end_i]])
start = data.season[(start + i + 1) % len]
}
}
if(interval_arr.length == 0){
interval_arr.push([start, data.season[end_i]])
}
return interval_arr
}
const season_iv = $derived(season_intervals());
const display_date = $derived(data.updatedAt ? new Date(data.updatedAt) : new Date(data.dateCreated));
const options = {
day: '2-digit',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
};
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>
<style>
*{
font-family: sans-serif;
}
h1{
text-align: center;
padding-block: 0.5em;
border-radius: 10000px;
margin:0;
font-size: 3rem;
overflow-wrap: break-word;
hyphens: auto;
text-wrap: balance;
}
.category{
--size: 1.75rem;
position: absolute;
top: calc(-1* var(--size) );
left:calc(-3/2 * var(--size));
background-color: var(--nord0);
color: var(--nord6);
text-decoration: none;
font-size: var(--size);
padding: calc(var(--size) * 2/3);
border-radius: 1000px;
transition: 100ms;
box-shadow: 0em 0em 1em 0.3em rgba(0,0,0,0.4);
}
.category:hover,
.category:focus-visible{
background-color: var(--nord1);
scale: 1.1;
}
.tags{
margin-block: 1rem;
display: flex;
flex-direction: row;
align-items: center;
flex-wrap: wrap;
gap: 1em;
}
.center{
justify-content: center;
}
.tag{
all:unset;
color: var(--nord0);
font-size: 1.1rem;
background-color: var(--nord5);
border-radius: 10000px;
padding: 0.25em 1em;
transition: 100ms;
box-shadow: 0em 0em 0.5em 0.05em rgba(0,0,0,0.3);
}
@media (prefers-color-scheme: dark) {
.tag{
background-color: var(--nord0);
color: white;
}
}
.tag:hover,
.tag:focus-visible
{
cursor: pointer;
transform: scale(1.1,1.1);
background-color: var(--orange);
box-shadow: 0.1em 0.1em 0.5em 0.1em rgba(0,0,0,0.3);
}
.wrapper_wrapper{
background-color: #fbf9f3;
padding-top: 10rem;
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 3rem;
transform: translateY(-7rem);
z-index: -2;
}
@media (prefers-color-scheme: dark) {
.wrapper_wrapper{
background-color: var(--background-dark);
}
}
.wrapper{
display: flex;
flex-direction: row;
max-width: 1000px;
justify-content: center;
margin-inline: auto;
}
@media screen and (max-width: 700px){
.wrapper{
flex-direction:column;
}
}
.title{
position: relative;
width: min(800px, 80vw);
margin-inline: auto;
background-color: var(--nord6);
padding: 1rem 2rem;
translate: 0 1px; /*bruh*/
z-index: 1;
}
@media (prefers-color-scheme: dark) {
.title{
background-color: var(--nord6-dark);
}
}
.icon{
font-family: "Noto Color Emoji", emoji;
position: absolute;
top: -1em;
right: -0.75em;
text-decoration: unset;
background-color: #FAFAFE;
padding: 0.5em;
font-size: 1.5rem;
border-radius: 100000px;
transition: 100ms;
box-shadow: 0em 0em 1em 0.3em rgba(0,0,0,0.4);
}
@media (prefers-color-scheme: dark) {
.icon{
background-color: var(--accent-dark);
}
}
.icon:hover,
.icon:focus-visible{
scale: 1.2 1.2;
animation: shake 0.5s ease forwards;
}
h4{
margin-block: 0;
}
.addendum{
max-width: 800px;
margin-inline: auto;
padding-inline: 2rem;
}
@media screen and (max-width: 800px){
.title{
width: 100%;
}
.icon{
right: 1rem;
top: -1.75rem;
}
.category{
left: 1rem;
top: calc(var(--size) * -1.5);
}
}
@keyframes shake{
0%{
transform: rotate(0)
scale(1,1);
}
25%{
box-shadow: 0em 0em 1em 0.2em rgba(0, 0, 0, 0.6);
transform: rotate(var(--angle))
scale(1.2,1.2)
;
}
50%{
box-shadow: 0em 0em 1em 0.2em rgba(0, 0, 0, 0.6);
transform: rotate(calc(-1* var(--angle)))
scale(1.2,1.2);
}
74%{
box-shadow: 0em 0em 1em 0.2em rgba(0, 0, 0, 0.6);
transform: rotate(var(--angle))
scale(1.2, 1.2);
}
100%{
transform: rotate(0)
scale(1.2,1.2);
}
}
.description{
text-align: center;
margin-bottom: 2em;
margin-top: -0.5em;
}
.date{
margin-bottom: 0;
}
</style>
<svelte:head>
<title>{stripHtmlTags(data.name)} - {labels.title}</title>
<meta name="description" content="{stripHtmlTags(data.description)}" />
<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/{imageShortName}.webp" />
<meta property="og:image:type" content="image/webp" />
<meta property="og:image:alt" content="{stripHtmlTags(data.name)}" />
{@html `<script type="application/ld+json">${JSON.stringify(data.recipeJsonLd)}</script>`}
<!-- SEO: hreflang tags -->
<link rel="alternate" hreflang="de" href="https://bocken.org/rezepte/{data.germanShortName}" />
{#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>
<TitleImgParallax src={hero_img_src} {placeholder_src}>
<div class=title>
<a class="category" href='/{data.recipeLang}/category/{data.category}'>{data.category}</a>
<a class="icon" href='/{data.recipeLang}/icon/{data.icon}'>{data.icon}</a>
<h1>{@html data.name}</h1>
{#if data.description && ! data.preamble}
<p class=description>{data.description}</p>
{/if}
{#if data.preamble}
<p>{@html data.preamble}</p>
{/if}
<div class=tags>
<h4>{labels.season}</h4>
{#each season_iv as season}
<a class=tag href="/{data.recipeLang}/season/{season[0]}">
{#if season[0]}
{months[season[0] - 1]}
{/if}
{#if season[1]}
- {months[season[1] - 1]}
{/if}
</a>
{/each}
</div>
<h4>{labels.keywords}</h4>
<div class="tags center">
{#each data.tags as tag}
<a class=tag href="/{data.recipeLang}/tag/{tag}">{tag}</a>
{/each}
</div>
<FavoriteButton
recipeId={data.germanShortName}
isFavorite={data.isFavorite || false}
isLoggedIn={!!data.session?.user}
/>
{#if data.note}
<RecipeNote note={data.note}></RecipeNote>
{/if}
</div>
<div class=wrapper_wrapper>
<div class=wrapper>
<IngredientsPage {data}></IngredientsPage>
<InstructionsPage {data}></InstructionsPage>
</div>
<div class=addendum>
{#if data.addendum}
{@html data.addendum}
{/if}
</div>
<p class=date>{labels.lastModified} {formatted_display_date}</p>
</div>
</TitleImgParallax>
<EditButton href="/rezepte/edit/{data.germanShortName}"></EditButton>

View File

@@ -0,0 +1,130 @@
import { error } from "@sveltejs/kit";
import { generateRecipeJsonLd } from '$lib/js/recipeJsonLd';
export async function load({ fetch, params, url}) {
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();
if(!res.ok){
throw error(res.status, item.message)
}
// Check if this recipe is favorited by the user
let isFavorite = false;
try {
const favRes = await fetch(`/api/rezepte/favorites/check/${params.name}`);
if (favRes.ok) {
const favData = await favRes.json();
isFavorite = favData.isFavorite;
}
} catch (e) {
// Silently fail if not authenticated or other error
}
// Get multiplier from URL parameters
const multiplier = parseFloat(url.searchParams.get('multiplier') || '1');
// Handle yeast swapping from URL parameters based on toggle flags
// Look for parameters like y0=1, y1=1 (yeast #0 and #1 are toggled)
if (item.ingredients) {
let yeastCounter = 0;
// Iterate through all ingredients to find yeast and apply conversions
for (let listIndex = 0; listIndex < item.ingredients.length; listIndex++) {
const list = item.ingredients[listIndex];
if (list.list) {
for (let ingredientIndex = 0; ingredientIndex < list.list.length; ingredientIndex++) {
const ingredient = list.list[ingredientIndex];
// Check if this is a yeast ingredient (both German and English names, case-insensitive)
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
const yeastParam = `y${yeastCounter}`;
const isToggled = url.searchParams.has(yeastParam);
if (isToggled) {
// Perform yeast conversion from original recipe data
const originalName = ingredient.name;
const originalAmount = parseFloat(ingredient.amount);
const originalUnit = ingredient.unit;
let newName: string, newAmount: string, newUnit: string;
if (isFreshYeast) {
// Convert fresh yeast to dry yeast
newName = isEnglish ? "Dry yeast" : "Trockenhefe";
if (originalUnit === "Prise") {
// "1 Prise Frischhefe" → "1 Prise Trockenhefe"
newAmount = ingredient.amount;
newUnit = "Prise";
} else if (originalUnit === "g" && originalAmount === 1) {
// "1 g Frischhefe" → "1 Prise Trockenhefe"
newAmount = "1";
newUnit = "Prise";
} else {
// Normal conversion: "9 g Frischhefe" → "3 g Trockenhefe" (divide by 3)
newAmount = (originalAmount / 3).toString();
newUnit = "g";
}
} else if (isDryYeast) {
// Convert dry yeast to fresh yeast
newName = isEnglish ? "Fresh yeast" : "Frischhefe";
if (originalUnit === "Prise") {
// "1 Prise Trockenhefe" → "1 g Frischhefe"
newAmount = "1";
newUnit = "g";
} else {
// Normal conversion: "1 g Trockenhefe" → "3 g Frischhefe" (multiply by 3)
newAmount = (originalAmount * 3).toString();
newUnit = "g";
}
} else {
// Fallback
newName = originalName;
newAmount = ingredient.amount;
newUnit = originalUnit;
}
// Apply the conversion
item.ingredients[listIndex].list[ingredientIndex] = {
...item.ingredients[listIndex].list[ingredientIndex],
name: newName,
amount: newAmount,
unit: newUnit
};
}
yeastCounter++;
}
}
}
}
}
// Generate JSON-LD server-side
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 {
...item,
isFavorite,
multiplier,
recipeJsonLd,
hasEnglishTranslation,
englishShortName,
germanShortName,
};
}

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

View File

@@ -0,0 +1,362 @@
<script lang="ts">
import Check from '$lib/assets/icons/Check.svelte';
import SeasonSelect from '$lib/components/SeasonSelect.svelte';
import TranslationApproval from '$lib/components/TranslationApproval.svelte';
import '$lib/css/action_button.css'
import '$lib/css/nordtheme.css'
let preamble = ""
let addendum = ""
// Translation workflow state
let showTranslationWorkflow = false;
let translationData: any = null;
import { season } from '$lib/js/season_store';
import { portions } from '$lib/js/portions_store';
import { img } from '$lib/js/img_store';
season.update(() => [])
let season_local
season.subscribe((s) => {
season_local = s
});
let portions_local
portions.update(() => "")
portions.subscribe((p) => {
portions_local = p});
let img_local
img.update(() => "")
img.subscribe((i) => {
img_local = i});
export let card_data ={
icon: "",
category: "",
name: "",
description: "",
tags: [],
}
export let add_info ={
preparation: "",
fermentation: {
bulk: "",
final: "",
},
baking: {
length: "",
temperature: "",
mode: "",
},
total_time: "",
cooking: "",
}
let images = []
let short_name = ""
let datecreated = new Date()
let datemodified = datecreated
import type { PageData } from './$types';
import CardAdd from '$lib/components/CardAdd.svelte';
import CreateIngredientList from '$lib/components/CreateIngredientList.svelte';
export let ingredients = []
import CreateStepList from '$lib/components/CreateStepList.svelte';
export let instructions = []
function get_season(){
let season = []
const el = document.getElementById("labels");
for(var i = 0; i < el.children.length; i++){
if(el.children[i].children[0].children[0].checked){
season.push(i+1)
}
}
return season
}
function write_season(season){
const el = document.getElementById("labels");
for(var i = 0; i < season.length; i++){
el.children[i].children[0].children[0].checked = true
}
}
async function upload_img(){
console.log("uploading...")
console.log(img_local)
const data = {
image: img_local,
name: short_name.trim(),
}
await fetch(`/api/rezepte/img/add`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
credentials: 'include',
},
body: JSON.stringify(data)
});
}
// Prepare the German recipe data
function getGermanRecipeData() {
return {
...card_data,
...add_info,
images: {mediapath: short_name.trim() + '.webp', alt: "", caption: ""},
season: season_local,
short_name : short_name.trim(),
portions: portions_local,
datecreated,
datemodified,
instructions,
ingredients,
preamble,
addendum,
};
}
// Show translation workflow before submission
function prepareSubmit() {
// Validate required fields
if (!short_name.trim()) {
alert('Bitte geben Sie einen Kurznamen ein');
return;
}
if (!card_data.name) {
alert('Bitte geben Sie einen Namen ein');
return;
}
showTranslationWorkflow = true;
// Scroll to translation section
setTimeout(() => {
document.getElementById('translation-section')?.scrollIntoView({ behavior: 'smooth' });
}, 100);
}
// Handle translation approval
function handleTranslationApproved(event: CustomEvent) {
translationData = event.detail.translatedRecipe;
doPost();
}
// Handle translation skipped
function handleTranslationSkipped() {
translationData = null;
doPost();
}
// Handle translation cancelled
function handleTranslationCancelled() {
showTranslationWorkflow = false;
translationData = null;
}
// Actually submit the recipe
async function doPost () {
upload_img()
console.log(add_info.total_time)
const recipeData = getGermanRecipeData();
// Add translations if available
if (translationData) {
recipeData.translations = {
en: translationData
};
recipeData.translationMetadata = {
lastModifiedGerman: new Date(),
fieldsModifiedSinceTranslation: [],
};
}
const res = await fetch('/api/rezepte/add', {
method: 'POST',
body: JSON.stringify({
recipe: recipeData,
}),
headers: {
'content-type': 'application/json',
}
});
if(res.status === 200){
const url = location.href.split('/')
url.splice(url.length -1, 1);
url.push(short_name)
location.assign(url.join('/'))
}
else{
const item = await res.json();
alert(item.message)
}
}
</script>
<style>
input{
display: block;
border: unset;
margin: 1rem auto;
padding: 0.5em 1em;
border-radius: 1000px;
background-color: var(--nord4);
font-size: 1.1rem;
transition: 100ms;
}
input:hover,
input:focus-visible
{
scale: 1.05 1.05;
}
.list_wrapper{
margin-inline: auto;
display: flex;
flex-direction: row;
max-width: 1000px;
gap: 2rem;
justify-content: center;
}
@media screen and (max-width: 700px){
.list_wrapper{
flex-direction: column;
}
}
h1{
text-align: center;
margin-bottom: 2rem;
}
.title_container{
max-width: 1000px;
display: flex;
flex-direction: column;
margin-inline: auto;
}
.title{
position: relative;
width: min(800px, 80vw);
margin-block: 2rem;
margin-inline: auto;
background-color: var(--nord6);
padding: 1rem 2rem;
}
.title p{
border: 2px solid var(--nord1);
border-radius: 10000px;
padding: 0.5em 1em;
font-size: 1.1rem;
transition: 200ms;
}
.title p:hover,
.title p:focus-within{
scale: 1.02 1.02;
}
.addendum{
font-size: 1.1rem;
max-width: 90%;
margin-inline: auto;
border: 2px solid var(--nord1);
border-radius: 45px;
padding: 1em 1em;
transition: 100ms;
}
.addendum:hover,
.addendum:focus-within
{
scale: 1.02 1.02;
}
.addendum_wrapper{
max-width: 1000px;
margin-inline: auto;
}
h3{
text-align: center;
}
button.action_button{
animation: unset !important;
font-size: 1.3rem;
color: white;
}
.submit_buttons{
display: flex;
margin-inline: auto;
max-width: 1000px;
margin-block: 1rem;
justify-content: center;
align-items: center;
gap: 2rem;
}
.submit_buttons p{
padding: 0;
padding-right: 0.5em;
margin: 0;
}
@media (prefers-color-scheme: dark){
.title{
background-color: var(--nord6-dark);
}
}
</style>
<svelte:head>
<title>Rezept erstellen</title>
<meta name="description" content="Hier können neue Rezepte hinzugefügt werden" />
</svelte:head>
<h1>Rezept erstellen</h1>
<CardAdd {card_data}></CardAdd>
<h3>Kurzname (für URL):</h3>
<input bind:value={short_name} placeholder="Kurzname"/>
<div class=title_container>
<div class=title>
<h4>Eine etwas längere Beschreibung:</h4>
<p bind:innerText={preamble} contenteditable></p>
<div class=tags>
<h4>Saison:</h4>
<SeasonSelect></SeasonSelect>
</div>
</div>
</div>
<div class=list_wrapper>
<div>
<CreateIngredientList {ingredients}></CreateIngredientList>
</div>
<div>
<CreateStepList {instructions} {add_info}></CreateStepList>
</div>
</div>
<div class=addendum_wrapper>
<h3>Nachtrag:</h3>
<div class=addendum bind:innerText={addendum} contenteditable></div>
</div>
{#if !showTranslationWorkflow}
<div class=submit_buttons>
<button class=action_button on:click={prepareSubmit}><p>Weiter zur Übersetzung</p><Check fill=white width=2rem height=2rem></Check></button>
</div>
{/if}
{#if showTranslationWorkflow}
<div id="translation-section">
<TranslationApproval
germanData={getGermanRecipeData()}
on:approved={handleTranslationApproved}
on:skipped={handleTranslationSkipped}
on:cancelled={handleTranslationCancelled}
/>
</div>
{/if}

View File

@@ -0,0 +1,27 @@
<script lang="ts">
import type { PageData } from './$types';
import "$lib/css/nordtheme.css";
let { data }: { data: PageData } = $props();
import TagCloud from '$lib/components/TagCloud.svelte';
import TagBall from '$lib/components/TagBall.svelte';
const isEnglish = $derived(data.lang === 'en');
const labels = $derived({
title: isEnglish ? 'Categories' : 'Kategorien'
});
</script>
<style>
h1 {
text-align: center;
font-size: 3rem;
}
</style>
<h1>{labels.title}</h1>
<section>
<TagCloud>
{#each data.categories as tag}
<TagBall {tag} ref="/{data.recipeLang}/category">
</TagBall>
{/each}
</TagCloud>
</section>

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

View File

@@ -0,0 +1,22 @@
import type { PageServerLoad } from "./$types";
import { getUserFavorites, addFavoriteStatusToRecipes } from "$lib/server/favorites";
export const load: PageServerLoad = async ({ fetch, locals, params }) => {
const 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();
// Get user favorites and session
const [userFavorites, session] = await Promise.all([
getUserFavorites(fetch, locals),
locals.auth()
]);
return {
category: params.category,
recipes: addFavoriteStatusToRecipes(items, userFavorites),
session
};
};

View File

@@ -0,0 +1,27 @@
<script lang="ts">
import type { PageData } from './$types';
import Recipes from '$lib/components/Recipes.svelte';
import Search from '$lib/components/Search.svelte';
let { data }: { data: PageData } = $props();
let current_month = new Date().getMonth() + 1;
import Card from '$lib/components/Card.svelte'
import { rand_array } from '$lib/js/randomize';
const isEnglish = $derived(data.lang === 'en');
const label = $derived(isEnglish ? 'Recipes in Category' : 'Rezepte in Kategorie');
</script>
<style>
h1 {
text-align: center;
font-size: 3em;
}
</style>
<h1>{label} <q>{data.category}</q>:</h1>
<Search category={data.category} lang={data.lang}></Search>
<section>
<Recipes>
{#each rand_array(data.recipes) as recipe}
<Card {recipe} {current_month} isFavorite={recipe.isFavorite} showFavoriteIndicator={!!data.session?.user} routePrefix="/{data.recipeLang}"></Card>
{/each}
</Recipes>
</section>

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

View File

@@ -0,0 +1,488 @@
<script lang="ts">
import Check from '$lib/assets/icons/Check.svelte';
import Cross from '$lib/assets/icons/Cross.svelte';
import SeasonSelect from '$lib/components/SeasonSelect.svelte';
import TranslationApproval from '$lib/components/TranslationApproval.svelte';
import '$lib/css/action_button.css'
import '$lib/css/nordtheme.css'
import { redirect } from '@sveltejs/kit';
import EditRecipeNote from '$lib/components/EditRecipeNote.svelte';
export let data: PageData;
let preamble = data.recipe.preamble
let addendum = data.recipe.addendum
let image_preview_url="https://bocken.org/static/rezepte/thumb/" + data.recipe.short_name + ".webp?v=" + data.recipe.dateModified;
let note = data.recipe.note
// Translation workflow state
let showTranslationWorkflow = false;
let translationData: any = data.recipe.translations?.en || null;
let changedFields: string[] = [];
// Store original recipe data for change detection
const originalRecipe = JSON.parse(JSON.stringify(data.recipe));
import { season } from '$lib/js/season_store';
import { portions } from '$lib/js/portions_store';
portions.update(() => data.recipe.portions)
let portions_local
portions.subscribe((p) => {
portions_local = p
});
season.update(() => data.recipe.season)
let season_local
season.subscribe((s) => {
season_local = s
});
import { img } from '$lib/js/img_store';
let img_local
img.update(() => "")
img.subscribe((i) => {
img_local = i});
let old_short_name = data.recipe.short_name
export let card_data ={
icon: data.recipe.icon,
category: data.recipe.category,
name: data.recipe.name,
description: data.recipe.description,
tags: data.recipe.tags,
}
export let add_info ={
preparation: data.recipe.preparation,
fermentation: {
bulk: data.recipe.fermentation.bulk,
final: data.recipe.fermentation.final,
},
baking: {
length: data.recipe.baking.length,
temperature: data.recipe.baking.temperature,
mode: data.recipe.baking.mode,
},
total_time: data.recipe.total_time,
cooking: data.recipe.cooking,
}
let images = data.recipe.images
let short_name = data.recipe.short_name
let datecreated = data.recipe.datecreated
let datemodified = new Date()
import type { PageData } from './$types';
import CardAdd from '$lib/components/CardAdd.svelte';
import CreateIngredientList from '$lib/components/CreateIngredientList.svelte';
export let ingredients = data.recipe.ingredients
import CreateStepList from '$lib/components/CreateStepList.svelte';
export let instructions = data.recipe.instructions
function get_season(){
let season = []
const el = document.getElementById("labels");
for(var i = 0; i < el.children.length; i++){
if(el.children[i].children[0].children[0].checked){
season.push(i+1)
}
}
return season
}
function write_season(season){
const el = document.getElementById("labels");
for(var i = 0; i < season.length; i++){
el.children[i].children[0].children[0].checked = true
}
}
// Get current German recipe data
function getCurrentRecipeData() {
return {
...card_data,
...add_info,
images,
season: season_local,
short_name: short_name.trim(),
datecreated,
portions: portions_local,
datemodified,
instructions,
ingredients,
addendum,
preamble,
note,
};
}
// Detect which fields have changed from the original
function detectChangedFields() {
const current = getCurrentRecipeData();
const changed: string[] = [];
const fieldsToCheck = [
'name', 'description', 'preamble', 'addendum',
'note', 'category', 'tags', 'ingredients', 'instructions'
];
for (const field of fieldsToCheck) {
const oldValue = JSON.stringify(originalRecipe[field] || '');
const newValue = JSON.stringify(current[field] || '');
if (oldValue !== newValue) {
changed.push(field);
}
}
return changed;
}
// Show translation workflow before submission
function prepareSubmit() {
changedFields = detectChangedFields();
showTranslationWorkflow = true;
// Scroll to translation section
setTimeout(() => {
document.getElementById('translation-section')?.scrollIntoView({ behavior: 'smooth' });
}, 100);
}
// Handle translation approval
function handleTranslationApproved(event: CustomEvent) {
translationData = event.detail.translatedRecipe;
doEdit();
}
// Handle translation skipped
function handleTranslationSkipped() {
// Mark translation as needing update if fields changed
if (changedFields.length > 0 && translationData) {
translationData.translationStatus = 'needs_update';
translationData.changedFields = changedFields;
}
doEdit();
}
// Handle translation cancelled
function handleTranslationCancelled() {
showTranslationWorkflow = false;
}
async function doDelete(){
const response = confirm("Bist du dir sicher, dass du das Rezept löschen willst?")
if(!response){
return
}
const res_img = await fetch('/api/rezepte/img/delete', {
method: 'POST',
body: JSON.stringify({
name: old_short_name,
}),
headers : {
'content-type': 'application/json',
credentials: 'include',
}
})
if(!res_img.ok){
const item = await res_img.json();
//alert(item.message)
return
}
return
const res = await fetch('/api/rezepte/delete', {
method: 'POST',
body: JSON.stringify({
old_short_name,
headers: {
'content-type': 'application/json',
}
})
})
if(res.ok){
const url = location.href.split('/')
url.splice(url.length -2, 2);
location.assign(url.join('/'))
}
else{
const item = await res.json();
// alert(item.message)
}
}
async function doEdit() {
// two cases:
//new image uploaded (not implemented yet)
// new short_name -> move images as well
// if new image
console.log("img_local", img_local)
if(img_local != ""){
async function delete_img(){
const res = await fetch('/api/rezepte/img/delete', {
method: 'POST',
body: JSON.stringify({
name: old_short_name,
}),
headers : {
'content-type': 'application/json',
credentials: 'include',
}
})
if(!res.ok){
const item = await res.json();
// alert(item.message)
}
}
async function upload_img(){
const data = {
image: img_local,
name: short_name.trim(),
}
const res = await fetch(`/api/rezepte/img/add`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
credentials: 'include',
},
body: JSON.stringify(data)
});
if(!res.ok){
const item = await res.json();
// alert(item.message)
}
}
delete_img()
upload_img()
}
// case new short_name:
else if(short_name != old_short_name){
console.log("MOVING")
const res_img = await fetch('/api/rezepte/img/mv', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
credentials: 'include',
},
body: JSON.stringify({
old_name: old_short_name,
new_name: short_name.trim(),
})
})
if(!res_img.ok){
const item = await res_img.json();
//alert(item.message)
return
}
}
const recipeData = getCurrentRecipeData();
// Add translations if available
if (translationData) {
recipeData.translations = {
en: translationData
};
// Update translation metadata
if (changedFields.length > 0) {
recipeData.translationMetadata = {
lastModifiedGerman: new Date(),
fieldsModifiedSinceTranslation: translationData.translationStatus === 'needs_update' ? changedFields : [],
};
}
}
const res = await fetch('/api/rezepte/edit', {
method: 'POST',
body: JSON.stringify({
recipe: recipeData,
old_short_name,
old_recipe: originalRecipe, // For change detection in API
}),
headers: {
'content-type': 'application/json',
credentials: 'include',
}
})
if(res.ok){
const url = location.href.split('/');
url.splice(url.length -2, 2);
url.push(short_name.trim());
location.assign(url.join('/'))
}
else{
const item = await res.json()
//alert(item.message)
}
}
</script>
<style>
input{
display: block;
border: unset;
margin: 1rem auto;
padding: 0.5em 1em;
border-radius: 1000px;
background-color: var(--nord4);
font-size: 1.1rem;
transition: 100ms;
}
input:hover,
input:focus-visible
{
scale: 1.05 1.05;
}
.list_wrapper{
margin-inline: auto;
display: flex;
flex-direction: row;
max-width: 1000px;
gap: 2rem;
justify-content: center;
}
@media screen and (max-width: 700px){
.list_wrapper{
flex-direction: column;
}
}
h1{
text-align: center;
margin-bottom: 2rem;
}
.title_container{
max-width: 1000px;
display: flex;
flex-direction: column;
margin-inline: auto;
}
.title{
position: relative;
width: min(800px, 80vw);
margin-block: 2rem;
margin-inline: auto;
background-color: var(--nord6);
padding: 1rem 2rem;
}
@media (prefers-color-scheme: dark){
.title{
background-color: var(--nord6-dark);
}
}
.title p{
border: 2px solid var(--nord1);
border-radius: 10000px;
padding: 0.5em 1em;
font-size: 1.1rem;
transition: 200ms;
}
.title p:hover,
.title p:focus-within{
scale: 1.02 1.02;
}
.addendum{
font-size: 1.1rem;
max-width: 90%;
margin-inline: auto;
border: 2px solid var(--nord1);
border-radius: 45px;
padding: 1em 1em;
transition: 100ms;
}
.addendum:hover,
.addendum:focus-within
{
scale: 1.02 1.02;
}
.addendum_wrapper{
max-width: 1000px;
margin-inline: auto;
}
h3{
text-align: center;
}
button.action_button{
animation: unset !important;
font-size: 1.3rem;
color: white;
}
.submit_buttons{
display: flex;
margin-inline: auto;
max-width: 1000px;
margin-block: 1rem;
justify-content: center;
align-items: center;
gap: 2rem;
}
.submit_buttons p{
padding: 0;
padding-right: 0.5em;
margin: 0;
}
@media (prefers-color-scheme: dark){
:global(body){
background-color: var(--background-dark);
}
}
</style>
<h1>Rezept editieren</h1>
<CardAdd {card_data} {image_preview_url} ></CardAdd>
<h3>Kurzname (für URL):</h3>
<input bind:value={short_name} placeholder="Kurzname"/>
<div class=title_container>
<div class=title>
<h4>Eine etwas längere Beschreibung:</h4>
<p bind:innerText={preamble} contenteditable></p>
<div class=tags>
<h4>Saison:</h4>
<SeasonSelect></SeasonSelect>
<EditRecipeNote><p contenteditable bind:innerText={note}></p></EditRecipeNote>
</div>
</div>
</div>
<div class=list_wrapper>
<div>
<CreateIngredientList {ingredients}></CreateIngredientList>
</div>
<div>
<CreateStepList {instructions} {add_info}></CreateStepList>
</div>
</div>
<div class=addendum_wrapper>
<h3>Nachtrag:</h3>
<div class=addendum bind:innerText={addendum} contenteditable></div>
</div>
{#if !showTranslationWorkflow}
<div class=submit_buttons>
<button class=action_button on:click={doDelete}><p>Löschen</p><Cross fill=white width=2rem height=2rem></Cross></button>
<button class=action_button on:click={prepareSubmit}><p>Weiter zur Übersetzung</p><Check fill=white width=2rem height=2rem></Check></button>
</div>
{/if}
{#if showTranslationWorkflow}
<div id="translation-section">
<TranslationApproval
germanData={getCurrentRecipeData()}
englishData={translationData}
{changedFields}
isEditMode={true}
on:approved={handleTranslationApproved}
on:skipped={handleTranslationSkipped}
on:cancelled={handleTranslationCancelled}
/>
</div>
{/if}

View File

@@ -0,0 +1,34 @@
import type { PageServerLoad } from "./$types";
import { redirect } from '@sveltejs/kit';
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();
if (!session?.user?.nickname) {
throw redirect(302, `/${params.recipeLang}`);
}
try {
const res = await fetch(`${apiBase}/favorites/recipes`);
if (!res.ok) {
return {
favorites: [],
error: 'Failed to load favorites'
};
}
const favorites = await res.json();
return {
favorites,
session
};
} catch (e) {
return {
favorites: [],
error: 'Failed to load favorites'
};
}
};

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

View File

@@ -0,0 +1,80 @@
<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();
</script>
<style>
a{
font-family: "Noto Color Emoji", emoji, sans-serif;
--padding: 0.5em;
font-size: 3rem;
text-decoration: none;
padding: var(--padding);
background-color: var(--nord4);
border-radius: 1000px;
box-shadow: 0em 0em 0.5em 0.2em rgba(0, 0, 0, 0.2);
text-align: center;
--width: calc(1.2em + var(--padding) * 2);
width: var(--width);
line-height: calc(var(--width) - 2*var(--padding));
height: var(--width);
}
a:hover,
a:focus-visible
{
--angle: 15deg;
animation: shake 0.5s ease forwards;
}
.flex{
display:flex;
flex-wrap:wrap;
gap: 1rem;
max-width: 500px;
justify-content: center;
margin:4rem auto;
}
@keyframes shake{
0%{
transform: rotate(0)
scale(1,1);
}
25%{
box-shadow: 0em 0em 0.6em 0.3em rgba(0, 0, 0, 0.2);
transform: rotate(var(--angle))
scale(1.2,1.2)
;
}
50%{
box-shadow: 0em 0em 0.6em 0.3em rgba(0, 0, 0, 0.2);
transform: rotate(calc(-1* var(--angle)))
scale(1.2,1.2);
}
74%{
box-shadow: 0em 0em 0.6em 0.3em rgba(0, 0, 0, 0.2);
transform: rotate(var(--angle))
scale(1.2, 1.2);
}
100%{
transform: rotate(0)
scale(1.2,1.2);
box-shadow: 0em 0em 0.6em 0.3em rgba(0, 0, 0, 0.2);
}
}
</style>
<div class=flex>
{#each data.icons as icon}
<a href="/{data.recipeLang}/icon/{icon}">{icon}</a>
{/each}
</div>

View File

@@ -0,0 +1,10 @@
import type { PageLoad } from "./$types";
export async function load({ fetch }) {
let current_month = new Date().getMonth() + 1
const res_icons = await fetch(`/api/rezepte/items/icon`);
const item = await res_icons.json();
return {
icons: item,
};
};

View File

@@ -0,0 +1,25 @@
import type { PageServerLoad } from "./$types";
import { getUserFavorites, addFavoriteStatusToRecipes } from "$lib/server/favorites";
export const load: PageServerLoad = async ({ fetch, locals, params }) => {
const isEnglish = params.recipeLang === 'recipes';
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 item_season = await res_season.json();
// Get user favorites and session
const [userFavorites, session] = await Promise.all([
getUserFavorites(fetch, locals),
locals.auth()
]);
return {
icons: icons,
icon: params.icon,
season: addFavoriteStatusToRecipes(item_season, userFavorites),
session
};
};

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import type { PageData } from './$types';
import Recipes from '$lib/components/Recipes.svelte';
import IconLayout from '$lib/components/IconLayout.svelte';
import MediaScroller from '$lib/components/MediaScroller.svelte';
import Card from '$lib/components/Card.svelte';
import Search from '$lib/components/Search.svelte';
let { data }: { data: PageData } = $props();
import { rand_array } from '$lib/js/randomize';
</script>
<IconLayout icons={data.icons} active_icon={data.icon} 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>
</IconLayout>

View File

@@ -0,0 +1,53 @@
import type { PageServerLoad } from './$types';
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 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';
// Build API URL with filters
const apiUrl = new URL(`${apiBase}/search`, url.origin);
if (query) apiUrl.searchParams.set('q', query);
if (category) apiUrl.searchParams.set('category', category);
if (tag) apiUrl.searchParams.set('tag', tag);
if (icon) apiUrl.searchParams.set('icon', icon);
if (season) apiUrl.searchParams.set('season', season);
if (favoritesOnly) apiUrl.searchParams.set('favorites', 'true');
try {
const response = await fetch(apiUrl.toString());
const results = await response.json();
return {
query,
results: response.ok ? results : [],
error: response.ok ? null : results.error || 'Search failed',
filters: {
category,
tag,
icon,
season,
favoritesOnly
}
};
} catch (error) {
return {
query,
results: [],
error: 'Search failed',
filters: {
category,
tag,
icon,
season,
favoritesOnly
}
};
}
};

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

View File

@@ -0,0 +1,22 @@
import type { PageServerLoad } from "./$types";
import { getUserFavorites, addFavoriteStatusToRecipes } from "$lib/server/favorites";
export const load: PageServerLoad = async ({ fetch, locals, params }) => {
const isEnglish = params.recipeLang === 'recipes';
const apiBase = isEnglish ? '/api/recipes' : '/api/rezepte';
let current_month = new Date().getMonth() + 1
const res_season = await fetch(`${apiBase}/items/in_season/` + current_month);
const item_season = await res_season.json();
// Get user favorites and session
const [userFavorites, session] = await Promise.all([
getUserFavorites(fetch, locals),
locals.auth()
]);
return {
season: addFavoriteStatusToRecipes(item_season, userFavorites),
session
};
};

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

View File

@@ -0,0 +1,22 @@
import type { PageServerLoad } from "./$types";
import { getUserFavorites, addFavoriteStatusToRecipes } from "$lib/server/favorites";
export const load: PageServerLoad = async ({ fetch, locals, params }) => {
const 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();
// Get user favorites and session
const [userFavorites, session] = await Promise.all([
getUserFavorites(fetch, locals),
locals.auth()
]);
return {
month: params.month,
season: addFavoriteStatusToRecipes(item_season, userFavorites),
session
};
};

View File

@@ -0,0 +1,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>

View File

@@ -0,0 +1,27 @@
<script lang="ts">
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
import "$lib/css/nordtheme.css";
import TagCloud from '$lib/components/TagCloud.svelte';
import TagBall from '$lib/components/TagBall.svelte';
const isEnglish = $derived(data.lang === 'en');
const labels = $derived({
title: isEnglish ? 'Keywords' : 'Stichwörter'
});
</script>
<style>
h1 {
font-size: 3rem;
text-align: center;
}
</style>
<h1>{labels.title}</h1>
<section>
<TagCloud>
{#each data.tags as tag}
<TagBall {tag} ref="/{data.recipeLang}/tag">
</TagBall>
{/each}
</TagCloud>
</section>

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

View File

@@ -0,0 +1,22 @@
import type { PageServerLoad} from "./$types";
import { getUserFavorites, addFavoriteStatusToRecipes } from "$lib/server/favorites";
export const load: PageServerLoad = async ({ fetch, locals, params }) => {
const 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();
// Get user favorites and session
const [userFavorites, session] = await Promise.all([
getUserFavorites(fetch, locals),
locals.auth()
]);
return {
tag: params.tag,
recipes: addFavoriteStatusToRecipes(items_tag, userFavorites),
session
};
};

View File

@@ -0,0 +1,27 @@
<script lang="ts">
import type { PageData } from './$types';
import Recipes from '$lib/components/Recipes.svelte';
let { data }: { data: PageData } = $props();
let current_month = new Date().getMonth() + 1;
import Card from '$lib/components/Card.svelte'
import Search from '$lib/components/Search.svelte';
import { rand_array } from '$lib/js/randomize';
const isEnglish = $derived(data.lang === 'en');
const label = $derived(isEnglish ? 'Recipes with Keyword' : 'Rezepte mit Stichwort');
</script>
<style>
h1 {
text-align: center;
font-size: 2em;
}
</style>
<h1>{label} <q>{data.tag}</q>:</h1>
<Search tag={data.tag} lang={data.lang}></Search>
<section>
<Recipes>
{#each rand_array(data.recipes) as recipe}
<Card {recipe} {current_month} isFavorite={recipe.isFavorite} showFavoriteIndicator={!!data.session?.user} routePrefix="/{data.recipeLang}"></Card>
{/each}
</Recipes>
</section>

View File

@@ -0,0 +1,62 @@
<script lang="ts">
import type { PageData } from './$types';
import AddButton from '$lib/components/AddButton.svelte';
import Converter from './Converter.svelte';
</script>
<style>
h1{
text-align: center;
margin-bottom: 0;
font-size: 4rem;
}
.subheading{
text-align: center;
margin-top: 0;
font-size: 1.5rem;
}
.content{
max-width: 800px;
margin: 0 auto;
background-color: var(--nord0);
padding: 1rem;
margin-block: 1rem;
}
</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>Tipps & Tricks</h1>
<div class=content>
<h2>Trockenhefe vs. Frischhefe</h2>
<Converter></Converter>
<p>
Frischhefe ist mit Trockenhefe ersetzbar, jedoch muss man ein paar Kleinigkeiten beachten:
</p>
<ol>
<li>Nur ein Drittel der Menge verwenden.</li>
<li>Falls ein kalter Teig zubereitet wird, die Trockenhefe umbedingt zuerst zur Gärprobe in warmer Flüssigkeit (je nach Rezept z.B. Milch oder Wasser) mit einem TL Zucker für ~10 Minuten <q>aufwachen</q> lassen.</li>
<li>Generell ist die Meinung das Trockenhefe etwas <q>energischer</q> ist am Anfang der Gärung und etwas langsamer am Ende der Gare. Dementsprechend eventuell die Stock- und Stückgare verkürzen bzw. verlängern.</li>
</ol>
</div>
<div class=content>
<h2>Fensterprobe</h2>
<p>
Die Fensterprobe ist eine Methode um den optimalen Knetzustand eines Teiges zu bestimmen.
Dazu wird ein kleines, ca. Walnussgrosses Stück Teig zwischen den Fingern auseinandergezogen. Ist der Teig elastisch und reisst nicht bis der Teig so dünn ist, dass man leicht licht durchsehen kann, so ist der Teig optimal verknetet.
</p>
<p>
Teig lässt sich leichter verkneten wenn er noch trockener ist. Daher lohnt es sich zunächst etwa 10% der Flüssigkeit zurückzuhalten und erst nach und nach zuzugeben nachdem der Teig bereits für einige Minuten geknetet wurde.
</p>
</div>
<AddButton href="/rezepte/add"></AddButton>

View File

@@ -0,0 +1,68 @@
<script>
class HefeConverter {
constructor(trockenhefe = 1) {
this._trockenhefe = trockenhefe;
this._frischhefe = this._trockenhefe * 3;
}
get trockenhefe() {
return Math.round(this._trockenhefe * 100) / 100 + "g";
}
set trockenhefe(value) {
this._trockenhefe = value.replace(/\D/g, '');
this._frischhefe = this._trockenhefe * 3;
}
get frischhefe() {
return this._frischhefe+"g";
}
set frischhefe(value) {
this._frischhefe = value.replace(/\D/g, '');
this._trockenhefe = this._frischhefe / 3;
}
}
const hefeConverter = new HefeConverter();
</script>
<style>
.converter_container {
width: fit-content;
display: flex;
flex-direction: row;
justify-content: center;
gap: 2rem;
background-color: var(--blue);
padding: 2rem;
margin-inline: auto;
align-items: center;
}
input {
width: 5rem;
height: 2rem;
font-size: 1rem;
text-align: center;
}
.flex_column {
display: flex;
flex-direction: column;
}
label {
margin-bottom: 0.25rem;
}
</style>
<div class=converter_container>
<div class="flex_column">
<label for="trockenhefe">Trockenhefe</label>
<input type="text" bind:value={hefeConverter.trockenhefe} min="0" />
</div>
<div>
=
</div>
<div class="flex_column">
<label for="frischhefe">Frischhefe</label>
<input type="text" bind:value={hefeConverter.frischhefe} min="0"/>
</div>
</div>