faith: progressive enhancement for all faith pages without JS
All checks were successful
CI / update (push) Successful in 1m29s
All checks were successful
CI / update (push) Successful in 1m29s
- Rosary: mystery selection, luminous toggle, and latin toggle fall back to URL params (?mystery=, ?luminous=, ?latin=) for no-JS navigation - Prayers/Angelus: latin toggle uses URL param fallback - Search on prayers page hidden without JS (requires DOM queries) - Toggle component supports href prop for link-based no-JS self-submit - LanguageSelector uses <a> links with computed paths and :focus-within dropdown for no-JS; displays correct language via server-provided prop - Recipe language links use translated slugs from $page.data - URL params cleaned via replaceState after hydration to avoid clutter
This commit is contained in:
@@ -5,9 +5,14 @@
|
||||
import { languageStore } from '$lib/stores/language';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let { lang = undefined }: { lang?: 'de' | 'en' } = $props();
|
||||
|
||||
// Use prop for display if provided (SSR-safe), otherwise fall back to store
|
||||
const displayLang = $derived(lang ?? $languageStore);
|
||||
|
||||
let currentPath = $state('');
|
||||
let langButton: HTMLButtonElement;
|
||||
let langOptions: HTMLDivElement;
|
||||
let isOpen = $state(false);
|
||||
|
||||
// Faith subroute mappings
|
||||
const faithSubroutes: Record<string, Record<string, string>> = {
|
||||
@@ -34,30 +39,58 @@
|
||||
});
|
||||
|
||||
function toggle_language_options(){
|
||||
if (langOptions) {
|
||||
langOptions.hidden = !langOptions.hidden;
|
||||
}
|
||||
isOpen = !isOpen;
|
||||
}
|
||||
|
||||
function convertFaithPath(path: string, targetLang: 'de' | 'en'): string {
|
||||
// Extract the current base and subroute
|
||||
const faithMatch = path.match(/^\/(glaube|faith)(\/(.+))?$/);
|
||||
if (!faithMatch) return path;
|
||||
|
||||
const targetBase = targetLang === 'en' ? 'faith' : 'glaube';
|
||||
const subroute = faithMatch[3]; // e.g., "gebete", "rosenkranz", "angelus"
|
||||
const rest = faithMatch[3]; // e.g., "gebete", "rosenkranz/sub", "angelus"
|
||||
|
||||
if (!subroute) {
|
||||
// Main faith page
|
||||
if (!rest) {
|
||||
return `/${targetBase}`;
|
||||
}
|
||||
|
||||
// Convert subroute
|
||||
const convertedSubroute = faithSubroutes[targetLang][subroute] || subroute;
|
||||
return `/${targetBase}/${convertedSubroute}`;
|
||||
// Split on / to convert just the first segment (gebete→prayers, etc.)
|
||||
const parts = rest.split('/');
|
||||
parts[0] = faithSubroutes[targetLang][parts[0]] || parts[0];
|
||||
return `/${targetBase}/${parts.join('/')}`;
|
||||
}
|
||||
|
||||
// Compute target paths for each language (used as href for no-JS)
|
||||
function computeTargetPath(targetLang: 'de' | 'en'): string {
|
||||
const path = currentPath || $page.url.pathname;
|
||||
|
||||
if (path.startsWith('/glaube') || path.startsWith('/faith')) {
|
||||
return convertFaithPath(path, targetLang);
|
||||
}
|
||||
|
||||
// Use translated recipe slugs from page data when available (works during SSR)
|
||||
const pageData = $page.data;
|
||||
if (targetLang === 'en' && path.startsWith('/rezepte')) {
|
||||
if (pageData?.englishShortName) {
|
||||
return `/recipes/${pageData.englishShortName}`;
|
||||
}
|
||||
return path.replace('/rezepte', '/recipes');
|
||||
}
|
||||
if (targetLang === 'de' && path.startsWith('/recipes')) {
|
||||
if (pageData?.germanShortName) {
|
||||
return `/rezepte/${pageData.germanShortName}`;
|
||||
}
|
||||
return path.replace('/recipes', '/rezepte');
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
const dePath = $derived(computeTargetPath('de'));
|
||||
const enPath = $derived(computeTargetPath('en'));
|
||||
|
||||
async function switchLanguage(lang: 'de' | 'en') {
|
||||
isOpen = false;
|
||||
|
||||
// Update the shared language store immediately
|
||||
languageStore.set(lang);
|
||||
|
||||
@@ -117,7 +150,7 @@
|
||||
onMount(() => {
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if(langButton && !langButton.contains(e.target as Node)){
|
||||
if (langOptions) langOptions.hidden = true;
|
||||
isOpen = false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -159,8 +192,18 @@
|
||||
width: 10ch;
|
||||
padding: 0.5rem;
|
||||
z-index: 1000;
|
||||
display: none;
|
||||
}
|
||||
.language-options button{
|
||||
/* Show via JS toggle */
|
||||
.language-options.open {
|
||||
display: block;
|
||||
}
|
||||
/* Show via CSS focus-within (no-JS fallback) */
|
||||
.language-selector:focus-within .language-options {
|
||||
display: block;
|
||||
}
|
||||
.language-options a{
|
||||
display: block;
|
||||
width: 100%;
|
||||
background-color: transparent;
|
||||
color: white;
|
||||
@@ -171,32 +214,36 @@
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
text-align: left;
|
||||
text-decoration: none;
|
||||
transition: background-color 100ms;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.language-options button:hover{
|
||||
.language-options a:hover{
|
||||
background-color: var(--nord2);
|
||||
}
|
||||
.language-options button.active{
|
||||
.language-options a.active{
|
||||
background-color: var(--nord14);
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="language-selector">
|
||||
<button bind:this={langButton} onclick={toggle_language_options} class="language-button">
|
||||
{$languageStore.toUpperCase()}
|
||||
{displayLang.toUpperCase()}
|
||||
</button>
|
||||
<div bind:this={langOptions} class="language-options" hidden>
|
||||
<button
|
||||
class:active={$languageStore === 'de'}
|
||||
onclick={() => switchLanguage('de')}
|
||||
<div class="language-options" class:open={isOpen}>
|
||||
<a
|
||||
href={dePath}
|
||||
class:active={displayLang === 'de'}
|
||||
onclick={(e) => { e.preventDefault(); switchLanguage('de'); }}
|
||||
>
|
||||
DE
|
||||
</button>
|
||||
<button
|
||||
class:active={$languageStore === 'en'}
|
||||
onclick={() => switchLanguage('en')}
|
||||
</a>
|
||||
<a
|
||||
href={enPath}
|
||||
class:active={displayLang === 'en'}
|
||||
onclick={(e) => { e.preventDefault(); switchLanguage('en'); }}
|
||||
>
|
||||
EN
|
||||
</button>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,11 +3,15 @@
|
||||
import { getLanguageContext } from '$lib/contexts/languageContext.js';
|
||||
import Toggle from './Toggle.svelte';
|
||||
|
||||
export let initialLatin = undefined;
|
||||
export let hasUrlLatin = false;
|
||||
export let href = undefined;
|
||||
|
||||
// Get the language context (must be created by parent page)
|
||||
const { showLatin, lang } = getLanguageContext();
|
||||
|
||||
// Local state for the checkbox
|
||||
let showBilingual = true;
|
||||
let showBilingual = initialLatin !== undefined ? initialLatin : true;
|
||||
|
||||
// Flag to prevent saving before we've loaded from localStorage
|
||||
let hasLoadedFromStorage = false;
|
||||
@@ -26,10 +30,12 @@
|
||||
: 'Lateinisch und Deutsch anzeigen';
|
||||
|
||||
onMount(() => {
|
||||
// Load from localStorage
|
||||
const saved = localStorage.getItem('rosary_showBilingual');
|
||||
if (saved !== null) {
|
||||
showBilingual = saved === 'true';
|
||||
// Only load from localStorage if no URL param was set
|
||||
if (!hasUrlLatin) {
|
||||
const saved = localStorage.getItem('rosary_showBilingual');
|
||||
if (saved !== null) {
|
||||
showBilingual = saved === 'true';
|
||||
}
|
||||
}
|
||||
|
||||
// Now allow saving
|
||||
@@ -40,5 +46,6 @@
|
||||
<Toggle
|
||||
bind:checked={showBilingual}
|
||||
{label}
|
||||
{href}
|
||||
accentColor="var(--nord14)"
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
let { checked = $bindable(false), label = "", accentColor = "var(--nord14)" } = $props<{ checked?: boolean, label?: string, accentColor?: string }>();
|
||||
let { checked = $bindable(false), label = "", accentColor = "var(--nord14)", href = undefined as string | undefined } = $props<{ checked?: boolean, label?: string, accentColor?: string, href?: string }>();
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@@ -7,17 +7,20 @@
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.toggle-wrapper label {
|
||||
.toggle-wrapper label,
|
||||
.toggle-wrapper a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
color: var(--nord4);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@media(prefers-color-scheme: light) {
|
||||
.toggle-wrapper label {
|
||||
.toggle-wrapper label,
|
||||
.toggle-wrapper a {
|
||||
color: var(--nord2);
|
||||
}
|
||||
}
|
||||
@@ -26,7 +29,8 @@
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* iOS-style toggle switch */
|
||||
/* iOS-style toggle switch — shared by checkbox and link variants */
|
||||
.toggle-track,
|
||||
.toggle-wrapper input[type="checkbox"] {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
@@ -40,18 +44,22 @@
|
||||
outline: none;
|
||||
border: none;
|
||||
flex-shrink: 0;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
@media(prefers-color-scheme: light) {
|
||||
.toggle-track,
|
||||
.toggle-wrapper input[type="checkbox"] {
|
||||
background: var(--nord4);
|
||||
}
|
||||
}
|
||||
|
||||
.toggle-track.checked,
|
||||
.toggle-wrapper input[type="checkbox"]:checked {
|
||||
background: var(--accent-color);
|
||||
}
|
||||
|
||||
.toggle-track::before,
|
||||
.toggle-wrapper input[type="checkbox"]::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
@@ -65,14 +73,22 @@
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.toggle-track.checked::before,
|
||||
.toggle-wrapper input[type="checkbox"]:checked::before {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="toggle-wrapper" style="--accent-color: {accentColor}">
|
||||
<label>
|
||||
<input type="checkbox" bind:checked />
|
||||
<span>{label}</span>
|
||||
</label>
|
||||
{#if href}
|
||||
<a {href} onclick={(e) => { e.preventDefault(); checked = !checked; }}>
|
||||
<span class="toggle-track" class:checked></span>
|
||||
<span>{label}</span>
|
||||
</a>
|
||||
{:else}
|
||||
<label>
|
||||
<input type="checkbox" bind:checked />
|
||||
<span>{label}</span>
|
||||
</label>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -6,9 +6,10 @@ const LANGUAGE_CONTEXT_KEY = Symbol('language');
|
||||
/**
|
||||
* Creates or updates a language context for prayer components
|
||||
* @param {Object} options
|
||||
* @param {'de' | 'en'} options.urlLang - The URL language (de for /glaube, en for /faith)
|
||||
* @param {'de' | 'en'} [options.urlLang] - The URL language (de for /glaube, en for /faith)
|
||||
* @param {boolean} [options.initialLatin] - Initial state for Latin/bilingual display
|
||||
*/
|
||||
export function createLanguageContext({ urlLang = 'de' } = {}) {
|
||||
export function createLanguageContext({ urlLang = 'de', initialLatin = true } = {}) {
|
||||
// Check if context already exists (e.g., during navigation)
|
||||
if (hasContext(LANGUAGE_CONTEXT_KEY)) {
|
||||
const existing = getContext(LANGUAGE_CONTEXT_KEY);
|
||||
@@ -17,7 +18,7 @@ export function createLanguageContext({ urlLang = 'de' } = {}) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const showLatin = writable(true); // true = bilingual (Latin + vernacular), false = monolingual
|
||||
const showLatin = writable(initialLatin); // true = bilingual (Latin + vernacular), false = monolingual
|
||||
const lang = writable(urlLang); // 'de' or 'en' based on URL
|
||||
|
||||
setContext(LANGUAGE_CONTEXT_KEY, {
|
||||
|
||||
Reference in New Issue
Block a user