refactor: unify recipe routes into [recipeLang] slug with full bilingual support

Consolidate /rezepte and /recipes routes into single [recipeLang] structure to eliminate code duplication. All pages now use conditional API routing and reactive labels based on language parameter.

- Merge duplicate route structures into /[recipeLang] with 404 for invalid slugs
- Add English API endpoints for search, favorites, tags, and categories
- Implement language dropdown in header with localStorage persistence
- Convert all pages to use Svelte 5 runes (, , )
- Add German-only redirects (301) for add/edit pages
- Make all view pages (list, detail, filters, search, favorites) fully bilingual
- Remove floating language switcher in favor of header dropdown
This commit is contained in:
2025-12-26 21:19:27 +01:00
parent 36a7fac39a
commit 6de3d76504
72 changed files with 417511 additions and 1097 deletions

View File

@@ -1,17 +1,74 @@
<script lang="ts">
import { onMount } from "svelte";
export let user;
import { goto } from '$app/navigation';
import { page } from '$app/stores';
let { user, showLanguageSelector = false } = $props();
let currentLang = $state('de');
let currentPath = $state('');
$effect(() => {
// Update current language and path when page changes
if (typeof window !== 'undefined') {
const path = window.location.pathname;
currentPath = path;
if (path.startsWith('/recipes')) {
currentLang = 'en';
} else if (path.startsWith('/rezepte')) {
currentLang = 'de';
}
}
});
function toggle_options(){
const el = document.querySelector("#options")
el.hidden = !el.hidden
}
function toggle_language_options(){
const el = document.querySelector("#language-options")
el.hidden = !el.hidden
}
function switchLanguage(lang: 'de' | 'en') {
// Update the current language state immediately
currentLang = lang;
// Store preference
if (typeof localStorage !== 'undefined') {
localStorage.setItem('preferredLanguage', lang);
}
// Get the current path directly from window
const path = typeof window !== 'undefined' ? window.location.pathname : currentPath;
// Convert current path to target language
let newPath = path;
if (lang === 'en' && path.startsWith('/rezepte')) {
newPath = path.replace('/rezepte', '/recipes');
} else if (lang === 'de' && path.startsWith('/recipes')) {
newPath = path.replace('/recipes', '/rezepte');
} else if (path === '/' || (!path.startsWith('/rezepte') && !path.startsWith('/recipes'))) {
// On main page or other pages, go to recipe home
newPath = lang === 'en' ? '/recipes' : '/rezepte';
}
goto(newPath);
}
onMount( () => {
document.addEventListener("click", (e) => {
const el = document.querySelector("#button")
if(!el.contains(e.target)){
document.querySelector("#options").hidden = true
const userButton = document.querySelector("#button")
const langButton = document.querySelector("#language-button")
if(userButton && !userButton.contains(e.target)){
const options = document.querySelector("#options");
if (options) options.hidden = true;
}
if(langButton && !langButton.contains(e.target)){
const langOptions = document.querySelector("#language-options");
if (langOptions) langOptions.hidden = true;
}
})
})
@@ -68,6 +125,59 @@
background-position: center;
background-size: contain;
}
.language-selector{
position: relative;
}
#language-button{
width: auto;
padding: 0.5rem 1rem;
border-radius: 8px;
background-color: var(--nord3);
color: white;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: background-color 100ms;
}
#language-button:hover{
background-color: var(--nord2);
}
#language-options{
--bg_color: var(--nord3);
box-sizing: border-box;
border-radius: 5px;
position: absolute;
right: 0;
top: calc(100% + 10px);
background-color: var(--bg_color);
width: 10ch;
padding: 0.5rem;
z-index: 1000;
}
#language-options button{
width: 100%;
background-color: transparent;
color: white;
border: none;
padding: 0.5rem;
margin: 0;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
text-align: left;
transition: background-color 100ms;
}
#language-options button:hover{
background-color: var(--nord2);
}
#language-options button.active{
background-color: var(--nord14);
}
.header-right{
display: flex;
align-items: center;
gap: 0.5rem;
}
#options{
--bg_color: var(--nord3);
box-sizing: border-box;
@@ -132,17 +242,41 @@ h2 + p{
}
</style>
{#if user}
<button on:click={toggle_options} style="background-image: url(https://bocken.org/static/user/thumb/{user.nickname}.webp)" id=button>
<div id=options class="speech top" hidden>
<h2>{user.name}</h2>
<p>({user.nickname})</p>
<ul>
<li><a href="https://sso.bocken.org/if/user/#/settings" >Einstellungen</a></li>
<li><a href="/logout" >Log Out</a></li>
</ul>
<div class="header-right">
{#if showLanguageSelector}
<div class="language-selector">
<button on:click={toggle_language_options} id="language-button">
{currentLang.toUpperCase()}
</button>
<div id="language-options" hidden>
<button
class:active={currentLang === 'de'}
on:click={() => switchLanguage('de')}
>
DE
</button>
<button
class:active={currentLang === 'en'}
on:click={() => switchLanguage('en')}
>
EN
</button>
</div>
</div>
</button>
{:else}
<a class=entry href=/login>Log In</a>
{/if}
{/if}
{#if user}
<button on:click={toggle_options} style="background-image: url(https://bocken.org/static/user/thumb/{user.nickname}.webp)" id=button>
<div id=options class="speech top" hidden>
<h2>{user.name}</h2>
<p>({user.nickname})</p>
<ul>
<li><a href="https://sso.bocken.org/if/user/#/settings" >Einstellungen</a></li>
<li><a href="/logout" >Log Out</a></li>
</ul>
</div>
</button>
{:else}
<a class=entry href=/login>Log In</a>
{/if}
</div>