refactor language selector into separate component

Extract language switching functionality from UserHeader into a new
LanguageSelector component. In mobile view, the selector appears in
the top bar next to the hamburger menu. In desktop view, it appears
in the navigation bar to the left of the UserHeader.

- Create LanguageSelector component with local element bindings
- Update Header component with language_selector_mobile and
  language_selector_desktop slots
- Remove language selector code from UserHeader
- Update recipe and main layouts to use new component
- Hide desktop language selector inside mobile hamburger menu
This commit is contained in:
2025-12-27 09:46:04 +01:00
parent 409180e94f
commit 8d5d64a9bd
5 changed files with 212 additions and 174 deletions

View File

@@ -79,6 +79,16 @@ nav[hidden]{
display: none; display: none;
padding-inline: 0.5rem; padding-inline: 0.5rem;
} }
.right-buttons{
display: flex;
align-items: center;
gap: 0.5rem;
}
.header-right{
display: flex;
align-items: center;
gap: 0.5rem;
}
:global(svg.symbol){ :global(svg.symbol){
height: 4rem; height: 4rem;
width: 4rem; width: 4rem;
@@ -174,18 +184,32 @@ footer{
:global(.site_header li:focus-within){ :global(.site_header li:focus-within){
transform: unset; transform: unset;
} }
.nav_site .header-right{
flex-direction: column;
}
.language-selector-desktop{
display: none;
}
} }
</style> </style>
<div class=wrapper lang=de> <div class=wrapper lang=de>
<div> <div>
<div class=button_wrapper> <div class=button_wrapper>
<a href="/"><Symbol></Symbol></a> <a href="/"><Symbol></Symbol></a>
<button class=nav_button on:click={() => {toggle_sidebar()}}><svg xmlns="http://www.w3.org/2000/svg" height="0.5em" viewBox="0 0 448 512"><!--! Font Awesome Free 6.4.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. --><path d="M0 96C0 78.3 14.3 64 32 64H416c17.7 0 32 14.3 32 32s-14.3 32-32 32H32C14.3 128 0 113.7 0 96zM0 256c0-17.7 14.3-32 32-32H416c17.7 0 32 14.3 32 32s-14.3 32-32 32H32c-17.7 0-32-14.3-32-32zM448 416c0 17.7-14.3 32-32 32H32c-17.7 0-32-14.3-32-32s14.3-32 32-32H416c17.7 0 32 14.3 32 32z"/></svg></button> <div class="right-buttons">
<slot name=language_selector_mobile></slot>
<button class=nav_button on:click={() => {toggle_sidebar()}}><svg xmlns="http://www.w3.org/2000/svg" height="0.5em" viewBox="0 0 448 512"><!--! Font Awesome Free 6.4.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. --><path d="M0 96C0 78.3 14.3 64 32 64H416c17.7 0 32 14.3 32 32s-14.3 32-32 32H32C14.3 128 0 113.7 0 96zM0 256c0-17.7 14.3-32 32-32H416c17.7 0 32 14.3 32 32s-14.3 32-32 32H32c-17.7 0-32-14.3-32-32zM448 416c0 17.7-14.3 32-32 32H32c-17.7 0-32-14.3-32-32s14.3-32 32-32H416c17.7 0 32 14.3 32 32z"/></svg></button>
</div>
</div> </div>
<nav hidden class=nav_site> <nav hidden class=nav_site>
<a class=entry href="/"><Symbol></Symbol></a> <a class=entry href="/"><Symbol></Symbol></a>
<slot name=links></slot> <slot name=links></slot>
<slot name=right_side></slot> <div class="header-right">
<div class="language-selector-desktop">
<slot name=language_selector_desktop></slot>
</div>
<slot name=right_side></slot>
</div>
</nav> </nav>
<slot></slot> <slot></slot>

View File

@@ -0,0 +1,163 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { recipeTranslationStore } from '$lib/stores/recipeTranslation';
import { onMount } from 'svelte';
let currentLang = $state('de');
let currentPath = $state('');
let langButton: HTMLButtonElement;
let langOptions: HTMLDivElement;
$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';
} else if (path === '/') {
// On main page, read from localStorage
const preferredLanguage = localStorage.getItem('preferredLanguage');
currentLang = preferredLanguage === 'en' ? 'en' : 'de';
}
}
});
function toggle_language_options(){
if (langOptions) {
langOptions.hidden = !langOptions.hidden;
}
}
async function switchLanguage(lang: 'de' | 'en') {
// Update the current language state immediately
currentLang = lang;
// Store preference
if (typeof localStorage !== 'undefined') {
localStorage.setItem('preferredLanguage', lang);
}
// Get the current path directly from window
const path = typeof window !== 'undefined' ? window.location.pathname : currentPath;
// If on main page, dispatch event instead of reloading
if (path === '/') {
window.dispatchEvent(new CustomEvent('languagechange', { detail: { lang } }));
return;
}
// If we have recipe translation data from store, use the correct short names
const recipeData = $recipeTranslationStore;
if (recipeData) {
if (lang === 'en' && recipeData.englishShortName) {
await goto(`/recipes/${recipeData.englishShortName}`);
return;
} else if (lang === 'de' && recipeData.germanShortName) {
await goto(`/rezepte/${recipeData.germanShortName}`);
return;
}
}
// Convert current path to target language (for non-recipe pages)
let newPath = path;
if (lang === 'en' && path.startsWith('/rezepte')) {
newPath = path.replace('/rezepte', '/recipes');
} else if (lang === 'de' && path.startsWith('/recipes')) {
newPath = path.replace('/recipes', '/rezepte');
} else if (!path.startsWith('/rezepte') && !path.startsWith('/recipes')) {
// On other pages (glaube, cospend, etc), go to recipe home
newPath = lang === 'en' ? '/recipes' : '/rezepte';
}
await goto(newPath);
}
onMount(() => {
const handleClick = (e: MouseEvent) => {
if(langButton && !langButton.contains(e.target as Node)){
if (langOptions) langOptions.hidden = true;
}
};
document.addEventListener("click", handleClick);
return () => {
document.removeEventListener("click", handleClick);
};
})
</script>
<style>
.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;
border: none;
}
.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);
}
</style>
<div class="language-selector">
<button bind:this={langButton} onclick={toggle_language_options} class="language-button">
{currentLang.toUpperCase()}
</button>
<div bind:this={langOptions} class="language-options" hidden>
<button
class:active={currentLang === 'de'}
onclick={() => switchLanguage('de')}
>
DE
</button>
<button
class:active={currentLang === 'en'}
onclick={() => switchLanguage('en')}
>
EN
</button>
</div>
</div>

