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