faith: progressive enhancement for all faith pages without JS
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:
2026-02-04 14:14:11 +01:00
parent 1c100a4534
commit 7d6a80442a
13 changed files with 347 additions and 90 deletions

View File

@@ -5,9 +5,14 @@
import { languageStore } from '$lib/stores/language'; import { languageStore } from '$lib/stores/language';
import { onMount } from 'svelte'; 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 currentPath = $state('');
let langButton: HTMLButtonElement; let langButton: HTMLButtonElement;
let langOptions: HTMLDivElement; let isOpen = $state(false);
// Faith subroute mappings // Faith subroute mappings
const faithSubroutes: Record<string, Record<string, string>> = { const faithSubroutes: Record<string, Record<string, string>> = {
@@ -34,30 +39,58 @@
}); });
function toggle_language_options(){ function toggle_language_options(){
if (langOptions) { isOpen = !isOpen;
langOptions.hidden = !langOptions.hidden;
}
} }
function convertFaithPath(path: string, targetLang: 'de' | 'en'): string { function convertFaithPath(path: string, targetLang: 'de' | 'en'): string {
// Extract the current base and subroute
const faithMatch = path.match(/^\/(glaube|faith)(\/(.+))?$/); const faithMatch = path.match(/^\/(glaube|faith)(\/(.+))?$/);
if (!faithMatch) return path; if (!faithMatch) return path;
const targetBase = targetLang === 'en' ? 'faith' : 'glaube'; 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) { if (!rest) {
// Main faith page
return `/${targetBase}`; return `/${targetBase}`;
} }
// Convert subroute // Split on / to convert just the first segment (gebete→prayers, etc.)
const convertedSubroute = faithSubroutes[targetLang][subroute] || subroute; const parts = rest.split('/');
return `/${targetBase}/${convertedSubroute}`; 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') { async function switchLanguage(lang: 'de' | 'en') {
isOpen = false;
// Update the shared language store immediately // Update the shared language store immediately
languageStore.set(lang); languageStore.set(lang);
@@ -117,7 +150,7 @@
onMount(() => { onMount(() => {
const handleClick = (e: MouseEvent) => { const handleClick = (e: MouseEvent) => {
if(langButton && !langButton.contains(e.target as Node)){ if(langButton && !langButton.contains(e.target as Node)){
if (langOptions) langOptions.hidden = true; isOpen = false;
} }
}; };
@@ -159,8 +192,18 @@
width: 10ch; width: 10ch;
padding: 0.5rem; padding: 0.5rem;
z-index: 1000; 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%; width: 100%;
background-color: transparent; background-color: transparent;
color: white; color: white;
@@ -171,32 +214,36 @@
cursor: pointer; cursor: pointer;
font-size: 1rem; font-size: 1rem;
text-align: left; text-align: left;
text-decoration: none;
transition: background-color 100ms; transition: background-color 100ms;
box-sizing: border-box;
} }
.language-options button:hover{ .language-options a:hover{
background-color: var(--nord2); background-color: var(--nord2);
} }
.language-options button.active{ .language-options a.active{
background-color: var(--nord14); background-color: var(--nord14);
} }
</style> </style>
<div class="language-selector"> <div class="language-selector">
<button bind:this={langButton} onclick={toggle_language_options} class="language-button"> <button bind:this={langButton} onclick={toggle_language_options} class="language-button">
{$languageStore.toUpperCase()} {displayLang.toUpperCase()}
</button> </button>
<div bind:this={langOptions} class="language-options" hidden> <div class="language-options" class:open={isOpen}>
<button <a
class:active={$languageStore === 'de'} href={dePath}
onclick={() => switchLanguage('de')} class:active={displayLang === 'de'}
onclick={(e) => { e.preventDefault(); switchLanguage('de'); }}
> >
DE DE
</button> </a>
<button <a
class:active={$languageStore === 'en'} href={enPath}
onclick={() => switchLanguage('en')} class:active={displayLang === 'en'}
onclick={(e) => { e.preventDefault(); switchLanguage('en'); }}
> >
EN EN
</button> </a>
</div> </div>
</div> </div>

View File

@@ -3,11 +3,15 @@
import { getLanguageContext } from '$lib/contexts/languageContext.js'; import { getLanguageContext } from '$lib/contexts/languageContext.js';
import Toggle from './Toggle.svelte'; 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) // Get the language context (must be created by parent page)
const { showLatin, lang } = getLanguageContext(); const { showLatin, lang } = getLanguageContext();
// Local state for the checkbox // Local state for the checkbox
let showBilingual = true; let showBilingual = initialLatin !== undefined ? initialLatin : true;
// Flag to prevent saving before we've loaded from localStorage // Flag to prevent saving before we've loaded from localStorage
let hasLoadedFromStorage = false; let hasLoadedFromStorage = false;
@@ -26,11 +30,13 @@
: 'Lateinisch und Deutsch anzeigen'; : 'Lateinisch und Deutsch anzeigen';
onMount(() => { onMount(() => {
// Load from localStorage // Only load from localStorage if no URL param was set
if (!hasUrlLatin) {
const saved = localStorage.getItem('rosary_showBilingual'); const saved = localStorage.getItem('rosary_showBilingual');
if (saved !== null) { if (saved !== null) {
showBilingual = saved === 'true'; showBilingual = saved === 'true';
} }
}
// Now allow saving // Now allow saving
hasLoadedFromStorage = true; hasLoadedFromStorage = true;
@@ -40,5 +46,6 @@
<Toggle <Toggle
bind:checked={showBilingual} bind:checked={showBilingual}
{label} {label}
{href}
accentColor="var(--nord14)" accentColor="var(--nord14)"
/> />

View File

@@ -1,5 +1,5 @@
<script lang="ts"> <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> </script>
<style> <style>
@@ -7,17 +7,20 @@
display: inline-flex; display: inline-flex;
} }
.toggle-wrapper label { .toggle-wrapper label,
.toggle-wrapper a {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.75rem; gap: 0.75rem;
cursor: pointer; cursor: pointer;
font-size: 0.95rem; font-size: 0.95rem;
color: var(--nord4); color: var(--nord4);
text-decoration: none;
} }
@media(prefers-color-scheme: light) { @media(prefers-color-scheme: light) {
.toggle-wrapper label { .toggle-wrapper label,
.toggle-wrapper a {
color: var(--nord2); color: var(--nord2);
} }
} }
@@ -26,7 +29,8 @@
user-select: none; user-select: none;
} }
/* iOS-style toggle switch */ /* iOS-style toggle switch — shared by checkbox and link variants */
.toggle-track,
.toggle-wrapper input[type="checkbox"] { .toggle-wrapper input[type="checkbox"] {
appearance: none; appearance: none;
-webkit-appearance: none; -webkit-appearance: none;
@@ -40,18 +44,22 @@
outline: none; outline: none;
border: none; border: none;
flex-shrink: 0; flex-shrink: 0;
display: inline-block;
} }
@media(prefers-color-scheme: light) { @media(prefers-color-scheme: light) {
.toggle-track,
.toggle-wrapper input[type="checkbox"] { .toggle-wrapper input[type="checkbox"] {
background: var(--nord4); background: var(--nord4);
} }
} }
.toggle-track.checked,
.toggle-wrapper input[type="checkbox"]:checked { .toggle-wrapper input[type="checkbox"]:checked {
background: var(--accent-color); background: var(--accent-color);
} }
.toggle-track::before,
.toggle-wrapper input[type="checkbox"]::before { .toggle-wrapper input[type="checkbox"]::before {
content: ''; content: '';
position: absolute; position: absolute;
@@ -65,14 +73,22 @@
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
} }
.toggle-track.checked::before,
.toggle-wrapper input[type="checkbox"]:checked::before { .toggle-wrapper input[type="checkbox"]:checked::before {
transform: translateX(20px); transform: translateX(20px);
} }
</style> </style>
<div class="toggle-wrapper" style="--accent-color: {accentColor}"> <div class="toggle-wrapper" style="--accent-color: {accentColor}">
{#if href}
<a {href} onclick={(e) => { e.preventDefault(); checked = !checked; }}>
<span class="toggle-track" class:checked></span>
<span>{label}</span>
</a>
{:else}
<label> <label>
<input type="checkbox" bind:checked /> <input type="checkbox" bind:checked />
<span>{label}</span> <span>{label}</span>
</label> </label>
{/if}
</div> </div>

View File

@@ -6,9 +6,10 @@ const LANGUAGE_CONTEXT_KEY = Symbol('language');
/** /**
* Creates or updates a language context for prayer components * Creates or updates a language context for prayer components
* @param {Object} options * @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) // Check if context already exists (e.g., during navigation)
if (hasContext(LANGUAGE_CONTEXT_KEY)) { if (hasContext(LANGUAGE_CONTEXT_KEY)) {
const existing = getContext(LANGUAGE_CONTEXT_KEY); const existing = getContext(LANGUAGE_CONTEXT_KEY);
@@ -17,7 +18,7 @@ export function createLanguageContext({ urlLang = 'de' } = {}) {
return existing; 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 const lang = writable(urlLang); // 'de' or 'en' based on URL
setContext(LANGUAGE_CONTEXT_KEY, { setContext(LANGUAGE_CONTEXT_KEY, {

View File

@@ -33,11 +33,11 @@ function isActive(path) {
{/snippet} {/snippet}
{#snippet language_selector_mobile()} {#snippet language_selector_mobile()}
<LanguageSelector /> <LanguageSelector lang={data.lang} />
{/snippet} {/snippet}
{#snippet language_selector_desktop()} {#snippet language_selector_desktop()}
<LanguageSelector /> <LanguageSelector lang={data.lang} />
{/snippet} {/snippet}
{#snippet right_side()} {#snippet right_side()}

View File

@@ -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
};
};

View File

@@ -1,4 +1,5 @@
<script> <script>
import { onMount } from 'svelte';
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { createLanguageContext } from "$lib/contexts/languageContext.js"; import { createLanguageContext } from "$lib/contexts/languageContext.js";
import "$lib/css/christ.css"; import "$lib/css/christ.css";
@@ -22,7 +23,7 @@
let { data } = $props(); let { data } = $props();
// Create language context for prayer components // Create language context for prayer components
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) // Update lang store when data.lang changes (e.g., after navigation)
$effect(() => { $effect(() => {
@@ -57,9 +58,19 @@
textMatch: isEnglish ? 'Match in prayer text' : 'Treffer im Gebetstext' textMatch: isEnglish ? 'Match in prayer text' : 'Treffer im Gebetstext'
}); });
// Search state // JS-only search (hidden without JS)
let jsEnabled = $state(false);
let searchQuery = $state(''); let searchQuery = $state('');
onMount(() => {
jsEnabled = true;
// Clean up URL params after hydration (state is now in component state)
if (window.location.search) {
history.replaceState({}, '', window.location.pathname);
}
});
// Match results: 'primary' (name/terms), 'secondary' (text only), or null (no match) // Match results: 'primary' (name/terms), 'secondary' (text only), or null (no match)
/** @type {Map<string, 'primary' | 'secondary'>} */ /** @type {Map<string, 'primary' | 'secondary'>} */
let matchResults = $state(/** @type {Map<string, 'primary' | 'secondary'>} */ (new Map())); let matchResults = $state(/** @type {Map<string, 'primary' | 'secondary'>} */ (new Map()));
@@ -165,6 +176,7 @@
// Helper to get match class for a prayer // Helper to get match class for a prayer
function getMatchClass(id) { function getMatchClass(id) {
if (!jsEnabled) return '';
const match = matchResults.get(id); const match = matchResults.get(id);
if (!searchQuery.trim()) return ''; if (!searchQuery.trim()) return '';
if (match === 'primary') return ''; if (match === 'primary') return '';
@@ -202,6 +214,9 @@
joseph: { bilingue: false }, joseph: { bilingue: false },
confiteor: { bilingue: true } confiteor: { bilingue: true }
}; };
// Toggle href for no-JS fallback (navigates to opposite latin state)
const latinToggleHref = $derived(data.initialLatin ? '?latin=0' : '?');
</script> </script>
<svelte:head> <svelte:head>
@@ -264,18 +279,33 @@ h1{
color: var(--nord0); color: var(--nord0);
} }
} }
/* Search is hidden without JS */
.js-only {
display: none;
}
.js-enabled .js-only {
display: block;
}
</style> </style>
<div class:js-enabled={jsEnabled}>
<h1>{labels.title}</h1> <h1>{labels.title}</h1>
<div class="toggle-controls"> <div class="toggle-controls">
<LanguageToggle /> <LanguageToggle
initialLatin={data.initialLatin}
hasUrlLatin={data.hasUrlLatin}
href={latinToggleHref}
/>
</div> </div>
<div class="js-only">
<SearchInput <SearchInput
bind:value={searchQuery} bind:value={searchQuery}
placeholder={labels.searchPlaceholder} placeholder={labels.searchPlaceholder}
clearTitle={labels.clearSearch} clearTitle={labels.clearSearch}
/> />
</div>
<div class="ccontainer"> <div class="ccontainer">
<div class=container> <div class=container>
@@ -317,3 +347,4 @@ h1{
{/each} {/each}
</div> </div>
</div> </div>
</div>

View File

@@ -17,12 +17,18 @@ const validSlugs = new Set([
'das-confiteor', 'the-confiteor' 'das-confiteor', 'the-confiteor'
]); ]);
export const load: PageServerLoad = async ({ params }) => { export const load: PageServerLoad = async ({ params, url }) => {
if (!validSlugs.has(params.prayer)) { if (!validSlugs.has(params.prayer)) {
throw error(404, 'Prayer not found'); throw error(404, 'Prayer not found');
} }
const latinParam = url.searchParams.get('latin');
const hasUrlLatin = latinParam !== null;
const initialLatin = hasUrlLatin ? latinParam !== '0' : true;
return { return {
prayer: params.prayer prayer: params.prayer,
initialLatin,
hasUrlLatin
}; };
}; };

