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 { 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>
|
||||||
|
|||||||
@@ -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,10 +30,12 @@
|
|||||||
: 'Lateinisch und Deutsch anzeigen';
|
: 'Lateinisch und Deutsch anzeigen';
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
// Load from localStorage
|
// Only load from localStorage if no URL param was set
|
||||||
const saved = localStorage.getItem('rosary_showBilingual');
|
if (!hasUrlLatin) {
|
||||||
if (saved !== null) {
|
const saved = localStorage.getItem('rosary_showBilingual');
|
||||||
showBilingual = saved === 'true';
|
if (saved !== null) {
|
||||||
|
showBilingual = saved === 'true';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Now allow saving
|
// Now allow saving
|
||||||
@@ -40,5 +46,6 @@
|
|||||||
<Toggle
|
<Toggle
|
||||||
bind:checked={showBilingual}
|
bind:checked={showBilingual}
|
||||||
{label}
|
{label}
|
||||||
|
{href}
|
||||||
accentColor="var(--nord14)"
|
accentColor="var(--nord14)"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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}">
|
||||||
<label>
|
{#if href}
|
||||||
<input type="checkbox" bind:checked />
|
<a {href} onclick={(e) => { e.preventDefault(); checked = !checked; }}>
|
||||||
<span>{label}</span>
|
<span class="toggle-track" class:checked></span>
|
||||||
</label>
|
<span>{label}</span>
|
||||||
|
</a>
|
||||||
|
{:else}
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" bind:checked />
|
||||||
|
<span>{label}</span>
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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, {
|
||||||
|
|||||||
@@ -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()}
|
||||||
|
|||||||
@@ -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
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
|
||||||
<SearchInput
|
<div class="js-only">
|
||||||
bind:value={searchQuery}
|
<SearchInput
|
||||||
placeholder={labels.searchPlaceholder}
|
bind:value={searchQuery}
|
||||||
clearTitle={labels.clearSearch}
|
placeholder={labels.searchPlaceholder}
|
||||||
/>
|
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>
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
|
||||||
const savedIncludeLuminous = localStorage.getItem('rosary_includeLuminous');
|
if (!data.hasUrlLuminous) {
|
||||||
|
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
|
||||||
todaysMystery = getMysteryForWeekday(new Date(), includeLuminous);
|
if (!data.hasUrlMystery) {
|
||||||
selectMystery(todaysMystery);
|
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
|
// 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>
|
||||||
|
|
||||||
|
|||||||
12
src/routes/[faithLang=faithLang]/angelus/+page.server.ts
Normal file
12
src/routes/[faithLang=faithLang]/angelus/+page.server.ts
Normal 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
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user