View File

@@ -1,99 +1,21 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte"; import { onMount } from "svelte";
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { recipeTranslationStore } from '$lib/stores/recipeTranslation';
let { user, showLanguageSelector = false } = $props(); let { user } = $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';
} else if (path === '/') {
// On main page, read from localStorage
const preferredLanguage = localStorage.getItem('preferredLanguage');
currentLang = preferredLanguage === 'en' ? 'en' : 'de';
}
}
});
function toggle_options(){ function toggle_options(){
const el = document.querySelector("#options") const el = document.querySelector("#options")
el.hidden = !el.hidden el.hidden = !el.hidden
} }
function toggle_language_options(){
const el = document.querySelector("#language-options")
el.hidden = !el.hidden
}
function switchLanguage(lang: 'de' | 'en') {
// Update the current language state immediately
currentLang = lang;
// Store preference
if (typeof localStorage !== 'undefined') {
localStorage.setItem('preferredLanguage', lang);
}
// Get the current path directly from window
const path = typeof window !== 'undefined' ? window.location.pathname : currentPath;
// If on main page, dispatch event instead of reloading
if (path === '/') {
window.dispatchEvent(new CustomEvent('languagechange', { detail: { lang } }));
return;
}
// If we have recipe translation data from store, use the correct short names
const recipeData = $recipeTranslationStore;
if (recipeData) {
if (lang === 'en' && recipeData.englishShortName) {
goto(`/recipes/${recipeData.englishShortName}`);
return;
} else if (lang === 'de' && recipeData.germanShortName) {
goto(`/rezepte/${recipeData.germanShortName}`);
return;
}
}
// Convert current path to target language (for non-recipe pages)
let newPath = path;
if (lang === 'en' && path.startsWith('/rezepte')) {
newPath = path.replace('/rezepte', '/recipes');
} else if (lang === 'de' && path.startsWith('/recipes')) {
newPath = path.replace('/recipes', '/rezepte');
} else if (!path.startsWith('/rezepte') && !path.startsWith('/recipes')) {
// On other pages (glaube, cospend, etc), go to recipe home
newPath = lang === 'en' ? '/recipes' : '/rezepte';
}
goto(newPath);
}
onMount( () => { onMount( () => {
document.addEventListener("click", (e) => { document.addEventListener("click", (e) => {
const userButton = document.querySelector("#button") const userButton = document.querySelector("#button")
const langButton = document.querySelector("#language-button")
if(userButton && !userButton.contains(e.target)){ if(userButton && !userButton.contains(e.target)){
const options = document.querySelector("#options"); const options = document.querySelector("#options");
if (options) options.hidden = true; if (options) options.hidden = true;
} }
if(langButton && !langButton.contains(e.target)){
const langOptions = document.querySelector("#language-options");
if (langOptions) langOptions.hidden = true;
}
}) })
}) })
</script> </script>
@@ -149,59 +71,6 @@
background-position: center; background-position: center;
background-size: contain; background-size: contain;
} }
.language-selector{
position: relative;
}
#language-button{
width: auto;
padding: 0.5rem 1rem;
border-radius: 8px;
background-color: var(--nord3);
color: white;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: background-color 100ms;
}
#language-button:hover{
background-color: var(--nord2);
}
#language-options{
--bg_color: var(--nord3);
box-sizing: border-box;
border-radius: 5px;
position: absolute;
right: 0;
top: calc(100% + 10px);
background-color: var(--bg_color);
width: 10ch;
padding: 0.5rem;
z-index: 1000;
}
#language-options button{
width: 100%;
background-color: transparent;
color: white;
border: none;
padding: 0.5rem;
margin: 0;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
text-align: left;
transition: background-color 100ms;
}
#language-options button:hover{
background-color: var(--nord2);
}
#language-options button.active{
background-color: var(--nord14);
}
.header-right{
display: flex;
align-items: center;
gap: 0.5rem;
}
#options{ #options{
--bg_color: var(--nord3); --bg_color: var(--nord3);
box-sizing: border-box; box-sizing: border-box;
@@ -266,41 +135,17 @@ h2 + p{
} }
</style> </style>
<div class="header-right"> {#if user}
{#if showLanguageSelector} <button on:click={toggle_options} style="background-image: url(https://bocken.org/static/user/thumb/{user.nickname}.webp)" id=button>
<div class="language-selector"> <div id=options class="speech top" hidden>
<button on:click={toggle_language_options} id="language-button"> <h2>{user.name}</h2>
{currentLang.toUpperCase()} <p>({user.nickname})</p>
</button> <ul>
<div id="language-options" hidden> <li><a href="https://sso.bocken.org/if/user/#/settings" >Einstellungen</a></li>
<button <li><a href="/logout" >Log Out</a></li>
class:active={currentLang === 'de'} </ul>
on:click={() => switchLanguage('de')}
>
DE
</button>
<button
class:active={currentLang === 'en'}
on:click={() => switchLanguage('en')}
>
EN
</button>
</div>
</div> </div>
{/if} </button>
{:else}
{#if user} <a class=entry href=/login>Log In</a>
<button on:click={toggle_options} style="background-image: url(https://bocken.org/static/user/thumb/{user.nickname}.webp)" id=button> {/if}
<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>

View File

@@ -1,6 +1,7 @@
<script> <script>
import Header from '$lib/components/Header.svelte' import Header from '$lib/components/Header.svelte'
import UserHeader from '$lib/components/UserHeader.svelte'; import UserHeader from '$lib/components/UserHeader.svelte';
import LanguageSelector from '$lib/components/LanguageSelector.svelte';
let { data } = $props(); let { data } = $props();
let user = $derived(data.session?.user); let user = $derived(data.session?.user);
@@ -9,6 +10,8 @@ let user = $derived(data.session?.user);
<Header> <Header>
<ul class=site_header slot=links> <ul class=site_header slot=links>
</ul> </ul>
<UserHeader {user} slot=right_side showLanguageSelector={true}></UserHeader> <LanguageSelector slot=language_selector_mobile />
<LanguageSelector slot=language_selector_desktop />
<UserHeader {user} slot=right_side></UserHeader>
<slot></slot> <slot></slot>
</Header> </Header>

View File

@@ -1,6 +1,7 @@
<script> <script>
import Header from '$lib/components/Header.svelte' import Header from '$lib/components/Header.svelte'
import UserHeader from '$lib/components/UserHeader.svelte'; import UserHeader from '$lib/components/UserHeader.svelte';
import LanguageSelector from '$lib/components/LanguageSelector.svelte';
let { data } = $props(); let { data } = $props();
let user = $derived(data.session?.user); let user = $derived(data.session?.user);
@@ -29,6 +30,8 @@ const labels = $derived({
<li><a href="/{data.recipeLang}/tag">{labels.keywords}</a></li> <li><a href="/{data.recipeLang}/tag">{labels.keywords}</a></li>
<li><a href="/rezepte/tips-and-tricks">{labels.tips}</a></li> <li><a href="/rezepte/tips-and-tricks">{labels.tips}</a></li>
</ul> </ul>
<UserHeader slot=right_side {user} showLanguageSelector={true}></UserHeader> <LanguageSelector slot=language_selector_mobile />
<LanguageSelector slot=language_selector_desktop />
<UserHeader slot=right_side {user}></UserHeader>
<slot></slot> <slot></slot>
</Header> </Header>