View File

@@ -1,4 +1,5 @@
<script> <script>
import { onMount } from 'svelte';
import { createLanguageContext } from "$lib/contexts/languageContext.js"; import { createLanguageContext } from "$lib/contexts/languageContext.js";
import "$lib/css/christ.css"; import "$lib/css/christ.css";
import "$lib/css/nordtheme.css"; import "$lib/css/nordtheme.css";
@@ -18,7 +19,7 @@
let { data } = $props(); let { data } = $props();
const langContext = createLanguageContext({ urlLang: data.lang }); const langContext = createLanguageContext({ urlLang: data.lang, initialLatin: data.initialLatin });
$effect(() => { $effect(() => {
langContext.lang.set(data.lang); langContext.lang.set(data.lang);
@@ -59,6 +60,16 @@
const gloriaIntro = $derived(isEnglish const gloriaIntro = $derived(isEnglish
? 'This ancient hymn begins with the words the angels used to celebrate the newborn Savior. It first praises God the Father, then God the Son; it concludes with homage to the Most Holy Trinity, during which one makes the sign of the cross.' ? 'This ancient hymn begins with the words the angels used to celebrate the newborn Savior. It first praises God the Father, then God the Son; it concludes with homage to the Most Holy Trinity, during which one makes the sign of the cross.'
: 'Der uralte Gesang beginnt mit den Worten, mit denen die Engelscharen den neugeborenen Welterlöser feierten. Er preist zunächst Gott Vater, dann Gott Sohn; er schliesst mit einer Huldigung an die Heiligste Dreifaltigkeit, wobei man sich mit dem grossen Kreuze bezeichnet.'); : 'Der uralte Gesang beginnt mit den Worten, mit denen die Engelscharen den neugeborenen Welterlöser feierten. Er preist zunächst Gott Vater, dann Gott Sohn; er schliesst mit einer Huldigung an die Heiligste Dreifaltigkeit, wobei man sich mit dem grossen Kreuze bezeichnet.');
// Toggle href for no-JS fallback (navigates to opposite latin state)
const latinToggleHref = $derived(data.initialLatin ? '?latin=0' : '?');
onMount(() => {
// Clean up URL params after hydration (state is now in component state)
if (window.location.search) {
history.replaceState({}, '', window.location.pathname);
}
});
</script> </script>
<svelte:head> <svelte:head>
@@ -121,7 +132,11 @@ h1 {
<h1>{prayerName}</h1> <h1>{prayerName}</h1>
<div class="toggle-controls"> <div class="toggle-controls">
<LanguageToggle /> <LanguageToggle
initialLatin={data.initialLatin}
hasUrlLatin={data.hasUrlLatin}
href={latinToggleHref}
/>
</div> </div>
<div class="gebet-wrapper"> <div class="gebet-wrapper">

View File

@@ -6,9 +6,62 @@ interface StreakData {
lastPrayed: string | null; 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<number, string> = {
0: 'glorreichen',
1: 'freudenreich',
2: 'schmerzhaften',
3: 'glorreichen',
4: 'lichtreichen',
5: 'schmerzhaften',
6: 'freudenreich'
};
return schedule[dayOfWeek];
} else {
const schedule: Record<number, string> = {
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(); 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 // Fetch streak data for logged-in users via API route
let streakData: StreakData | null = null; let streakData: StreakData | null = null;
if (session?.user?.nickname) { if (session?.user?.nickname) {
@@ -24,6 +77,13 @@ export const load: PageServerLoad = async ({ fetch, locals }) => {
return { return {
mysteryDescriptions: mysteryVerseData, mysteryDescriptions: mysteryVerseData,
streakData streakData,
initialMystery,
todaysMystery,
initialLuminous,
initialLatin,
hasUrlMystery,
hasUrlLuminous,
hasUrlLatin
}; };
}; };

View File

@@ -180,14 +180,14 @@ const mysteryTitlesEnglish = {
] ]
}; };
// Toggle for including Luminous mysteries // Toggle for including Luminous mysteries (initialized from URL param or default)
let includeLuminous = $state(true); let includeLuminous = $state(data.initialLuminous);
// Flag to prevent saving before we've loaded from localStorage // Flag to prevent saving before we've loaded from localStorage
let hasLoadedFromStorage = false; let hasLoadedFromStorage = false;
// Create language context for prayer components (LanguageToggle will use this) // 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) // Update lang store when data.lang changes (e.g., after navigation)
$effect(() => { $effect(() => {
@@ -268,10 +268,9 @@ function getMysteryForWeekday(date, includeLuminous) {
} }
} }
// Determine which mystery to use based on current weekday // Use server-computed initial values (supports no-JS via URL params)
const initialMystery = getMysteryForWeekday(new Date(), true); // Use literal true to avoid capturing reactive state let selectedMystery = $state(data.initialMystery);
let selectedMystery = $state(initialMystery); let todaysMystery = $state(data.todaysMystery);
let todaysMystery = $state(initialMystery); // Track today's auto-selected mystery
// Derive these values from selectedMystery so they update automatically // Derive these values from selectedMystery so they update automatically
let currentMysteries = $derived(mysteries[selectedMystery]); let currentMysteries = $derived(mysteries[selectedMystery]);
@@ -285,6 +284,23 @@ function selectMystery(mysteryType) {
selectedMystery = 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 // When luminous toggle changes, update today's mystery and fix invalid selection
$effect(() => { $effect(() => {
todaysMystery = getMysteryForWeekday(new Date(), includeLuminous); todaysMystery = getMysteryForWeekday(new Date(), includeLuminous);
@@ -385,16 +401,24 @@ for (let d = 1; d < 5; d++) {
const pos = sectionPositions; const pos = sectionPositions;
onMount(() => { onMount(() => {
// Load toggle state from localStorage // Load toggle state from localStorage only if not overridden by URL params
if (!data.hasUrlLuminous) {
const savedIncludeLuminous = localStorage.getItem('rosary_includeLuminous'); const savedIncludeLuminous = localStorage.getItem('rosary_includeLuminous');
if (savedIncludeLuminous !== null) { if (savedIncludeLuminous !== null) {
includeLuminous = savedIncludeLuminous === 'true'; includeLuminous = savedIncludeLuminous === 'true';
} }
}
// Recalculate mystery based on loaded includeLuminous value // If no mystery was specified in URL, recompute based on loaded preferences
if (!data.hasUrlMystery) {
todaysMystery = getMysteryForWeekday(new Date(), includeLuminous); todaysMystery = getMysteryForWeekday(new Date(), includeLuminous);
selectMystery(todaysMystery); 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 // Now allow saving to localStorage
hasLoadedFromStorage = true; hasLoadedFromStorage = true;
@@ -1095,6 +1119,8 @@ h1 {
align-items: center; align-items: center;
gap: 1rem; gap: 1rem;
position: relative; position: relative;
text-decoration: none;
color: inherit;
} }
@media(prefers-color-scheme: light) { @media(prefers-color-scheme: light) {
@@ -1266,49 +1292,53 @@ h1 {
<h2 style="text-align:center;">{labels.mysteries}</h2> <h2 style="text-align:center;">{labels.mysteries}</h2>
<!-- Mystery Selector --> <!-- Mystery Selector (links for no-JS, enhanced with onclick for JS) -->
<div class="mystery-selector" class:four-mysteries={includeLuminous}> <div class="mystery-selector" class:four-mysteries={includeLuminous}>
<button <a
class="mystery-button" class="mystery-button"
class:selected={selectedMystery === 'freudenreich'} class:selected={selectedMystery === 'freudenreich'}
onclick={() => selectMystery('freudenreich')} href={mysteryHref('freudenreich')}
onclick={(e) => { e.preventDefault(); selectMystery('freudenreich'); }}
> >
{#if todaysMystery === 'freudenreich'} {#if todaysMystery === 'freudenreich'}
<span class="today-badge">{labels.today}</span> <span class="today-badge">{labels.today}</span>
{/if} {/if}
<MysteryIcon type="joyful" /> <MysteryIcon type="joyful" />
<h3>{labels.joyful}</h3> <h3>{labels.joyful}</h3>
</button> </a>
<button <a
class="mystery-button" class="mystery-button"
class:selected={selectedMystery === 'schmerzhaften'} class:selected={selectedMystery === 'schmerzhaften'}
onclick={() => selectMystery('schmerzhaften')} href={mysteryHref('schmerzhaften')}
onclick={(e) => { e.preventDefault(); selectMystery('schmerzhaften'); }}
> >
{#if todaysMystery === 'schmerzhaften'} {#if todaysMystery === 'schmerzhaften'}
<span class="today-badge">{labels.today}</span> <span class="today-badge">{labels.today}</span>
{/if} {/if}
<MysteryIcon type="sorrowful" /> <MysteryIcon type="sorrowful" />
<h3>{labels.sorrowful}</h3> <h3>{labels.sorrowful}</h3>
</button> </a>
<button <a
class="mystery-button" class="mystery-button"
class:selected={selectedMystery === 'glorreichen'} class:selected={selectedMystery === 'glorreichen'}
onclick={() => selectMystery('glorreichen')} href={mysteryHref('glorreichen')}
onclick={(e) => { e.preventDefault(); selectMystery('glorreichen'); }}
> >
{#if todaysMystery === 'glorreichen'} {#if todaysMystery === 'glorreichen'}
<span class="today-badge">{labels.today}</span> <span class="today-badge">{labels.today}</span>
{/if} {/if}
<MysteryIcon type="glorious" /> <MysteryIcon type="glorious" />
<h3>{labels.glorious}</h3> <h3>{labels.glorious}</h3>
</button> </a>
{#if includeLuminous} {#if includeLuminous}
<button <a
class="mystery-button" class="mystery-button"
class:selected={selectedMystery === 'lichtreichen'} class:selected={selectedMystery === 'lichtreichen'}
onclick={() => selectMystery('lichtreichen')} href={mysteryHref('lichtreichen')}
onclick={(e) => { e.preventDefault(); selectMystery('lichtreichen'); }}
> >
{#if todaysMystery === 'lichtreichen'} {#if todaysMystery === 'lichtreichen'}
<span class="today-badge">{labels.today}</span> <span class="today-badge">{labels.today}</span>
@@ -1316,7 +1346,7 @@ h1 {
<MysteryIcon type="luminous" /> <MysteryIcon type="luminous" />
<h3>{labels.luminous}</h3> <h3>{labels.luminous}</h3>
</button> </a>
{/if} {/if}
</div> </div>
@@ -1324,14 +1354,19 @@ h1 {
<div class="controls-row"> <div class="controls-row">
<StreakCounter streakData={data.streakData} lang={data.lang} /> <StreakCounter streakData={data.streakData} lang={data.lang} />
<div class="toggle-controls"> <div class="toggle-controls">
<!-- Luminous Mysteries Toggle --> <!-- Luminous Mysteries Toggle (link for no-JS, enhanced with onclick for JS) -->
<Toggle <Toggle
bind:checked={includeLuminous} bind:checked={includeLuminous}
label={labels.includeLuminous} label={labels.includeLuminous}
href={luminousToggleHref}
/> />
<!-- Language Toggle --> <!-- Language Toggle (link for no-JS, enhanced with onclick for JS) -->
<LanguageToggle /> <LanguageToggle
initialLatin={data.initialLatin}
hasUrlLatin={data.hasUrlLatin}
href={latinToggleHref}
/>
</div> </div>
</div> </div>

View File

@@ -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
};
};

View File

@@ -1,4 +1,5 @@
<script> <script>
import { onMount } from 'svelte';
import { createLanguageContext } from "$lib/contexts/languageContext.js"; import { createLanguageContext } from "$lib/contexts/languageContext.js";
import LanguageToggle from "$lib/components/LanguageToggle.svelte"; import LanguageToggle from "$lib/components/LanguageToggle.svelte";
import Prayer from '$lib/components/prayers/Prayer.svelte'; import Prayer from '$lib/components/prayers/Prayer.svelte';
@@ -9,12 +10,22 @@
let { data } = $props(); let { data } = $props();
// Create language context for prayer components // Create language context for prayer components
const langContext = createLanguageContext({ urlLang: data.lang }); const langContext = createLanguageContext({ urlLang: data.lang, initialLatin: data.initialLatin });
// Toggle href for no-JS fallback (navigates to opposite latin state)
const latinToggleHref = $derived(data.initialLatin ? '?latin=0' : '?');
// Update lang store when data.lang changes (e.g., after navigation) // Update lang store when data.lang changes (e.g., after navigation)
$effect(() => { $effect(() => {
langContext.lang.set(data.lang); langContext.lang.set(data.lang);
}); });
onMount(() => {
// Clean up URL params after hydration (state is now in component state)
if (window.location.search) {
history.replaceState({}, '', window.location.pathname);
}
});
</script> </script>
<svelte:head> <svelte:head>
@@ -25,7 +36,11 @@
<div class="angelus-page"> <div class="angelus-page">
<h1>Angelus</h1> <h1>Angelus</h1>
<div class="toggle-controls"> <div class="toggle-controls">
<LanguageToggle /> <LanguageToggle
initialLatin={data.initialLatin}
hasUrlLatin={data.hasUrlLatin}
href={latinToggleHref}
/>
</div> </div>
<div class="prayers-content"> <div class="prayers-content">