add route matcher to fix /login and /logout routes
Some checks failed
CI / update (push) Failing after 2m52s
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:
73
src/routes/[recipeLang=recipeLang]/[name]/+page.server.ts
Normal file
73
src/routes/[recipeLang=recipeLang]/[name]/+page.server.ts
Normal 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());
|
||||
}
|
||||
};
|
||||
368
src/routes/[recipeLang=recipeLang]/[name]/+page.svelte
Normal file
368
src/routes/[recipeLang=recipeLang]/[name]/+page.svelte
Normal 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>
|
||||
130
src/routes/[recipeLang=recipeLang]/[name]/+page.ts
Normal file
130
src/routes/[recipeLang=recipeLang]/[name]/+page.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user