diff --git a/src/lib/components/LanguageSelector.svelte b/src/lib/components/LanguageSelector.svelte index 1b4d08f..82abaf7 100644 --- a/src/lib/components/LanguageSelector.svelte +++ b/src/lib/components/LanguageSelector.svelte @@ -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> = { @@ -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); }
-
diff --git a/src/lib/components/LanguageToggle.svelte b/src/lib/components/LanguageToggle.svelte index f56ee6f..951e036 100644 --- a/src/lib/components/LanguageToggle.svelte +++ b/src/lib/components/LanguageToggle.svelte @@ -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 @@ diff --git a/src/lib/components/Toggle.svelte b/src/lib/components/Toggle.svelte index 3f0e834..48a66c0 100644 --- a/src/lib/components/Toggle.svelte +++ b/src/lib/components/Toggle.svelte @@ -1,5 +1,5 @@
- + {#if href} + { e.preventDefault(); checked = !checked; }}> + + {label} + + {:else} + + {/if}
diff --git a/src/lib/contexts/languageContext.js b/src/lib/contexts/languageContext.js index 9b84cc5..59e4381 100644 --- a/src/lib/contexts/languageContext.js +++ b/src/lib/contexts/languageContext.js @@ -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, { diff --git a/src/routes/[faithLang=faithLang]/+layout.svelte b/src/routes/[faithLang=faithLang]/+layout.svelte index 9efbc7d..a003047 100644 --- a/src/routes/[faithLang=faithLang]/+layout.svelte +++ b/src/routes/[faithLang=faithLang]/+layout.svelte @@ -33,11 +33,11 @@ function isActive(path) { {/snippet} {#snippet language_selector_mobile()} - + {/snippet} {#snippet language_selector_desktop()} - + {/snippet} {#snippet right_side()} diff --git a/src/routes/[faithLang=faithLang]/[prayers=prayersLang]/+page.server.ts b/src/routes/[faithLang=faithLang]/[prayers=prayersLang]/+page.server.ts new file mode 100644 index 0000000..50bb977 --- /dev/null +++ b/src/routes/[faithLang=faithLang]/[prayers=prayersLang]/+page.server.ts @@ -0,0 +1,12 @@ +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ url }) => { + const latinParam = url.searchParams.get('latin'); + const hasUrlLatin = latinParam !== null; + const initialLatin = hasUrlLatin ? latinParam !== '0' : true; + + return { + initialLatin, + hasUrlLatin + }; +}; diff --git a/src/routes/[faithLang=faithLang]/[prayers=prayersLang]/+page.svelte b/src/routes/[faithLang=faithLang]/[prayers=prayersLang]/+page.svelte index b9ed564..9f9a97f 100644 --- a/src/routes/[faithLang=faithLang]/[prayers=prayersLang]/+page.svelte +++ b/src/routes/[faithLang=faithLang]/[prayers=prayersLang]/+page.svelte @@ -1,4 +1,5 @@ @@ -264,18 +279,33 @@ h1{ color: var(--nord0); } } + +/* Search is hidden without JS */ +.js-only { + display: none; +} +.js-enabled .js-only { + display: block; +} +

{labels.title}

- +
- +
+ +
@@ -317,3 +347,4 @@ h1{ {/each}
+
diff --git a/src/routes/[faithLang=faithLang]/[prayers=prayersLang]/[prayer]/+page.server.ts b/src/routes/[faithLang=faithLang]/[prayers=prayersLang]/[prayer]/+page.server.ts index 8aced78..7ffb10d 100644 --- a/src/routes/[faithLang=faithLang]/[prayers=prayersLang]/[prayer]/+page.server.ts +++ b/src/routes/[faithLang=faithLang]/[prayers=prayersLang]/[prayer]/+page.server.ts @@ -17,12 +17,18 @@ const validSlugs = new Set([ 'das-confiteor', 'the-confiteor' ]); -export const load: PageServerLoad = async ({ params }) => { +export const load: PageServerLoad = async ({ params, url }) => { if (!validSlugs.has(params.prayer)) { throw error(404, 'Prayer not found'); } + const latinParam = url.searchParams.get('latin'); + const hasUrlLatin = latinParam !== null; + const initialLatin = hasUrlLatin ? latinParam !== '0' : true; + return { - prayer: params.prayer + prayer: params.prayer, + initialLatin, + hasUrlLatin }; }; diff --git a/src/routes/[faithLang=faithLang]/[prayers=prayersLang]/[prayer]/+page.svelte b/src/routes/[faithLang=faithLang]/[prayers=prayersLang]/[prayer]/+page.svelte index 018addd..17aa601 100644 --- a/src/routes/[faithLang=faithLang]/[prayers=prayersLang]/[prayer]/+page.svelte +++ b/src/routes/[faithLang=faithLang]/[prayers=prayersLang]/[prayer]/+page.svelte @@ -1,4 +1,5 @@ @@ -121,7 +132,11 @@ h1 {

{prayerName}

- +
diff --git a/src/routes/[faithLang=faithLang]/[rosary=rosaryLang]/+page.server.ts b/src/routes/[faithLang=faithLang]/[rosary=rosaryLang]/+page.server.ts index efe24c1..d2aca80 100644 --- a/src/routes/[faithLang=faithLang]/[rosary=rosaryLang]/+page.server.ts +++ b/src/routes/[faithLang=faithLang]/[rosary=rosaryLang]/+page.server.ts @@ -6,9 +6,62 @@ interface StreakData { lastPrayed: string | null; } -export const load: PageServerLoad = async ({ fetch, locals }) => { +const validMysteries = ['freudenreich', 'schmerzhaften', 'glorreichen', 'lichtreichen'] as const; + +function getMysteryForWeekday(date: Date, includeLuminous: boolean): string { + const dayOfWeek = date.getDay(); + + if (includeLuminous) { + const schedule: Record = { + 0: 'glorreichen', + 1: 'freudenreich', + 2: 'schmerzhaften', + 3: 'glorreichen', + 4: 'lichtreichen', + 5: 'schmerzhaften', + 6: 'freudenreich' + }; + return schedule[dayOfWeek]; + } else { + const schedule: Record = { + 0: 'glorreichen', + 1: 'freudenreich', + 2: 'schmerzhaften', + 3: 'glorreichen', + 4: 'freudenreich', + 5: 'schmerzhaften', + 6: 'glorreichen' + }; + return schedule[dayOfWeek]; + } +} + +export const load: PageServerLoad = async ({ url, fetch, locals }) => { const session = await locals.auth(); + // Read toggle/mystery state from URL search params (for no-JS progressive enhancement) + const luminousParam = url.searchParams.get('luminous'); + const latinParam = url.searchParams.get('latin'); + const mysteryParam = url.searchParams.get('mystery'); + + const hasUrlLuminous = luminousParam !== null; + const hasUrlLatin = latinParam !== null; + const hasUrlMystery = mysteryParam !== null; + + const initialLuminous = hasUrlLuminous ? luminousParam !== '0' : true; + const initialLatin = hasUrlLatin ? latinParam !== '0' : true; + + const todaysMystery = getMysteryForWeekday(new Date(), initialLuminous); + + let initialMystery = (validMysteries as readonly string[]).includes(mysteryParam ?? '') + ? mysteryParam! + : todaysMystery; + + // If luminous is off and luminous mystery was selected, fall back + if (!initialLuminous && initialMystery === 'lichtreichen') { + initialMystery = todaysMystery; + } + // Fetch streak data for logged-in users via API route let streakData: StreakData | null = null; if (session?.user?.nickname) { @@ -24,6 +77,13 @@ export const load: PageServerLoad = async ({ fetch, locals }) => { return { mysteryDescriptions: mysteryVerseData, - streakData + streakData, + initialMystery, + todaysMystery, + initialLuminous, + initialLatin, + hasUrlMystery, + hasUrlLuminous, + hasUrlLatin }; }; diff --git a/src/routes/[faithLang=faithLang]/[rosary=rosaryLang]/+page.svelte b/src/routes/[faithLang=faithLang]/[rosary=rosaryLang]/+page.svelte index 6efa9cf..01e68c2 100644 --- a/src/routes/[faithLang=faithLang]/[rosary=rosaryLang]/+page.svelte +++ b/src/routes/[faithLang=faithLang]/[rosary=rosaryLang]/+page.svelte @@ -180,14 +180,14 @@ const mysteryTitlesEnglish = { ] }; -// Toggle for including Luminous mysteries -let includeLuminous = $state(true); +// Toggle for including Luminous mysteries (initialized from URL param or default) +let includeLuminous = $state(data.initialLuminous); // Flag to prevent saving before we've loaded from localStorage let hasLoadedFromStorage = false; // Create language context for prayer components (LanguageToggle will use this) -const langContext = createLanguageContext({ urlLang: data.lang }); +const langContext = createLanguageContext({ urlLang: data.lang, initialLatin: data.initialLatin }); // Update lang store when data.lang changes (e.g., after navigation) $effect(() => { @@ -268,10 +268,9 @@ function getMysteryForWeekday(date, includeLuminous) { } } -// Determine which mystery to use based on current weekday -const initialMystery = getMysteryForWeekday(new Date(), true); // Use literal true to avoid capturing reactive state -let selectedMystery = $state(initialMystery); -let todaysMystery = $state(initialMystery); // Track today's auto-selected mystery +// Use server-computed initial values (supports no-JS via URL params) +let selectedMystery = $state(data.initialMystery); +let todaysMystery = $state(data.todaysMystery); // Derive these values from selectedMystery so they update automatically let currentMysteries = $derived(mysteries[selectedMystery]); @@ -285,6 +284,23 @@ function selectMystery(mysteryType) { selectedMystery = mysteryType; } +// Build URLs preserving full state (for no-JS fallback) +function buildHref({ mystery = selectedMystery, luminous = includeLuminous, latin = data.initialLatin } = {}) { + const params = new URLSearchParams(); + params.set('mystery', mystery); + if (!luminous) params.set('luminous', '0'); + if (!latin) params.set('latin', '0'); + return `?${params.toString()}`; +} + +function mysteryHref(mystery) { + return buildHref({ mystery }); +} + +// Toggle hrefs navigate to opposite state (for no-JS self-submit) +let luminousToggleHref = $derived(buildHref({ luminous: !includeLuminous })); +let latinToggleHref = $derived(buildHref({ latin: !data.initialLatin })); + // When luminous toggle changes, update today's mystery and fix invalid selection $effect(() => { todaysMystery = getMysteryForWeekday(new Date(), includeLuminous); @@ -385,16 +401,24 @@ for (let d = 1; d < 5; d++) { const pos = sectionPositions; onMount(() => { - // Load toggle state from localStorage - const savedIncludeLuminous = localStorage.getItem('rosary_includeLuminous'); - - if (savedIncludeLuminous !== null) { - includeLuminous = savedIncludeLuminous === 'true'; + // Load toggle state from localStorage only if not overridden by URL params + if (!data.hasUrlLuminous) { + const savedIncludeLuminous = localStorage.getItem('rosary_includeLuminous'); + if (savedIncludeLuminous !== null) { + includeLuminous = savedIncludeLuminous === 'true'; + } } - // Recalculate mystery based on loaded includeLuminous value - todaysMystery = getMysteryForWeekday(new Date(), includeLuminous); - selectMystery(todaysMystery); + // If no mystery was specified in URL, recompute based on loaded preferences + if (!data.hasUrlMystery) { + todaysMystery = getMysteryForWeekday(new Date(), includeLuminous); + selectMystery(todaysMystery); + } + + // Clean up URL params after hydration (state is now in component state) + if (window.location.search) { + history.replaceState({}, '', window.location.pathname); + } // Now allow saving to localStorage hasLoadedFromStorage = true; @@ -1095,6 +1119,8 @@ h1 { align-items: center; gap: 1rem; position: relative; + text-decoration: none; + color: inherit; } @media(prefers-color-scheme: light) { @@ -1266,49 +1292,53 @@ h1 {

{labels.mysteries}

- +
- + - + - + {#if includeLuminous} - + {/if}
@@ -1324,14 +1354,19 @@ h1 {
- + - - + +
diff --git a/src/routes/[faithLang=faithLang]/angelus/+page.server.ts b/src/routes/[faithLang=faithLang]/angelus/+page.server.ts new file mode 100644 index 0000000..50bb977 --- /dev/null +++ b/src/routes/[faithLang=faithLang]/angelus/+page.server.ts @@ -0,0 +1,12 @@ +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ url }) => { + const latinParam = url.searchParams.get('latin'); + const hasUrlLatin = latinParam !== null; + const initialLatin = hasUrlLatin ? latinParam !== '0' : true; + + return { + initialLatin, + hasUrlLatin + }; +}; diff --git a/src/routes/[faithLang=faithLang]/angelus/+page.svelte b/src/routes/[faithLang=faithLang]/angelus/+page.svelte index 42553b5..6d6f49d 100644 --- a/src/routes/[faithLang=faithLang]/angelus/+page.svelte +++ b/src/routes/[faithLang=faithLang]/angelus/+page.svelte @@ -1,4 +1,5 @@ @@ -25,7 +36,11 @@

Angelus

- +