feat: add Latin route support, Angelus/Regina Caeli streak counter, and Eastertide liturgical adjustments
All checks were successful
CI / update (push) Successful in 4m58s
All checks were successful
CI / update (push) Successful in 4m58s
- Add /fides route with Latin-only mode for all faith pages (rosary, prayers, individual prayers) - Add LA option to language selector for faith routes - Add Angelus/Regina Caeli streak counter with 3x daily tracking (morning/noon/evening bitmask) - Store streak data in localStorage (offline) and MongoDB (logged-in sync) - Show Annunciation/Coronation paintings via StickyImage with artist captions - Switch Angelus↔Regina Caeli in header and landing page based on Eastertide - Fix Eastertide to end at Ascension (+39 days) instead of Pentecost - Fix Lent Holy Saturday off-by-one with toMidnight() normalization - Fix non-reactive typedLang in faith layout - Fix header nav highlighting: exclude angelus/regina-caeli from prayers active state
This commit is contained in:
@@ -6,7 +6,7 @@
|
||||
import { convertFitnessPath } from '$lib/js/fitnessI18n';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let { lang = undefined }: { lang?: 'de' | 'en' } = $props();
|
||||
let { lang = undefined }: { lang?: 'de' | 'en' | 'la' } = $props();
|
||||
|
||||
// Use prop for display if provided (SSR-safe), otherwise fall back to store
|
||||
const displayLang = $derived(lang ?? $languageStore);
|
||||
@@ -17,10 +17,17 @@
|
||||
|
||||
// Faith subroute mappings
|
||||
const faithSubroutes: Record<string, Record<string, string>> = {
|
||||
en: { gebete: 'prayers', rosenkranz: 'rosary' },
|
||||
de: { prayers: 'gebete', rosary: 'rosenkranz' }
|
||||
en: { gebete: 'prayers', rosenkranz: 'rosary', rosarium: 'rosary', orationes: 'prayers' },
|
||||
de: { prayers: 'gebete', rosary: 'rosenkranz', rosarium: 'rosenkranz', orationes: 'gebete' },
|
||||
la: { prayers: 'orationes', gebete: 'orationes', rosary: 'rosarium', rosenkranz: 'rosarium' }
|
||||
};
|
||||
|
||||
// Whether the current page is a faith route (show LA option)
|
||||
const faithPath = $derived(currentPath || $page.url.pathname);
|
||||
const isFaithRoute = $derived(
|
||||
faithPath.startsWith('/glaube') || faithPath.startsWith('/faith') || faithPath.startsWith('/fides')
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
// Update current language and path when page changes (reactive to browser navigation)
|
||||
const path = $page.url.pathname;
|
||||
@@ -30,6 +37,8 @@
|
||||
languageStore.set('en');
|
||||
} else if (path.startsWith('/rezepte') || path.startsWith('/glaube')) {
|
||||
languageStore.set('de');
|
||||
} else if (path.startsWith('/fides')) {
|
||||
// Latin route — no language switching needed
|
||||
} else if (path.startsWith('/fitness')) {
|
||||
// Language is determined by sub-route slugs; don't override store
|
||||
} else {
|
||||
@@ -45,11 +54,11 @@
|
||||
isOpen = !isOpen;
|
||||
}
|
||||
|
||||
function convertFaithPath(path: string, targetLang: 'de' | 'en'): string {
|
||||
const faithMatch = path.match(/^\/(glaube|faith)(\/(.+))?$/);
|
||||
function convertFaithPath(path: string, targetLang: 'de' | 'en' | 'la'): string {
|
||||
const faithMatch = path.match(/^\/(glaube|faith|fides)(\/(.+))?$/);
|
||||
if (!faithMatch) return path;
|
||||
|
||||
const targetBase = targetLang === 'en' ? 'faith' : 'glaube';
|
||||
const targetBase = targetLang === 'la' ? 'fides' : targetLang === 'en' ? 'faith' : 'glaube';
|
||||
const rest = faithMatch[3]; // e.g., "gebete", "rosenkranz/sub", "angelus"
|
||||
|
||||
if (!rest) {
|
||||
@@ -63,14 +72,14 @@
|
||||
}
|
||||
|
||||
// Compute target paths for each language (used as href for no-JS)
|
||||
function computeTargetPath(targetLang: 'de' | 'en'): string {
|
||||
function computeTargetPath(targetLang: 'de' | 'en' | 'la'): string {
|
||||
const path = currentPath || $page.url.pathname;
|
||||
|
||||
if (path.startsWith('/glaube') || path.startsWith('/faith')) {
|
||||
if (path.startsWith('/glaube') || path.startsWith('/faith') || path.startsWith('/fides')) {
|
||||
return convertFaithPath(path, targetLang);
|
||||
}
|
||||
|
||||
if (path.startsWith('/fitness')) {
|
||||
if (path.startsWith('/fitness') && targetLang !== 'la') {
|
||||
return convertFitnessPath(path, targetLang);
|
||||
}
|
||||
|
||||
@@ -94,15 +103,18 @@
|
||||
|
||||
const dePath = $derived(computeTargetPath('de'));
|
||||
const enPath = $derived(computeTargetPath('en'));
|
||||
const laPath = $derived(computeTargetPath('la'));
|
||||
|
||||
async function switchLanguage(lang: 'de' | 'en') {
|
||||
async function switchLanguage(lang: 'de' | 'en' | 'la') {
|
||||
isOpen = false;
|
||||
|
||||
// Update the shared language store immediately
|
||||
languageStore.set(lang);
|
||||
// Update the shared language store immediately (la not tracked in store)
|
||||
if (lang !== 'la') {
|
||||
languageStore.set(lang);
|
||||
}
|
||||
|
||||
// Store preference
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
if (typeof localStorage !== 'undefined' && lang !== 'la') {
|
||||
localStorage.setItem('preferredLanguage', lang);
|
||||
}
|
||||
|
||||
@@ -112,14 +124,14 @@
|
||||
// For pages that handle their own translations inline (not recipe/faith routes),
|
||||
// dispatch event and stay on the page
|
||||
if (!path.startsWith('/rezepte') && !path.startsWith('/recipes')
|
||||
&& !path.startsWith('/glaube') && !path.startsWith('/faith')
|
||||
&& !path.startsWith('/glaube') && !path.startsWith('/faith') && !path.startsWith('/fides')
|
||||
&& !path.startsWith('/fitness')) {
|
||||
window.dispatchEvent(new CustomEvent('languagechange', { detail: { lang } }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle faith pages
|
||||
if (path.startsWith('/glaube') || path.startsWith('/faith')) {
|
||||
if (path.startsWith('/glaube') || path.startsWith('/faith') || path.startsWith('/fides')) {
|
||||
const newPath = convertFaithPath(path, lang);
|
||||
await goto(newPath);
|
||||
return;
|
||||
@@ -313,6 +325,15 @@
|
||||
>
|
||||
EN
|
||||
</a>
|
||||
{#if isFaithRoute}
|
||||
<a
|
||||
href={laPath}
|
||||
class:active={displayLang === 'la'}
|
||||
onclick={(e) => { e.preventDefault(); switchLanguage('la'); }}
|
||||
>
|
||||
LA
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
273
src/lib/components/faith/AngelusStreakCounter.svelte
Normal file
273
src/lib/components/faith/AngelusStreakCounter.svelte
Normal file
@@ -0,0 +1,273 @@
|
||||
<script lang="ts">
|
||||
import { browser } from '$app/environment';
|
||||
import { getAngelusStreak, getCurrentTimeSlot, type TimeSlot } from '$lib/stores/angelusStreak.svelte';
|
||||
import StreakAura from '$lib/components/faith/StreakAura.svelte';
|
||||
import { Coffee, Sun, Moon } from 'lucide-svelte';
|
||||
import { tick, onMount } from 'svelte';
|
||||
|
||||
let burst = $state(false);
|
||||
let store = $state<ReturnType<typeof getAngelusStreak> | null>(null);
|
||||
let selectedSlot = $state<TimeSlot>('morning');
|
||||
|
||||
interface Props {
|
||||
streakData?: { streak: number; lastComplete: string | null; todayPrayed: number; todayDate: string | null } | null;
|
||||
lang?: 'de' | 'en' | 'la';
|
||||
isLoggedIn?: boolean;
|
||||
}
|
||||
|
||||
let { streakData = null, lang = 'de', isLoggedIn = false }: Props = $props();
|
||||
|
||||
const isEnglish = $derived(lang === 'en');
|
||||
const isLatin = $derived(lang === 'la');
|
||||
|
||||
// Display values: store when available, SSR fallback
|
||||
const displayStreak = $derived(store?.streak ?? streakData?.streak ?? 0);
|
||||
const todayPrayed = $derived(store?.todayPrayed ?? (() => {
|
||||
if (!streakData || streakData.todayDate !== new Date().toISOString().split('T')[0]) return 0;
|
||||
return streakData.todayPrayed;
|
||||
})());
|
||||
const todayComplete = $derived(todayPrayed === 7);
|
||||
const selectedSlotPrayed = $derived(isSlotPrayed(selectedSlot));
|
||||
|
||||
// Count bits set in todayPrayed for fractional display
|
||||
const partialCount = $derived(
|
||||
((todayPrayed & 1) + ((todayPrayed >> 1) & 1) + ((todayPrayed >> 2) & 1))
|
||||
);
|
||||
const showFraction = $derived(partialCount > 0 && partialCount < 3);
|
||||
|
||||
const autoSlot = $derived(browser ? getCurrentTimeSlot() : 'morning' as TimeSlot);
|
||||
|
||||
// Auto-select the current time slot initially, and advance to next unprayed slot
|
||||
$effect(() => {
|
||||
if (!isSlotPrayed(autoSlot)) {
|
||||
selectedSlot = autoSlot;
|
||||
} else {
|
||||
// Find next unprayed slot
|
||||
const order: TimeSlot[] = ['morning', 'noon', 'evening'];
|
||||
const next = order.find(s => !isSlotPrayed(s));
|
||||
if (next) selectedSlot = next;
|
||||
}
|
||||
});
|
||||
|
||||
const slots: { key: TimeSlot; icon: typeof Coffee; color: string }[] = [
|
||||
{ key: 'morning', icon: Coffee, color: 'var(--nord13)' },
|
||||
{ key: 'noon', icon: Sun, color: 'var(--nord12)' },
|
||||
{ key: 'evening', icon: Moon, color: 'var(--nord15)' }
|
||||
];
|
||||
|
||||
const labels = $derived({
|
||||
days: isLatin ? 'Dies' : isEnglish ? (displayStreak === 1 && !showFraction ? 'Day' : 'Days') : (displayStreak === 1 && !showFraction ? 'Tag' : 'Tage'),
|
||||
pray: isLatin ? 'Ora' : isEnglish ? 'Pray' : 'Beten',
|
||||
done: isLatin ? 'Hodie completa' : isEnglish ? 'Done today' : 'Heute fertig',
|
||||
morning: isLatin ? 'Mane' : isEnglish ? 'Morning' : 'Morgens',
|
||||
noon: isLatin ? 'Meridie' : isEnglish ? 'Noon' : 'Mittags',
|
||||
evening: isLatin ? 'Vespere' : isEnglish ? 'Evening' : 'Abends',
|
||||
ariaLabel: isLatin ? 'Orationem notatam fac' : isEnglish ? 'Mark prayer as prayed' : 'Gebet als gebetet markieren'
|
||||
});
|
||||
|
||||
const slotLabels: Record<TimeSlot, string> = $derived({
|
||||
morning: labels.morning,
|
||||
noon: labels.noon,
|
||||
evening: labels.evening
|
||||
});
|
||||
|
||||
function isSlotPrayed(slot: TimeSlot): boolean {
|
||||
const bits: Record<TimeSlot, number> = { morning: 1, noon: 2, evening: 4 };
|
||||
return (todayPrayed & bits[slot]) !== 0;
|
||||
}
|
||||
|
||||
function selectSlot(slot: TimeSlot) {
|
||||
if (!isSlotPrayed(slot)) {
|
||||
selectedSlot = slot;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const s = getAngelusStreak();
|
||||
s.initWithServerData(streakData, isLoggedIn);
|
||||
store = s;
|
||||
});
|
||||
|
||||
async function pray() {
|
||||
if (!store || isSlotPrayed(selectedSlot)) return;
|
||||
const completed = await store.recordPrayer(selectedSlot);
|
||||
if (completed) {
|
||||
burst = true;
|
||||
await tick();
|
||||
setTimeout(() => burst = false, 700);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="angelus-streak">
|
||||
<div class="streak-display">
|
||||
<StreakAura value={displayStreak} {burst}>
|
||||
<span class="number">
|
||||
{displayStreak}{#if showFraction}<span class="fraction"><span class="num">{partialCount}</span><span class="slash">/</span><span class="den">3</span></span>{/if}
|
||||
</span>
|
||||
</StreakAura>
|
||||
<span class="streak-label">{labels.days}</span>
|
||||
</div>
|
||||
|
||||
<div class="prayer-controls">
|
||||
<div class="time-slots">
|
||||
{#each slots as slot}
|
||||
<button
|
||||
class="slot-dot"
|
||||
class:prayed={isSlotPrayed(slot.key)}
|
||||
class:selected={slot.key === selectedSlot && !isSlotPrayed(slot.key)}
|
||||
disabled={isSlotPrayed(slot.key)}
|
||||
title={slotLabels[slot.key]}
|
||||
aria-label={slotLabels[slot.key]}
|
||||
style="--slot-color: {slot.color}"
|
||||
onclick={() => selectSlot(slot.key)}
|
||||
>
|
||||
<slot.icon size={18} />
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<form method="POST" action="?/pray-angelus" onsubmit={(e) => { e.preventDefault(); pray(); }}>
|
||||
<input type="hidden" name="time" value={selectedSlot} />
|
||||
<button
|
||||
class="pray-button"
|
||||
type="submit"
|
||||
disabled={todayComplete || selectedSlotPrayed}
|
||||
aria-label={labels.ariaLabel}
|
||||
>
|
||||
{#if todayComplete}
|
||||
{labels.done}
|
||||
{:else}
|
||||
{labels.pray}
|
||||
{/if}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.angelus-streak {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
padding: 1rem 1.5rem;
|
||||
background: var(--color-surface);
|
||||
border-radius: 12px;
|
||||
width: fit-content;
|
||||
margin: 1.5rem auto;
|
||||
}
|
||||
|
||||
.streak-display {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.streak-label {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.number {
|
||||
position: relative;
|
||||
z-index: 5;
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--nord13);
|
||||
}
|
||||
|
||||
.fraction {
|
||||
font-size: 0.45em;
|
||||
font-weight: 600;
|
||||
position: relative;
|
||||
top: -0.15em;
|
||||
margin-left: 0.05em;
|
||||
}
|
||||
|
||||
.fraction .num {
|
||||
position: relative;
|
||||
top: -0.35em;
|
||||
}
|
||||
|
||||
.fraction .slash {
|
||||
margin: 0 0.01em;
|
||||
}
|
||||
|
||||
.fraction .den {
|
||||
position: relative;
|
||||
top: 0.15em;
|
||||
}
|
||||
|
||||
.prayer-controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.time-slots {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.slot-dot {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--color-border);
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
transition: all 0.3s ease;
|
||||
padding: 0;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.slot-dot.selected {
|
||||
border-color: var(--slot-color);
|
||||
color: var(--slot-color);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.slot-dot.prayed {
|
||||
border-color: var(--slot-color);
|
||||
background: var(--slot-color);
|
||||
color: white;
|
||||
cursor: default;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.slot-dot:hover:not(:disabled) {
|
||||
border-color: var(--slot-color);
|
||||
opacity: 0.85;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.pray-button {
|
||||
padding: 0.6rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: var(--blue);
|
||||
color: white;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.pray-button:hover:not(:disabled) {
|
||||
background: var(--nord10);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.pray-button:disabled {
|
||||
background: var(--blue);
|
||||
cursor: default;
|
||||
opacity: 0.5;
|
||||
}
|
||||
</style>
|
||||
@@ -8,7 +8,7 @@
|
||||
* - 'layout': flex row on desktop (image sticky right, content left). Use as page-level wrapper.
|
||||
* - 'overlay': image floats over the page (fixed position, IntersectionObserver show/hide). Use when nested inside existing layouts.
|
||||
*/
|
||||
let { src, alt = '', mode = 'layout', children } = $props();
|
||||
let { src, alt = '', mode = 'layout', caption = '', children } = $props();
|
||||
|
||||
/** @type {HTMLDivElement | null} */
|
||||
let pipEl = $state(null);
|
||||
@@ -86,6 +86,9 @@
|
||||
<div class="sticky-image-layout" class:overlay={mode === 'overlay'}>
|
||||
<div class="image-wrap-desktop">
|
||||
<img {src} {alt}>
|
||||
{#if caption}
|
||||
<figcaption class="image-caption">{@html caption}</figcaption>
|
||||
{/if}
|
||||
</div>
|
||||
<PipImage {pip} {src} {alt} visible={inView} bind:el={pipEl} />
|
||||
<div class="content-scroll" bind:this={contentEl}>
|
||||
@@ -107,6 +110,11 @@
|
||||
.image-wrap-desktop {
|
||||
display: none;
|
||||
}
|
||||
.image-caption {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-secondary);
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
.content-scroll {
|
||||
width: 100%;
|
||||
max-width: 700px;
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
<script lang="ts">
|
||||
import FireEffect from './FireEffect.svelte';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
value?: number;
|
||||
burst?: boolean;
|
||||
children?: Snippet;
|
||||
}
|
||||
|
||||
let { value = 0, burst = false }: Props = $props();
|
||||
let { value = 0, burst = false, children }: Props = $props();
|
||||
|
||||
// Latch burst so the FireEffect stays mounted for the full animation
|
||||
let showBurst = $state(false);
|
||||
@@ -61,7 +63,11 @@
|
||||
<FireEffect holy={phase>=4} burst fire={phase>=3}/>
|
||||
{/if}
|
||||
|
||||
<span class="number">{value}</span>
|
||||
{#if children}
|
||||
{@render children()}
|
||||
{:else}
|
||||
<span class="number">{value}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -9,7 +9,7 @@ let streak = $state<ReturnType<typeof getRosaryStreak> | null>(null);
|
||||
|
||||
interface Props {
|
||||
streakData?: { length: number; lastPrayed: string | null } | null;
|
||||
lang?: 'de' | 'en';
|
||||
lang?: 'de' | 'en' | 'la';
|
||||
isLoggedIn?: boolean;
|
||||
}
|
||||
|
||||
@@ -22,11 +22,12 @@ let displayLength = $derived(streak?.length ?? streakData?.length ?? 0);
|
||||
let prayedToday = $derived(streak?.prayedToday ?? (streakData?.lastPrayed === new Date().toISOString().split('T')[0]));
|
||||
|
||||
// Labels need to come after displayLength since they depend on it
|
||||
const isLatin = $derived(lang === 'la');
|
||||
const labels = $derived({
|
||||
days: isEnglish ? (displayLength === 1 ? 'Day' : 'Days') : (displayLength === 1 ? 'Tag' : 'Tage'),
|
||||
prayed: isEnglish ? 'Prayed' : 'Gebetet',
|
||||
prayedToday: isEnglish ? 'Prayed today' : 'Heute gebetet',
|
||||
ariaLabel: isEnglish ? 'Mark prayer as prayed' : 'Gebet als gebetet markieren'
|
||||
days: isLatin ? (displayLength === 1 ? 'Dies' : 'Dies') : isEnglish ? (displayLength === 1 ? 'Day' : 'Days') : (displayLength === 1 ? 'Tag' : 'Tage'),
|
||||
prayed: isLatin ? 'Oravi' : isEnglish ? 'Prayed' : 'Gebetet',
|
||||
prayedToday: isLatin ? 'Hodie oravi' : isEnglish ? 'Prayed today' : 'Heute gebetet',
|
||||
ariaLabel: isLatin ? 'Orationem notatam fac' : isEnglish ? 'Mark prayer as prayed' : 'Gebet als gebetet markieren'
|
||||
});
|
||||
|
||||
// Initialize store on mount (client-side only)
|
||||
|
||||
@@ -38,6 +38,8 @@
|
||||
/* === LANGUAGE VISIBILITY === */
|
||||
.prayer-wrapper.lang-de :global(v:lang(en)),
|
||||
.prayer-wrapper.lang-en :global(v:lang(de)),
|
||||
.prayer-wrapper.lang-la :global(v:lang(de)),
|
||||
.prayer-wrapper.lang-la :global(v:lang(en)),
|
||||
.prayer-wrapper.monolingual :global(v:lang(la)) {
|
||||
display: none;
|
||||
}
|
||||
@@ -68,6 +70,11 @@
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
/* Latin-only spacing */
|
||||
.prayer-wrapper.lang-la :global(v:lang(la)) {
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
/* === LIGHT MODE === */
|
||||
@media (prefers-color-scheme: light) {
|
||||
:global(:root:not([data-theme="dark"])) .prayer-wrapper :global(v:lang(la)),
|
||||
@@ -164,6 +171,7 @@
|
||||
class:no-latin={!hasLatin}
|
||||
class:lang-de={urlLang === 'de'}
|
||||
class:lang-en={urlLang === 'en'}
|
||||
class:lang-la={urlLang === 'la'}
|
||||
>
|
||||
{@render children?.(showLatin, urlLang)}
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,7 @@ 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' | 'la'} [options.urlLang] - The URL language (de for /glaube, en for /faith, la for /fides)
|
||||
* @param {boolean} [options.initialLatin] - Initial state for Latin/bilingual display
|
||||
*/
|
||||
export function createLanguageContext({ urlLang = 'de', initialLatin = true } = {}) {
|
||||
|
||||
@@ -19,15 +19,22 @@ export function computeEaster(year: number): Date {
|
||||
return new Date(year, month - 1, day);
|
||||
}
|
||||
|
||||
/** Strip time component for date-only comparison. */
|
||||
function toMidnight(date: Date): Date {
|
||||
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a date falls within Eastertide (Easter Sunday to Pentecost Sunday, inclusive).
|
||||
* Check if a date falls within Eastertide (Easter Sunday to Ascension Thursday, inclusive).
|
||||
* Ascension = Easter + 39 days.
|
||||
*/
|
||||
export function isEastertide(date: Date = new Date()): boolean {
|
||||
const year = date.getFullYear();
|
||||
const easter = computeEaster(year);
|
||||
const pentecost = new Date(easter);
|
||||
pentecost.setDate(pentecost.getDate() + 49);
|
||||
return date >= easter && date <= pentecost;
|
||||
const ascension = new Date(easter);
|
||||
ascension.setDate(ascension.getDate() + 39);
|
||||
const d = toMidnight(date);
|
||||
return d >= easter && d <= ascension;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -41,7 +48,8 @@ export function isLent(date: Date = new Date()): boolean {
|
||||
ashWednesday.setDate(ashWednesday.getDate() - 46);
|
||||
const holySaturday = new Date(easter);
|
||||
holySaturday.setDate(holySaturday.getDate() - 1);
|
||||
return date >= ashWednesday && date <= holySaturday;
|
||||
const d = toMidnight(date);
|
||||
return d >= ashWednesday && d <= holySaturday;
|
||||
}
|
||||
|
||||
export type LiturgicalSeason = 'eastertide' | 'lent' | null;
|
||||
|
||||
351
src/lib/stores/angelusStreak.svelte.ts
Normal file
351
src/lib/stores/angelusStreak.svelte.ts
Normal file
@@ -0,0 +1,351 @@
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
const STORAGE_KEY = 'angelus_streak';
|
||||
|
||||
interface AngelusStreakData {
|
||||
streak: number;
|
||||
lastComplete: string | null; // YYYY-MM-DD
|
||||
todayPrayed: number; // bitmask: 1=morning, 2=noon, 4=evening
|
||||
todayDate: string | null; // YYYY-MM-DD
|
||||
}
|
||||
|
||||
export type TimeSlot = 'morning' | 'noon' | 'evening';
|
||||
|
||||
const TIME_BITS: Record<TimeSlot, number> = {
|
||||
morning: 1,
|
||||
noon: 2,
|
||||
evening: 4
|
||||
};
|
||||
|
||||
function getToday(): string {
|
||||
return new Date().toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
function isYesterday(dateStr: string): boolean {
|
||||
const yesterday = new Date();
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
return dateStr === yesterday.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
export function getCurrentTimeSlot(): TimeSlot {
|
||||
const hour = new Date().getHours();
|
||||
if (hour < 10) return 'morning';
|
||||
if (hour < 15) return 'noon';
|
||||
return 'evening';
|
||||
}
|
||||
|
||||
function loadFromStorage(): AngelusStreakData {
|
||||
if (!browser) return { streak: 0, lastComplete: null, todayPrayed: 0, todayDate: null };
|
||||
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
return JSON.parse(stored);
|
||||
}
|
||||
} catch {
|
||||
// Invalid data
|
||||
}
|
||||
return { streak: 0, lastComplete: null, todayPrayed: 0, todayDate: null };
|
||||
}
|
||||
|
||||
function saveToStorage(data: AngelusStreakData): void {
|
||||
if (!browser) return;
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
|
||||
}
|
||||
|
||||
async function saveToServer(data: AngelusStreakData): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch('/api/glaube/angelus-streak', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function mergeStreakData(local: AngelusStreakData, server: AngelusStreakData | null): AngelusStreakData {
|
||||
if (!server) return local;
|
||||
|
||||
const today = getToday();
|
||||
|
||||
// Reset todayPrayed if date rolled over
|
||||
const localEffective = local.todayDate === today ? local : { ...local, todayPrayed: 0, todayDate: null };
|
||||
const serverEffective = server.todayDate === today ? server : { ...server, todayPrayed: 0, todayDate: null };
|
||||
|
||||
// Merge todayPrayed bitmasks (union of both)
|
||||
const mergedTodayPrayed = localEffective.todayPrayed | serverEffective.todayPrayed;
|
||||
|
||||
// Take the higher streak or more recent lastComplete
|
||||
let bestStreak: number;
|
||||
let bestLastComplete: string | null;
|
||||
|
||||
if (localEffective.lastComplete === serverEffective.lastComplete) {
|
||||
bestStreak = Math.max(localEffective.streak, serverEffective.streak);
|
||||
bestLastComplete = localEffective.lastComplete;
|
||||
} else if (!localEffective.lastComplete) {
|
||||
bestStreak = serverEffective.streak;
|
||||
bestLastComplete = serverEffective.lastComplete;
|
||||
} else if (!serverEffective.lastComplete) {
|
||||
bestStreak = localEffective.streak;
|
||||
bestLastComplete = localEffective.lastComplete;
|
||||
} else {
|
||||
// Take whichever has more recent lastComplete
|
||||
if (localEffective.lastComplete > serverEffective.lastComplete) {
|
||||
bestStreak = localEffective.streak;
|
||||
bestLastComplete = localEffective.lastComplete;
|
||||
} else {
|
||||
bestStreak = serverEffective.streak;
|
||||
bestLastComplete = serverEffective.lastComplete;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
streak: bestStreak,
|
||||
lastComplete: bestLastComplete,
|
||||
todayPrayed: mergedTodayPrayed,
|
||||
todayDate: mergedTodayPrayed > 0 ? today : null
|
||||
};
|
||||
}
|
||||
|
||||
function isPWA(): boolean {
|
||||
if (!browser) return false;
|
||||
return window.matchMedia('(display-mode: standalone)').matches ||
|
||||
(window.navigator as Navigator & { standalone?: boolean }).standalone === true;
|
||||
}
|
||||
|
||||
class AngelusStreakStore {
|
||||
#streak = $state(0);
|
||||
#lastComplete = $state<string | null>(null);
|
||||
#todayPrayed = $state(0);
|
||||
#todayDate = $state<string | null>(null);
|
||||
#isLoggedIn = $state(false);
|
||||
#initialized = false;
|
||||
#syncing = $state(false);
|
||||
#pendingSync = false;
|
||||
#isOffline = $state(false);
|
||||
#reconnectInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
constructor() {
|
||||
if (browser) {
|
||||
this.#initFromStorage();
|
||||
this.#setupNetworkListeners();
|
||||
}
|
||||
}
|
||||
|
||||
#initFromStorage() {
|
||||
if (this.#initialized) return;
|
||||
const data = loadFromStorage();
|
||||
const today = getToday();
|
||||
|
||||
// Reset todayPrayed if date rolled over
|
||||
if (data.todayDate !== today) {
|
||||
data.todayPrayed = 0;
|
||||
data.todayDate = null;
|
||||
}
|
||||
|
||||
this.#streak = data.streak;
|
||||
this.#lastComplete = data.lastComplete;
|
||||
this.#todayPrayed = data.todayPrayed;
|
||||
this.#todayDate = data.todayDate;
|
||||
this.#initialized = true;
|
||||
}
|
||||
|
||||
#setupNetworkListeners() {
|
||||
this.#isOffline = !navigator.onLine;
|
||||
|
||||
window.addEventListener('online', () => {
|
||||
this.#isOffline = false;
|
||||
this.#stopReconnectPolling();
|
||||
if (this.#isLoggedIn && this.#pendingSync) {
|
||||
this.#pushToServer();
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('offline', () => {
|
||||
this.#isOffline = true;
|
||||
if (isPWA() && this.#isLoggedIn && this.#pendingSync) {
|
||||
this.#startReconnectPolling();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#startReconnectPolling() {
|
||||
if (this.#reconnectInterval) return;
|
||||
this.#reconnectInterval = setInterval(() => {
|
||||
if (navigator.onLine) {
|
||||
this.#isOffline = false;
|
||||
this.#stopReconnectPolling();
|
||||
if (this.#pendingSync) {
|
||||
this.#pushToServer();
|
||||
}
|
||||
}
|
||||
}, 30000);
|
||||
}
|
||||
|
||||
#stopReconnectPolling() {
|
||||
if (this.#reconnectInterval) {
|
||||
clearInterval(this.#reconnectInterval);
|
||||
this.#reconnectInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
get streak() {
|
||||
// If lastComplete is stale (not today, not yesterday), streak is broken
|
||||
if (this.#lastComplete && this.#lastComplete !== getToday() && !isYesterday(this.#lastComplete)) {
|
||||
// But if today has some prayers, streak might still be valid from today's completion
|
||||
if (this.#todayDate !== getToday() || this.#todayPrayed !== 7) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
return this.#streak;
|
||||
}
|
||||
|
||||
get lastComplete() {
|
||||
return this.#lastComplete;
|
||||
}
|
||||
|
||||
get todayPrayed() {
|
||||
if (this.#todayDate !== getToday()) return 0;
|
||||
return this.#todayPrayed;
|
||||
}
|
||||
|
||||
get todayComplete(): boolean {
|
||||
return this.todayPrayed === 7;
|
||||
}
|
||||
|
||||
get isLoggedIn() {
|
||||
return this.#isLoggedIn;
|
||||
}
|
||||
|
||||
get syncing() {
|
||||
return this.#syncing;
|
||||
}
|
||||
|
||||
isSlotPrayed(slot: TimeSlot): boolean {
|
||||
return (this.todayPrayed & TIME_BITS[slot]) !== 0;
|
||||
}
|
||||
|
||||
initWithServerData(serverData: AngelusStreakData | null, isLoggedIn: boolean): void {
|
||||
this.#isLoggedIn = isLoggedIn;
|
||||
|
||||
if (!isLoggedIn || !serverData) {
|
||||
return;
|
||||
}
|
||||
|
||||
const localData = loadFromStorage();
|
||||
const merged = mergeStreakData(localData, serverData);
|
||||
|
||||
// Check if streak is expired
|
||||
const isExpired =
|
||||
merged.lastComplete !== null &&
|
||||
merged.lastComplete !== getToday() &&
|
||||
!isYesterday(merged.lastComplete) &&
|
||||
merged.todayPrayed !== 7;
|
||||
const effective: AngelusStreakData = isExpired
|
||||
? { streak: 0, lastComplete: null, todayPrayed: merged.todayPrayed, todayDate: merged.todayDate }
|
||||
: merged;
|
||||
|
||||
this.#streak = effective.streak;
|
||||
this.#lastComplete = effective.lastComplete;
|
||||
this.#todayPrayed = effective.todayPrayed;
|
||||
this.#todayDate = effective.todayDate;
|
||||
saveToStorage(effective);
|
||||
|
||||
if (
|
||||
effective.streak !== serverData.streak ||
|
||||
effective.lastComplete !== serverData.lastComplete ||
|
||||
effective.todayPrayed !== serverData.todayPrayed
|
||||
) {
|
||||
this.#pushToServer();
|
||||
}
|
||||
}
|
||||
|
||||
async #pushToServer(): Promise<void> {
|
||||
if (this.#syncing || !this.#isLoggedIn) return;
|
||||
this.#syncing = true;
|
||||
|
||||
try {
|
||||
const data: AngelusStreakData = {
|
||||
streak: this.#streak,
|
||||
lastComplete: this.#lastComplete,
|
||||
todayPrayed: this.#todayPrayed,
|
||||
todayDate: this.#todayDate
|
||||
};
|
||||
const success = await saveToServer(data);
|
||||
this.#pendingSync = !success;
|
||||
} catch {
|
||||
this.#pendingSync = true;
|
||||
} finally {
|
||||
this.#syncing = false;
|
||||
}
|
||||
}
|
||||
|
||||
async recordPrayer(slot: TimeSlot): Promise<boolean> {
|
||||
const today = getToday();
|
||||
|
||||
// Reset if date rolled over
|
||||
if (this.#todayDate !== today) {
|
||||
this.#todayPrayed = 0;
|
||||
this.#todayDate = today;
|
||||
}
|
||||
|
||||
const bit = TIME_BITS[slot];
|
||||
|
||||
// Already prayed this slot
|
||||
if ((this.#todayPrayed & bit) !== 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.#todayPrayed |= bit;
|
||||
this.#todayDate = today;
|
||||
|
||||
// Check if day is now complete
|
||||
let dayCompleted = false;
|
||||
if (this.#todayPrayed === 7) {
|
||||
dayCompleted = true;
|
||||
|
||||
// Update streak
|
||||
if (this.#lastComplete && (this.#lastComplete === today || isYesterday(this.#lastComplete))) {
|
||||
// lastComplete is today (shouldn't happen) or yesterday → continue streak
|
||||
if (this.#lastComplete !== today) {
|
||||
this.#streak += 1;
|
||||
}
|
||||
} else {
|
||||
// Gap or first time → start new streak
|
||||
this.#streak = 1;
|
||||
}
|
||||
this.#lastComplete = today;
|
||||
}
|
||||
|
||||
const data: AngelusStreakData = {
|
||||
streak: this.#streak,
|
||||
lastComplete: this.#lastComplete,
|
||||
todayPrayed: this.#todayPrayed,
|
||||
todayDate: this.#todayDate
|
||||
};
|
||||
saveToStorage(data);
|
||||
|
||||
if (this.#isLoggedIn) {
|
||||
const success = await saveToServer(data);
|
||||
this.#pendingSync = !success;
|
||||
|
||||
if (!success && this.#isOffline && isPWA()) {
|
||||
this.#startReconnectPolling();
|
||||
}
|
||||
}
|
||||
|
||||
return dayCompleted;
|
||||
}
|
||||
}
|
||||
|
||||
let instance: AngelusStreakStore | null = null;
|
||||
|
||||
export function getAngelusStreak(): AngelusStreakStore {
|
||||
if (!instance) {
|
||||
instance = new AngelusStreakStore();
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
24
src/models/AngelusStreak.ts
Normal file
24
src/models/AngelusStreak.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import mongoose from 'mongoose';
|
||||
|
||||
const AngelusStreakSchema = new mongoose.Schema(
|
||||
{
|
||||
username: { type: String, required: true, unique: true },
|
||||
streak: { type: Number, required: true, default: 0 },
|
||||
lastComplete: { type: String, default: null }, // YYYY-MM-DD of last fully-completed day
|
||||
todayPrayed: { type: Number, required: true, default: 0 }, // bitmask: 1=morning, 2=noon, 4=evening
|
||||
todayDate: { type: String, default: null } // YYYY-MM-DD
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
interface IAngelusStreak {
|
||||
username: string;
|
||||
streak: number;
|
||||
lastComplete: string | null;
|
||||
todayPrayed: number;
|
||||
todayDate: string | null;
|
||||
}
|
||||
|
||||
let _model: mongoose.Model<IAngelusStreak>;
|
||||
try { _model = mongoose.model<IAngelusStreak>("AngelusStreak"); } catch { _model = mongoose.model<IAngelusStreak>("AngelusStreak", AngelusStreakSchema); }
|
||||
export const AngelusStreak = _model;
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ParamMatcher } from '@sveltejs/kit';
|
||||
|
||||
export const match: ParamMatcher = (param) => {
|
||||
return param === 'faith' || param === 'glaube';
|
||||
return param === 'faith' || param === 'glaube' || param === 'fides';
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ParamMatcher } from '@sveltejs/kit';
|
||||
|
||||
export const match: ParamMatcher = (param) => {
|
||||
return param === 'prayers' || param === 'gebete';
|
||||
return param === 'prayers' || param === 'gebete' || param === 'orationes';
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ParamMatcher } from '@sveltejs/kit';
|
||||
|
||||
export const match: ParamMatcher = (param) => {
|
||||
return param === 'rosary' || param === 'rosenkranz';
|
||||
return param === 'rosary' || param === 'rosenkranz' || param === 'rosarium';
|
||||
};
|
||||
|
||||
@@ -3,11 +3,11 @@ import { error } from "@sveltejs/kit";
|
||||
|
||||
export const load : LayoutServerLoad = async ({locals, params}) => {
|
||||
// Validate faithLang parameter
|
||||
if (params.faithLang !== 'glaube' && params.faithLang !== 'faith') {
|
||||
if (params.faithLang !== 'glaube' && params.faithLang !== 'faith' && params.faithLang !== 'fides') {
|
||||
throw error(404, 'Not found');
|
||||
}
|
||||
|
||||
const lang = params.faithLang === 'faith' ? 'en' : 'de';
|
||||
const lang = params.faithLang === 'faith' ? 'en' : params.faithLang === 'fides' ? 'la' : 'de';
|
||||
|
||||
return {
|
||||
session: locals.session ?? await locals.auth(),
|
||||
|
||||
@@ -4,26 +4,33 @@ import { page } from '$app/stores';
|
||||
import Header from '$lib/components/Header.svelte'
|
||||
import UserHeader from '$lib/components/UserHeader.svelte';
|
||||
import LanguageSelector from '$lib/components/LanguageSelector.svelte';
|
||||
import { isEastertide } from '$lib/js/easter.svelte';
|
||||
let { data, children } = $props();
|
||||
|
||||
const isEnglish = $derived(data.lang === 'en');
|
||||
const prayersPath = $derived(isEnglish ? 'prayers' : 'gebete');
|
||||
const rosaryPath = $derived(isEnglish ? 'rosary' : 'rosenkranz');
|
||||
const isLatin = $derived(data.lang === 'la');
|
||||
const eastertide = isEastertide();
|
||||
const prayersHref = $derived(isLatin ? '/fides/orationes' : `/${data.faithLang}/${isEnglish ? 'prayers' : 'gebete'}`);
|
||||
const rosaryHref = $derived(`/${data.faithLang}/${isLatin ? 'rosarium' : isEnglish ? 'rosary' : 'rosenkranz'}`);
|
||||
const angelusHref = $derived(eastertide
|
||||
? `${prayersHref}/regina-caeli`
|
||||
: `${prayersHref}/angelus`);
|
||||
const angelusLabel = $derived(eastertide ? 'Regína Cæli' : 'Angelus');
|
||||
|
||||
const labels = $derived({
|
||||
prayers: isEnglish ? 'Prayers' : 'Gebete',
|
||||
rosary: isEnglish ? 'Rosary' : 'Rosenkranz'
|
||||
prayers: isLatin ? 'Orationes' : isEnglish ? 'Prayers' : 'Gebete',
|
||||
rosary: isLatin ? 'Rosarium' : isEnglish ? 'Rosary' : 'Rosenkranz'
|
||||
});
|
||||
|
||||
/** @type {'de' | 'en'} */
|
||||
const typedLang = /** @type {'de' | 'en'} */ (data.lang);
|
||||
const typedLang = $derived(/** @type {'de' | 'en'} */ (data.lang));
|
||||
|
||||
/** @param {string} path */
|
||||
function isActive(path) {
|
||||
const currentPath = $page.url.pathname;
|
||||
// Check if current path starts with the link path
|
||||
return currentPath.startsWith(path);
|
||||
}
|
||||
|
||||
const prayersActive = $derived(isActive(prayersHref) && !isActive(angelusHref));
|
||||
</script>
|
||||
<svelte:head>
|
||||
<link rel="preload" href="/fonts/crosses.woff2" as="font" type="font/woff2" crossorigin="anonymous">
|
||||
@@ -31,8 +38,13 @@ function isActive(path) {
|
||||
<Header>
|
||||
{#snippet links()}
|
||||
<ul class=site_header>
|
||||
<li style="--active-fill: var(--nord12)"><a href="/{data.faithLang}/{prayersPath}" class:active={isActive(`/${data.faithLang}/${prayersPath}`)} title={labels.prayers}><svg class="nav-icon" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 640 512" fill="currentColor"><path d="M351.2 4.8c3.2-2 6.6-3.3 10-4.1c4.7-1 9.6-.9 14.1 .1c7.7 1.8 14.8 6.5 19.4 13.6L514.6 194.2c8.8 13.1 13.4 28.6 13.4 44.4v73.5c0 6.9 4.4 13 10.9 15.2l79.2 26.4C631.2 358 640 370.2 640 384v96c0 9.9-4.6 19.3-12.5 25.4s-18.1 8.1-27.7 5.5L431 465.9c-56-14.9-95-65.7-95-123.7V224c0-17.7 14.3-32 32-32s32 14.3 32 32v80c0 8.8 7.2 16 16 16s16-7.2 16-16V219.1c0-7-1.8-13.8-5.3-19.8L340.3 48.1c-1.7-3-2.9-6.1-3.6-9.3c-1-4.7-1-9.6 .1-14.1c1.9-8 6.8-15.2 14.3-19.9zm-62.4 0c7.5 4.6 12.4 11.9 14.3 19.9c1.1 4.6 1.2 9.4 .1 14.1c-.7 3.2-1.9 6.3-3.6 9.3L213.3 199.3c-3.5 6-5.3 12.9-5.3 19.8V304c0 8.8 7.2 16 16 16s16-7.2 16-16V224c0-17.7 14.3-32 32-32s32 14.3 32 32V342.3c0 58-39 108.7-95 123.7l-168.7 45c-9.6 2.6-19.9 .5-27.7-5.5S0 490 0 480V384c0-13.8 8.8-26 21.9-30.4l79.2-26.4c6.5-2.2 10.9-8.3 10.9-15.2V238.5c0-15.8 4.7-31.2 13.4-44.4L245.2 14.5c4.6-7.1 11.7-11.8 19.4-13.6c4.6-1.1 9.4-1.2 14.1-.1c3.5 .8 6.9 2.1 10 4.1z"/></svg><span class="nav-label">{labels.prayers}</span></a></li>
|
||||
<li style="--active-fill: var(--nord11)"><a href="/{data.faithLang}/{rosaryPath}" class:active={isActive(`/${data.faithLang}/${rosaryPath}`)} title={labels.rosary}><svg class="nav-icon" width="16" height="16" viewBox="0 0 512 512" fill="currentColor"><path d="M241.251,145.056c-39.203-17.423-91.472,17.423-104.54,60.982c-13.068,43.558,8.712,117.608,65.337,143.742 c56.626,26.135,108.896-8.712,87.117-39.202c-74.049-8.712-121.963-87.117-100.184-126.319S280.453,162.479,241.251,145.056z"/><path d="M337.079,271.375c47.914-39.202,21.779-126.319-17.423-135.031c-39.202-8.712-56.626,13.068-26.135,39.202 c39.203,30.491-8.712,91.472-39.202,87.117C254.318,262.663,289.165,310.577,337.079,271.375z"/><path d="M254.318,119.788c43.558-17.423,74.049-9.579,100.184,16.556c13.068-39.202-30.491-104.54-108.896-113.252 S93.153,118.921,127.999,171.191C136.711,153.767,188.981,106.721,254.318,119.788z"/><path d="M110.576,245.24C36.527,262.663,28.87,335.248,45.239,380.27c17.423,47.914,4.356,82.761,26.135,91.472 c20.622,8.253,91.472,13.068,152.454,17.423c60.982,4.356,108.896-47.914,91.472-108.896 C141.067,410.761,110.576,284.442,110.576,245.24z"/><path d="M93.883,235.796c0,0,2.178-28.313,10.89-43.558c-4.356-4.356-8.712-21.779-8.712-21.779 s-4.356-19.601-4.356-34.846c-32.669-6.534-89.295,34.846-91.472,41.38c-2.178,6.534,10.889,80.583,39.202,82.761 C69.927,235.796,93.883,235.796,93.883,235.796z"/><path d="M489.533,175.546c-39.202-82.761-113.252-65.337-113.252-65.337s4.356,21.779-4.356,34.846 c43.558,47.914,13.067,146.643-24.681,158.265c130.675,56.626,159.712-58.081,164.068-75.504 C515.668,210.393,498.245,197.326,489.533,175.546z"/><path d="M454.108,332.076c-22.359,15.841-85.663,11.613-121.964-7.265c1.446,14.514-13.067,37.756-20.325,39.202 c27.59,11.621,53.725,62.436,7.265,116.161c18.878,18.87,95.828,4.356,140.842-24.689c7.325-4.722,18.869-52.27,21.779-79.851 C485.56,339.103,488.963,307.387,454.108,332.076z"/><path d="M257.227,213.294c-18.928,5.164-30.439-6.27-23.234-18.869c5.811-10.167,5.266-20.69-8.712-13.068 c-29.044,17.423-11.612,66.784,24.689,62.428c49.36-17.423,27.581-62.428,14.514-60.982 C251.417,184.249,273.196,208.938,257.227,213.294z"/></svg><span class="nav-label">{labels.rosary}</span></a></li>
|
||||
<li style="--active-fill: var(--nord12)"><a href={prayersHref} class:active={prayersActive} title={labels.prayers}><svg class="nav-icon" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 640 512" fill="currentColor"><path d="M351.2 4.8c3.2-2 6.6-3.3 10-4.1c4.7-1 9.6-.9 14.1 .1c7.7 1.8 14.8 6.5 19.4 13.6L514.6 194.2c8.8 13.1 13.4 28.6 13.4 44.4v73.5c0 6.9 4.4 13 10.9 15.2l79.2 26.4C631.2 358 640 370.2 640 384v96c0 9.9-4.6 19.3-12.5 25.4s-18.1 8.1-27.7 5.5L431 465.9c-56-14.9-95-65.7-95-123.7V224c0-17.7 14.3-32 32-32s32 14.3 32 32v80c0 8.8 7.2 16 16 16s16-7.2 16-16V219.1c0-7-1.8-13.8-5.3-19.8L340.3 48.1c-1.7-3-2.9-6.1-3.6-9.3c-1-4.7-1-9.6 .1-14.1c1.9-8 6.8-15.2 14.3-19.9zm-62.4 0c7.5 4.6 12.4 11.9 14.3 19.9c1.1 4.6 1.2 9.4 .1 14.1c-.7 3.2-1.9 6.3-3.6 9.3L213.3 199.3c-3.5 6-5.3 12.9-5.3 19.8V304c0 8.8 7.2 16 16 16s16-7.2 16-16V224c0-17.7 14.3-32 32-32s32 14.3 32 32V342.3c0 58-39 108.7-95 123.7l-168.7 45c-9.6 2.6-19.9 .5-27.7-5.5S0 490 0 480V384c0-13.8 8.8-26 21.9-30.4l79.2-26.4c6.5-2.2 10.9-8.3 10.9-15.2V238.5c0-15.8 4.7-31.2 13.4-44.4L245.2 14.5c4.6-7.1 11.7-11.8 19.4-13.6c4.6-1.1 9.4-1.2 14.1-.1c3.5 .8 6.9 2.1 10 4.1z"/></svg><span class="nav-label">{labels.prayers}</span></a></li>
|
||||
<li style="--active-fill: var(--nord11)"><a href={rosaryHref} class:active={isActive(rosaryHref)} title={labels.rosary}><svg class="nav-icon" width="16" height="16" viewBox="0 0 512 512" fill="currentColor"><path d="M241.251,145.056c-39.203-17.423-91.472,17.423-104.54,60.982c-13.068,43.558,8.712,117.608,65.337,143.742 c56.626,26.135,108.896-8.712,87.117-39.202c-74.049-8.712-121.963-87.117-100.184-126.319S280.453,162.479,241.251,145.056z"/><path d="M337.079,271.375c47.914-39.202,21.779-126.319-17.423-135.031c-39.202-8.712-56.626,13.068-26.135,39.202 c39.203,30.491-8.712,91.472-39.202,87.117C254.318,262.663,289.165,310.577,337.079,271.375z"/><path d="M254.318,119.788c43.558-17.423,74.049-9.579,100.184,16.556c13.068-39.202-30.491-104.54-108.896-113.252 S93.153,118.921,127.999,171.191C136.711,153.767,188.981,106.721,254.318,119.788z"/><path d="M110.576,245.24C36.527,262.663,28.87,335.248,45.239,380.27c17.423,47.914,4.356,82.761,26.135,91.472 c20.622,8.253,91.472,13.068,152.454,17.423c60.982,4.356,108.896-47.914,91.472-108.896 C141.067,410.761,110.576,284.442,110.576,245.24z"/><path d="M93.883,235.796c0,0,2.178-28.313,10.89-43.558c-4.356-4.356-8.712-21.779-8.712-21.779 s-4.356-19.601-4.356-34.846c-32.669-6.534-89.295,34.846-91.472,41.38c-2.178,6.534,10.889,80.583,39.202,82.761 C69.927,235.796,93.883,235.796,93.883,235.796z"/><path d="M489.533,175.546c-39.202-82.761-113.252-65.337-113.252-65.337s4.356,21.779-4.356,34.846 c43.558,47.914,13.067,146.643-24.681,158.265c130.675,56.626,159.712-58.081,164.068-75.504 C515.668,210.393,498.245,197.326,489.533,175.546z"/><path d="M454.108,332.076c-22.359,15.841-85.663,11.613-121.964-7.265c1.446,14.514-13.067,37.756-20.325,39.202 c27.59,11.621,53.725,62.436,7.265,116.161c18.878,18.87,95.828,4.356,140.842-24.689c7.325-4.722,18.869-52.27,21.779-79.851 C485.56,339.103,488.963,307.387,454.108,332.076z"/><path d="M257.227,213.294c-18.928,5.164-30.439-6.27-23.234-18.869c5.811-10.167,5.266-20.69-8.712-13.068 c-29.044,17.423-11.612,66.784,24.689,62.428c49.36-17.423,27.581-62.428,14.514-60.982 C251.417,184.249,273.196,208.938,257.227,213.294z"/></svg><span class="nav-label">{labels.rosary}</span></a></li>
|
||||
{#if eastertide}
|
||||
<li style="--active-fill: var(--nord14)"><a href={angelusHref} class:active={isActive(angelusHref)} title={angelusLabel}><svg class="nav-icon" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="-10 -274 532 548" fill="currentColor"><path d="M256-168c27 0 48-21 48-48s-21-48-48-48-48 21-48 48 21 48 48 48zM6-63l122 199-56 70c-5 7-8 14-8 23 0 19 16 35 36 35h312c20 0 36-16 36-35 0-9-3-16-8-23l-56-70L507-63c3-6 5-13 5-20 0-20-16-37-37-37-7 0-14 2-20 6l-17 12c-13 8-30 6-40-4l-35-35c-7-7-17-11-27-11s-20 4-27 11l-30 30c-13 13-33 13-46 0l-30-30c-7-7-17-11-27-11s-20 4-27 11l-34 34c-11 11-28 13-41 4l-17-11c-6-4-13-6-20-6-20 0-37 17-37 37 0 7 2 14 6 20z"/></svg><span class="nav-label">{angelusLabel}</span></a></li>
|
||||
{:else}
|
||||
<li style="--active-fill: var(--nord14)"><a href={angelusHref} class:active={isActive(angelusHref)} title={angelusLabel}><svg class="nav-icon" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="6 -274 564 548" fill="currentColor"><path d="M392-162c-4-10-9-18-15-26 5-4 7-8 7-12 0-18-43-32-96-32s-96 14-96 32c0 4 3 8 7 12-6 8-11 16-15 26-15-11-24-24-24-38 0-35 57-64 128-64s128 29 128 64c0 14-9 27-24 38zm-104-22c35 0 64 29 64 64s-29 64-64 64-64-29-64-64 29-64 64-64zM82 159c3-22-3-48-20-64C34 68 16 30 16-12v-64c0-42 34-76 76-76 23 0 44 10 59 27l65 78c-21 16-37 40-43 67l-43 195c-4 17-2 34 5 49h-21c-26 0-46-24-42-50l10-55zm364 56L403 20c-6-27-21-51-42-67l64-77c15-18 36-28 59-28 42 0 76 34 76 76v64c0 42-18 80-46 107-17 16-23 42-20 64l10 56c4 26-16 49-42 49h-20c6-15 8-32 4-49zM220 31c7-32 35-55 68-55s61 23 68 55l43 194c5 20-11 39-31 39H208c-21 0-36-19-31-39l43-194z"/></svg><span class="nav-label">{angelusLabel}</span></a></li>
|
||||
{/if}
|
||||
</ul>
|
||||
{/snippet}
|
||||
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
<script>
|
||||
import LinksGrid from '$lib/components/LinksGrid.svelte';
|
||||
import { isEastertide } from '$lib/js/easter.svelte';
|
||||
let { data } = $props();
|
||||
|
||||
const isEnglish = $derived(data.lang === 'en');
|
||||
const prayersPath = $derived(isEnglish ? 'prayers' : 'gebete');
|
||||
const rosaryPath = $derived(isEnglish ? 'rosary' : 'rosenkranz');
|
||||
const isLatin = $derived(data.lang === 'la');
|
||||
const prayersPath = $derived(isLatin ? 'orationes' : isEnglish ? 'prayers' : 'gebete');
|
||||
const rosaryPath = $derived(isLatin ? 'rosarium' : isEnglish ? 'rosary' : 'rosenkranz');
|
||||
const eastertide = isEastertide();
|
||||
|
||||
const labels = $derived({
|
||||
title: isEnglish ? 'Faith' : 'Glaube',
|
||||
description: isEnglish
|
||||
? 'Here you will find some prayers and an interactive rosary for the Catholic faith. A focus on Latin and the Tridentine rite will be noticeable.'
|
||||
: 'Hier findet man einige Gebete und einen interaktiven Rosenkranz zum katholischen Glauben. Ein Fokus auf Latein und den tridentinischen Ritus wird zu bemerken sein.',
|
||||
prayers: isEnglish ? 'Prayers' : 'Gebete',
|
||||
rosary: isEnglish ? 'Rosary' : 'Rosenkranz'
|
||||
title: isLatin ? 'Fides' : isEnglish ? 'Faith' : 'Glaube',
|
||||
description: isLatin
|
||||
? 'Hic invenies orationes et rosarium interactivum fidei catholicae.'
|
||||
: isEnglish
|
||||
? 'Here you will find some prayers and an interactive rosary for the Catholic faith. A focus on Latin and the Tridentine rite will be noticeable.'
|
||||
: 'Hier findet man einige Gebete und einen interaktiven Rosenkranz zum katholischen Glauben. Ein Fokus auf Latein und den tridentinischen Ritus wird zu bemerken sein.',
|
||||
prayers: isLatin ? 'Orationes' : isEnglish ? 'Prayers' : 'Gebete',
|
||||
rosary: isLatin ? 'Rosarium Vivum' : isEnglish ? 'Rosary' : 'Rosenkranz'
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -71,8 +76,15 @@
|
||||
</svg>
|
||||
<h3>{labels.rosary}</h3>
|
||||
</a>
|
||||
<a href="/{data.faithLang}/{prayersPath}/angelus">
|
||||
<svg xmlns="http://www.w3.org/2000/svg"viewBox="6 -274 564 548"><path d="M392-162c-4-10-9-18-15-26 5-4 7-8 7-12 0-18-43-32-96-32s-96 14-96 32c0 4 3 8 7 12-6 8-11 16-15 26-15-11-24-24-24-38 0-35 57-64 128-64s128 29 128 64c0 14-9 27-24 38zm-104-22c35 0 64 29 64 64s-29 64-64 64-64-29-64-64 29-64 64-64zM82 159c3-22-3-48-20-64C34 68 16 30 16-12v-64c0-42 34-76 76-76 23 0 44 10 59 27l65 78c-21 16-37 40-43 67l-43 195c-4 17-2 34 5 49h-21c-26 0-46-24-42-50l10-55zm364 56L403 20c-6-27-21-51-42-67l64-77c15-18 36-28 59-28 42 0 76 34 76 76v64c0 42-18 80-46 107-17 16-23 42-20 64l10 56c4 26-16 49-42 49h-20c6-15 8-32 4-49zM220 31c7-32 35-55 68-55s61 23 68 55l43 194c5 20-11 39-31 39H208c-21 0-36-19-31-39l43-194z"/></svg>
|
||||
<h3>Angelus</h3>
|
||||
</a>
|
||||
{#if eastertide}
|
||||
<a href="/{data.faithLang}/{prayersPath}/regina-caeli">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-10 -274 532 548"><path d="M256-168c27 0 48-21 48-48s-21-48-48-48-48 21-48 48 21 48 48 48zM6-63l122 199-56 70c-5 7-8 14-8 23 0 19 16 35 36 35h312c20 0 36-16 36-35 0-9-3-16-8-23l-56-70L507-63c3-6 5-13 5-20 0-20-16-37-37-37-7 0-14 2-20 6l-17 12c-13 8-30 6-40-4l-35-35c-7-7-17-11-27-11s-20 4-27 11l-30 30c-13 13-33 13-46 0l-30-30c-7-7-17-11-27-11s-20 4-27 11l-34 34c-11 11-28 13-41 4l-17-11c-6-4-13-6-20-6-20 0-37 17-37 37 0 7 2 14 6 20z"/></svg>
|
||||
<h3>Regína Cæli</h3>
|
||||
</a>
|
||||
{:else}
|
||||
<a href="/{data.faithLang}/{prayersPath}/angelus">
|
||||
<svg xmlns="http://www.w3.org/2000/svg"viewBox="6 -274 564 548"><path d="M392-162c-4-10-9-18-15-26 5-4 7-8 7-12 0-18-43-32-96-32s-96 14-96 32c0 4 3 8 7 12-6 8-11 16-15 26-15-11-24-24-24-38 0-35 57-64 128-64s128 29 128 64c0 14-9 27-24 38zm-104-22c35 0 64 29 64 64s-29 64-64 64-64-29-64-64 29-64 64-64zM82 159c3-22-3-48-20-64C34 68 16 30 16-12v-64c0-42 34-76 76-76 23 0 44 10 59 27l65 78c-21 16-37 40-43 67l-43 195c-4 17-2 34 5 49h-21c-26 0-46-24-42-50l10-55zm364 56L403 20c-6-27-21-51-42-67l64-77c15-18 36-28 59-28 42 0 76 34 76 76v64c0 42-18 80-46 107-17 16-23 42-20 64l10 56c4 26-16 49-42 49h-20c6-15 8-32 4-49zM220 31c7-32 35-55 68-55s61 23 68 55l43 194c5 20-11 39-31 39H208c-21 0-36-19-31-39l43-194z"/></svg>
|
||||
<h3>Angelus</h3>
|
||||
</a>
|
||||
{/if}
|
||||
</LinksGrid>
|
||||
|
||||
@@ -32,41 +32,47 @@
|
||||
let { data } = $props();
|
||||
|
||||
// Create language context for prayer components
|
||||
const langContext = createLanguageContext({ urlLang: /** @type {'de' | 'en'} */(data.lang), initialLatin: data.initialLatin });
|
||||
const langContext = createLanguageContext({ urlLang: /** @type {'de' | 'en'} */(data.lang), initialLatin: data.lang === 'la' ? true : data.initialLatin });
|
||||
|
||||
// Update lang store when data.lang changes (e.g., after navigation)
|
||||
$effect(() => {
|
||||
langContext.lang.set(data.lang);
|
||||
if (data.lang === 'la') {
|
||||
langContext.showLatin.set(true);
|
||||
}
|
||||
});
|
||||
|
||||
// Reactive isEnglish based on data.lang
|
||||
const isEnglish = $derived(data.lang === 'en');
|
||||
const isLatin = $derived(data.lang === 'la');
|
||||
|
||||
const labels = $derived({
|
||||
title: isEnglish ? 'Prayers' : 'Gebete',
|
||||
description: isEnglish
|
||||
? 'Catholic prayers in Latin and English.'
|
||||
: 'Katholische Gebete auf Deutsch und Latein.',
|
||||
signOfCross: isEnglish ? 'The Sign of the Cross' : 'Das heilige Kreuzzeichen',
|
||||
title: isLatin ? 'Orationes' : isEnglish ? 'Prayers' : 'Gebete',
|
||||
description: isLatin
|
||||
? 'Orationes catholicae in lingua Latina.'
|
||||
: isEnglish
|
||||
? 'Catholic prayers in Latin and English.'
|
||||
: 'Katholische Gebete auf Deutsch und Latein.',
|
||||
signOfCross: isLatin ? 'Signum Crucis' : isEnglish ? 'The Sign of the Cross' : 'Das heilige Kreuzzeichen',
|
||||
gloriaPatri: 'Glória Patri',
|
||||
paternoster: isEnglish ? 'Our Father' : 'Paternoster',
|
||||
credo: isEnglish ? 'Nicene Creed' : 'Credo',
|
||||
aveMaria: isEnglish ? 'Hail Mary' : 'Ave Maria',
|
||||
paternoster: isLatin ? 'Pater Noster' : isEnglish ? 'Our Father' : 'Paternoster',
|
||||
credo: 'Credo',
|
||||
aveMaria: 'Ave Maria',
|
||||
salveRegina: 'Salve Regina',
|
||||
fatima: isEnglish ? 'Fatima Prayer' : 'Das Fatimagebet',
|
||||
fatima: isLatin ? 'Oratio Fatimensis' : isEnglish ? 'Fatima Prayer' : 'Das Fatimagebet',
|
||||
gloria: 'Glória',
|
||||
michael: isEnglish ? 'Prayer to St. Michael the Archangel' : 'Gebet zum hl. Erzengel Michael',
|
||||
michael: isLatin ? 'Oratio ad S. Michaëlem Archangelum' : isEnglish ? 'Prayer to St. Michael the Archangel' : 'Gebet zum hl. Erzengel Michael',
|
||||
bruderKlaus: isEnglish ? 'Prayer of St. Nicholas of Flüe' : 'Bruder Klaus Gebet',
|
||||
joseph: isEnglish ? 'Prayer to St. Joseph by Pope St. Pius X' : 'Josephgebet des hl. Papst Pius X',
|
||||
confiteor: isEnglish ? 'The Confiteor' : 'Das Confiteor',
|
||||
searchPlaceholder: isEnglish ? 'Search prayers...' : 'Gebete suchen...',
|
||||
clearSearch: isEnglish ? 'Clear search' : 'Suche löschen',
|
||||
textMatch: isEnglish ? 'Match in prayer text' : 'Treffer im Gebetstext',
|
||||
postcommunio: isEnglish ? 'Postcommunio Prayers' : 'Nachkommuniongebete',
|
||||
confiteor: isLatin ? 'Confiteor' : isEnglish ? 'The Confiteor' : 'Das Confiteor',
|
||||
searchPlaceholder: isLatin ? 'Orationes quaerere...' : isEnglish ? 'Search prayers...' : 'Gebete suchen...',
|
||||
clearSearch: isLatin ? 'Quaestionem delere' : isEnglish ? 'Clear search' : 'Suche löschen',
|
||||
textMatch: isLatin ? 'In textu orationis' : isEnglish ? 'Match in prayer text' : 'Treffer im Gebetstext',
|
||||
postcommunio: isLatin ? 'Orationes post Communionem' : isEnglish ? 'Postcommunio Prayers' : 'Nachkommuniongebete',
|
||||
animachristi: 'Ánima Christi',
|
||||
prayerbeforeacrucifix: isEnglish ? 'Prayer Before a Crucifix' : 'Gebet vor einem Kruzifix',
|
||||
guardianAngel: isEnglish ? 'Guardian Angel Prayer' : 'Schutzengel-Gebet',
|
||||
apostlesCreed: isEnglish ? "Apostles' Creed" : 'Apostolisches Glaubensbekenntnis',
|
||||
prayerbeforeacrucifix: isLatin ? 'Oratio ante Crucifixum' : isEnglish ? 'Prayer Before a Crucifix' : 'Gebet vor einem Kruzifix',
|
||||
guardianAngel: isLatin ? 'Angele Dei' : isEnglish ? 'Guardian Angel Prayer' : 'Schutzengel-Gebet',
|
||||
apostlesCreed: isLatin ? 'Symbolum Apostolorum' : isEnglish ? "Apostles' Creed" : 'Apostolisches Glaubensbekenntnis',
|
||||
tantumErgo: 'Tantum Ergo',
|
||||
angelus: 'Angelus',
|
||||
reginaCaeli: 'Regína Cæli'
|
||||
@@ -76,12 +82,12 @@
|
||||
// when corresponding prayers are added to the collection
|
||||
|
||||
const categories = [
|
||||
{ id: 'essential', de: 'Grundgebete', en: 'Essential' },
|
||||
{ id: 'marian', de: 'Marianisch', en: 'Marian' },
|
||||
{ id: 'saints', de: 'Heilige', en: 'Saints' },
|
||||
{ id: 'eucharistic', de: 'Eucharistie', en: 'Eucharistic' },
|
||||
{ id: 'praise', de: 'Lobpreis', en: 'Praise' },
|
||||
{ id: 'penitential', de: 'Busse', en: 'Penitential' },
|
||||
{ id: 'essential', de: 'Grundgebete', en: 'Essential', la: 'Fundamentales' },
|
||||
{ id: 'marian', de: 'Marianisch', en: 'Marian', la: 'Mariana' },
|
||||
{ id: 'saints', de: 'Heilige', en: 'Saints', la: 'Sancti' },
|
||||
{ id: 'eucharistic', de: 'Eucharistie', en: 'Eucharistic', la: 'Eucharistica' },
|
||||
{ id: 'praise', de: 'Lobpreis', en: 'Praise', la: 'Laudatio' },
|
||||
{ id: 'penitential', de: 'Busse', en: 'Penitential', la: 'Paenitentialia' },
|
||||
];
|
||||
|
||||
const prayerCategories = {
|
||||
@@ -163,7 +169,7 @@
|
||||
]);
|
||||
|
||||
// Base URL for prayer links
|
||||
const baseUrl = $derived(isEnglish ? '/faith/prayers' : '/glaube/gebete');
|
||||
const baseUrl = $derived(isLatin ? '/fides/orationes' : isEnglish ? '/faith/prayers' : '/glaube/gebete');
|
||||
|
||||
// Get prayer name by ID (reactive based on language)
|
||||
/** @param {string} id */
|
||||
@@ -268,6 +274,10 @@
|
||||
// Filtered by category, then sorted by search match
|
||||
const filteredPrayers = $derived.by(() => {
|
||||
let result = prayers;
|
||||
// Latin route: only show prayers that have Latin text
|
||||
if (isLatin) {
|
||||
result = result.filter(p => prayerMeta[p.id]?.bilingue !== false);
|
||||
}
|
||||
if (selectedCategory) {
|
||||
const cat = selectedCategory;
|
||||
result = result.filter(p => /** @type {Record<string, string[]>} */(prayerCategories)[p.id]?.includes(cat));
|
||||
@@ -475,6 +485,7 @@ h1{
|
||||
<div class:js-enabled={jsEnabled}>
|
||||
<h1>{labels.title}</h1>
|
||||
|
||||
{#if !isLatin}
|
||||
<div class="toggle-controls">
|
||||
<LanguageToggle
|
||||
initialLatin={data.initialLatin}
|
||||
@@ -482,15 +493,16 @@ h1{
|
||||
href={latinToggleHref}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<nav class="category-filters" aria-label={isEnglish ? 'Filter by category' : 'Nach Kategorie filtern'}>
|
||||
<nav class="category-filters" aria-label={isLatin ? 'Filtrare per categoriam' : isEnglish ? 'Filter by category' : 'Nach Kategorie filtern'}>
|
||||
<a
|
||||
href={buildFilterHref(null)}
|
||||
class="category-pill"
|
||||
class:selected={!selectedCategory}
|
||||
onclick={(e) => { e.preventDefault(); selectedCategory = null; }
|
||||
}
|
||||
>{isEnglish ? 'All' : 'Alle'}</a>
|
||||
>{isLatin ? 'Omnia' : isEnglish ? 'All' : 'Alle'}</a>
|
||||
{#each categories as cat (cat.id)}
|
||||
<a
|
||||
href={buildFilterHref(cat.id)}
|
||||
@@ -498,7 +510,7 @@ h1{
|
||||
class:selected={selectedCategory === cat.id}
|
||||
onclick={(e) => { e.preventDefault(); selectedCategory = cat.id; }
|
||||
}
|
||||
>{isEnglish ? cat.en : cat.de}</a>
|
||||
>{isLatin ? cat.la : isEnglish ? cat.en : cat.de}</a>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
@@ -557,7 +569,7 @@ h1{
|
||||
<Postcommunio onlyIntro={true} />
|
||||
{/if}
|
||||
{#if prayer.id === 'reginaCaeli' && isEastertide}
|
||||
<span class="seasonal-badge">{isEnglish ? 'Eastertide' : 'Osterzeit'}</span>
|
||||
<span class="seasonal-badge">{isLatin ? 'Tempus Paschale' : isEnglish ? 'Eastertide' : 'Osterzeit'}</span>
|
||||
{/if}
|
||||
</Gebet>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { PageServerLoad } from "./$types";
|
||||
import type { PageServerLoad, Actions } from "./$types";
|
||||
import { error } from "@sveltejs/kit";
|
||||
import { validPrayerSlugs } from '$lib/data/prayerSlugs';
|
||||
|
||||
export const load: PageServerLoad = async ({ params, url }) => {
|
||||
const angelusSlugs = new Set(['angelus', 'regina-caeli']);
|
||||
|
||||
export const load: PageServerLoad = async ({ params, url, locals, fetch }) => {
|
||||
if (!validPrayerSlugs.has(params.prayer)) {
|
||||
throw error(404, 'Prayer not found');
|
||||
}
|
||||
@@ -11,9 +13,89 @@ export const load: PageServerLoad = async ({ params, url }) => {
|
||||
const hasUrlLatin = latinParam !== null;
|
||||
const initialLatin = hasUrlLatin ? latinParam !== '0' : true;
|
||||
|
||||
return {
|
||||
const result: Record<string, unknown> = {
|
||||
prayer: params.prayer,
|
||||
initialLatin,
|
||||
hasUrlLatin
|
||||
};
|
||||
|
||||
// Fetch angelus streak data for angelus/regina-caeli pages
|
||||
if (angelusSlugs.has(params.prayer)) {
|
||||
const session = await locals.auth();
|
||||
if (session?.user?.nickname) {
|
||||
try {
|
||||
const res = await fetch('/api/glaube/angelus-streak');
|
||||
if (res.ok) {
|
||||
result.angelusStreak = await res.json();
|
||||
}
|
||||
} catch {
|
||||
// Fail silently — streak will use localStorage
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
'pray-angelus': async ({ request, locals, fetch }) => {
|
||||
const session = await locals.auth();
|
||||
if (!session?.user?.nickname) {
|
||||
throw error(401, 'Authentication required');
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const time = formData.get('time') as string;
|
||||
|
||||
if (!['morning', 'noon', 'evening'].includes(time)) {
|
||||
throw error(400, 'Invalid time slot');
|
||||
}
|
||||
|
||||
const bits: Record<string, number> = { morning: 1, noon: 2, evening: 4 };
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
|
||||
// Fetch current state
|
||||
let current = { streak: 0, lastComplete: null as string | null, todayPrayed: 0, todayDate: null as string | null };
|
||||
try {
|
||||
const res = await fetch('/api/glaube/angelus-streak');
|
||||
if (res.ok) {
|
||||
current = await res.json();
|
||||
}
|
||||
} catch {
|
||||
// Start fresh
|
||||
}
|
||||
|
||||
// Reset if date rolled over
|
||||
if (current.todayDate !== today) {
|
||||
current.todayPrayed = 0;
|
||||
current.todayDate = today;
|
||||
}
|
||||
|
||||
// Set the bit
|
||||
current.todayPrayed |= bits[time];
|
||||
current.todayDate = today;
|
||||
|
||||
// Check completion
|
||||
if (current.todayPrayed === 7) {
|
||||
const yesterday = new Date();
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
const yesterdayStr = yesterday.toISOString().split('T')[0];
|
||||
|
||||
if (current.lastComplete === yesterdayStr) {
|
||||
current.streak += 1;
|
||||
} else if (current.lastComplete !== today) {
|
||||
current.streak = 1;
|
||||
}
|
||||
current.lastComplete = today;
|
||||
}
|
||||
|
||||
// Save
|
||||
await fetch('/api/glaube/angelus-streak', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(current)
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -23,16 +23,21 @@
|
||||
import AngelusComponent from "$lib/components/faith/prayers/Angelus.svelte";
|
||||
import ReginaCaeli from "$lib/components/faith/prayers/ReginaCaeli.svelte";
|
||||
import StickyImage from "$lib/components/faith/StickyImage.svelte";
|
||||
import AngelusStreakCounter from "$lib/components/faith/AngelusStreakCounter.svelte";
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
const langContext = createLanguageContext({ urlLang: /** @type {'de' | 'en'} */(data.lang), initialLatin: data.initialLatin });
|
||||
const langContext = createLanguageContext({ urlLang: /** @type {'de' | 'en'} */(data.lang), initialLatin: data.lang === 'la' ? true : data.initialLatin });
|
||||
|
||||
$effect(() => {
|
||||
langContext.lang.set(data.lang);
|
||||
if (data.lang === 'la') {
|
||||
langContext.showLatin.set(true);
|
||||
}
|
||||
});
|
||||
|
||||
const isEnglish = $derived(data.lang === 'en');
|
||||
const isLatin = $derived(data.lang === 'la');
|
||||
|
||||
// Prayer definitions with slugs
|
||||
const prayerDefs = $derived({
|
||||
@@ -74,6 +79,12 @@
|
||||
const prayerName = $derived(prayer?.name || data.prayer);
|
||||
const isBilingue = $derived(prayer?.bilingue ?? true);
|
||||
const prayerId = $derived(prayer?.id);
|
||||
const isAngelusPage = $derived(prayerId === 'angelus' || prayerId === 'reginaCaeli');
|
||||
|
||||
const angelusImageCaption = $derived(prayerId === 'reginaCaeli'
|
||||
? { artist: 'Diego Velázquez', title: isEnglish ? 'Coronation of the Virgin' : 'Die Krönung der Jungfrau', year: 1641 }
|
||||
: { artist: 'Bartolomé Esteban Murillo', title: isEnglish ? 'The Annunciation' : 'Die Verkündigung', year: /** @type {number | null} */(null) }
|
||||
);
|
||||
|
||||
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.'
|
||||
@@ -154,10 +165,11 @@ h1 {
|
||||
background-color: var(--nord5);
|
||||
}
|
||||
</style>
|
||||
{#if prayerId === 'postcommunio' || prayerId === 'prayerbeforeacrucifix'}
|
||||
{#if prayerId === 'postcommunio' || prayerId === 'prayerbeforeacrucifix' || isAngelusPage}
|
||||
|
||||
<h1>{prayerName}</h1>
|
||||
|
||||
{#if !isLatin}
|
||||
<div class="toggle-controls">
|
||||
<LanguageToggle
|
||||
initialLatin={data.initialLatin}
|
||||
@@ -165,14 +177,31 @@ h1 {
|
||||
href={latinToggleHref}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<StickyImage src="/glaube/crucifix.webp" alt="Crucifix">
|
||||
{#if isAngelusPage}
|
||||
<AngelusStreakCounter
|
||||
streakData={data.angelusStreak}
|
||||
lang={isLatin ? 'la' : isEnglish ? 'en' : 'de'}
|
||||
isLoggedIn={!!data.session?.user}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<StickyImage
|
||||
src={prayerId === 'reginaCaeli' ? '/glaube/glorious/5-diego-veazquez.coronation-mary.webp' : prayerId === 'angelus' ? '/glaube/joyful/1-murilllo-annunciation.webp' : '/glaube/crucifix.webp'}
|
||||
alt={prayerName}
|
||||
caption={isAngelusPage ? `${angelusImageCaption.artist}, <em>${angelusImageCaption.title}</em>${angelusImageCaption.year ? `, ${angelusImageCaption.year}` : ''}` : ''}
|
||||
>
|
||||
<div class="gebet-wrapper">
|
||||
<div class="gebet" class:bilingue={isBilingue}>
|
||||
{#if prayerId === 'postcommunio'}
|
||||
<Postcommunio onlyIntro={false} />
|
||||
{:else}
|
||||
{:else if prayerId === 'prayerbeforeacrucifix'}
|
||||
<PrayerBeforeACrucifix />
|
||||
{:else if prayerId === 'angelus'}
|
||||
<AngelusComponent verbose={true} />
|
||||
{:else if prayerId === 'reginaCaeli'}
|
||||
<ReginaCaeli />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -181,6 +210,7 @@ h1 {
|
||||
<div class="container">
|
||||
<h1>{prayerName}</h1>
|
||||
|
||||
{#if !isLatin}
|
||||
<div class="toggle-controls">
|
||||
<LanguageToggle
|
||||
initialLatin={data.initialLatin}
|
||||
@@ -188,6 +218,7 @@ h1 {
|
||||
href={latinToggleHref}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
<div class="gebet-wrapper">
|
||||
|
||||
@@ -88,7 +88,7 @@ export const load: PageServerLoad = async ({ url, fetch, locals, params }) => {
|
||||
}
|
||||
|
||||
return {
|
||||
mysteryDescriptions: params.faithLang === 'faith' ? mysteryVerseDataEn : mysteryVerseDataDe,
|
||||
mysteryDescriptions: params.faithLang === 'glaube' ? mysteryVerseDataDe : mysteryVerseDataEn,
|
||||
streakData,
|
||||
isLoggedIn: !!session?.user?.nickname,
|
||||
initialMystery,
|
||||
|
||||
@@ -24,7 +24,7 @@ import RosarySvg from "./RosarySvg.svelte";
|
||||
import MysterySelector from "./MysterySelector.svelte";
|
||||
import MysteryImageColumn from "./MysteryImageColumn.svelte";
|
||||
/** @typedef {import('./rosaryData.js').MysteryType} MysteryType */
|
||||
import { mysteries, mysteriesLatin, mysteriesEnglish, mysteryTitles, mysteryTitlesEnglish, allMysteryImages, getLabels, getMysteryForWeekday, BEAD_SPACING, DECADE_OFFSET, sectionPositions } from "./rosaryData.js";
|
||||
import { mysteries, mysteriesLatin, mysteriesEnglish, mysteryTitles, mysteryTitlesEnglish, mysteryTitlesLatin, allMysteryImages, getLabels, getLabelsLatin, getMysteryForWeekday, BEAD_SPACING, DECADE_OFFSET, sectionPositions } from "./rosaryData.js";
|
||||
import { isEastertide, getLiturgicalSeason } from "$lib/js/easter.svelte";
|
||||
import { setupScrollSync } from "./rosaryScrollSync.js";
|
||||
let { data } = $props();
|
||||
@@ -39,18 +39,24 @@ let showImages = $state(data.initialShowImages);
|
||||
let hasLoadedFromStorage = $state(false);
|
||||
|
||||
// Create language context for prayer components (LanguageToggle will use this)
|
||||
const langContext = createLanguageContext({ urlLang: /** @type {'en'|'de'} */ (data.lang), initialLatin: data.initialLatin });
|
||||
// For Latin route, force showLatin on so only Latin prayers render
|
||||
const langContext = createLanguageContext({ urlLang: /** @type {'en'|'de'} */ (data.lang), initialLatin: data.lang === 'la' ? true : data.initialLatin });
|
||||
|
||||
// Update lang store when data.lang changes (e.g., after navigation)
|
||||
// For Latin route, force showLatin on — Latin text is only rendered when showLatin is true
|
||||
$effect(() => {
|
||||
langContext.lang.set(data.lang);
|
||||
if (data.lang === 'la') {
|
||||
langContext.showLatin.set(true);
|
||||
}
|
||||
});
|
||||
|
||||
// UI labels based on URL language (reactive)
|
||||
const isEnglish = $derived(data.lang === 'en');
|
||||
/** @type {'en'|'de'} */
|
||||
const lang = $derived(isEnglish ? 'en' : 'de');
|
||||
const labels = $derived(getLabels(isEnglish));
|
||||
const isLatin = $derived(data.lang === 'la');
|
||||
/** @type {'en'|'de'|'la'} */
|
||||
const lang = $derived(isLatin ? 'la' : isEnglish ? 'en' : 'de');
|
||||
const labels = $derived(isLatin ? getLabelsLatin() : getLabels(isEnglish));
|
||||
|
||||
// Save toggle states to localStorage whenever they change (but only after initial load)
|
||||
$effect(() => {
|
||||
@@ -72,7 +78,7 @@ let todaysMystery = $state(/** @type {MysteryType} */ (data.todaysMystery));
|
||||
let currentMysteries = $derived(mysteries[selectedMystery]);
|
||||
let currentMysteriesLatin = $derived(mysteriesLatin[selectedMystery]);
|
||||
let currentMysteriesEnglish = $derived(mysteriesEnglish[selectedMystery]);
|
||||
let currentMysteryTitles = $derived(isEnglish ? mysteryTitlesEnglish[selectedMystery] : mysteryTitles[selectedMystery]);
|
||||
let currentMysteryTitles = $derived(isLatin ? mysteryTitlesLatin[selectedMystery] : isEnglish ? mysteryTitlesEnglish[selectedMystery] : mysteryTitles[selectedMystery]);
|
||||
let currentMysteryDescriptions = $derived(data.mysteryDescriptions[selectedMystery] || []);
|
||||
|
||||
// Function to switch mysteries
|
||||
@@ -530,7 +536,8 @@ onMount(() => {
|
||||
}
|
||||
|
||||
/* Reduce min-height in monolingual mode since content is shorter */
|
||||
.prayer-section.decade:has(:global(.prayer-wrapper.monolingual)) {
|
||||
.prayer-section.decade:has(:global(.prayer-wrapper.monolingual)),
|
||||
.prayer-section.decade:has(:global(.prayer-wrapper.lang-la)) {
|
||||
min-height: 30vh;
|
||||
}
|
||||
|
||||
@@ -538,7 +545,8 @@ onMount(() => {
|
||||
.prayer-section.decade {
|
||||
padding-bottom: 1.5rem;
|
||||
}
|
||||
.prayer-section.decade:has(:global(.prayer-wrapper.monolingual)) {
|
||||
.prayer-section.decade:has(:global(.prayer-wrapper.monolingual)),
|
||||
.prayer-section.decade:has(:global(.prayer-wrapper.lang-la)) {
|
||||
min-height: 20vh;
|
||||
}
|
||||
.prayer-section {
|
||||
@@ -802,12 +810,14 @@ h1 {
|
||||
href={imagesToggleHref}
|
||||
/>
|
||||
|
||||
<!-- Language Toggle (link for no-JS, enhanced with onclick for JS) -->
|
||||
<LanguageToggle
|
||||
initialLatin={data.initialLatin}
|
||||
hasUrlLatin={data.hasUrlLatin}
|
||||
href={latinToggleHref}
|
||||
/>
|
||||
<!-- Language Toggle (link for no-JS, enhanced with onclick for JS) — hidden on Latin route -->
|
||||
{#if !isLatin}
|
||||
<LanguageToggle
|
||||
initialLatin={data.initialLatin}
|
||||
hasUrlLatin={data.hasUrlLatin}
|
||||
href={latinToggleHref}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -127,6 +127,38 @@ export const mysteryTitles = {
|
||||
]
|
||||
};
|
||||
|
||||
// Latin short titles for mysteries
|
||||
export const mysteryTitlesLatin = {
|
||||
freudenreich: [
|
||||
"Annuntiatio",
|
||||
"Visitatio",
|
||||
"Nativitas",
|
||||
"Præsentatio",
|
||||
"Inventio in Templo"
|
||||
],
|
||||
schmerzhaften: [
|
||||
"Agonia in Horto",
|
||||
"Flagellatio",
|
||||
"Coronatio Spinis",
|
||||
"Baiulatio Crucis",
|
||||
"Crucifixio"
|
||||
],
|
||||
glorreichen: [
|
||||
"Resurrectio",
|
||||
"Ascensio",
|
||||
"Missio Spiritus Sancti",
|
||||
"Assumptio Mariæ",
|
||||
"Coronatio Mariæ"
|
||||
],
|
||||
lichtreichen: [
|
||||
"Baptisma",
|
||||
"Nuptiæ in Cana",
|
||||
"Prædicatio Regni Dei",
|
||||
"Transfiguratio",
|
||||
"Institutio Eucharistiæ"
|
||||
]
|
||||
};
|
||||
|
||||
// English short titles for mysteries
|
||||
export const mysteryTitlesEnglish = {
|
||||
freudenreich: [
|
||||
@@ -200,6 +232,44 @@ export function getLabels(isEnglish) {
|
||||
};
|
||||
}
|
||||
|
||||
// Latin UI labels
|
||||
export function getLabelsLatin() {
|
||||
return {
|
||||
pageTitle: 'Rosarium Vivum',
|
||||
pageDescription: 'Versio digitalis Rosarii ad precandum.',
|
||||
mysteries: 'Mysteria',
|
||||
today: 'Hodie',
|
||||
joyful: 'Gaudiosa',
|
||||
sorrowful: 'Dolorosa',
|
||||
glorious: 'Gloriosa',
|
||||
luminous: 'Luminosa',
|
||||
includeLuminous: 'Mysteria Luminosa includere',
|
||||
showImages: 'Imagines monstrare',
|
||||
beginning: 'Initium',
|
||||
signOfCross: '♱ Signum Crucis',
|
||||
ourFather: 'Pater Noster',
|
||||
hailMary: 'Ave Maria',
|
||||
faith: 'Fides',
|
||||
hope: 'Spes',
|
||||
love: 'Caritas',
|
||||
decade: 'Decas',
|
||||
optional: 'ad libitum',
|
||||
gloriaPatri: 'Gloria Patri',
|
||||
fatimaPrayer: 'Oratio Fatimensis',
|
||||
conclusion: 'Conclusio',
|
||||
finalPrayer: 'Oratio Finalis',
|
||||
saintMichael: 'Oratio ad Sanctum Michaëlem Archangelum',
|
||||
footnoteSign: 'Hic signum crucis fac',
|
||||
footnoteBow: 'Hic caput inclina',
|
||||
showBibleVerse: 'Versum biblicum monstrare',
|
||||
mysteryFaith: 'Jesus, qui adáugeat nobis fidem',
|
||||
mysteryHope: 'Jesus, qui corróboret nobis spem',
|
||||
mysteryLove: 'Jesus, qui perficiat in nobis caritátem',
|
||||
eastertide: 'Tempus Paschale',
|
||||
lent: 'Quadragesima'
|
||||
};
|
||||
}
|
||||
|
||||
// Get the appropriate mystery for a given weekday
|
||||
/**
|
||||
* @param {Date} date
|
||||
|
||||
@@ -2,6 +2,6 @@ import { redirect } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ params }) => {
|
||||
const prayersPath = params.faithLang === 'faith' ? 'prayers' : 'gebete';
|
||||
const prayersPath = params.faithLang === 'fides' ? 'orationes' : params.faithLang === 'faith' ? 'prayers' : 'gebete';
|
||||
redirect(301, `/${params.faithLang}/${prayersPath}/angelus`);
|
||||
};
|
||||
|
||||
73
src/routes/api/glaube/angelus-streak/+server.ts
Normal file
73
src/routes/api/glaube/angelus-streak/+server.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { json, error, type RequestHandler } from '@sveltejs/kit';
|
||||
import { AngelusStreak } from '$models/AngelusStreak';
|
||||
import { dbConnect } from '$utils/db';
|
||||
|
||||
export const GET: RequestHandler = async ({ locals }) => {
|
||||
const session = await locals.auth();
|
||||
|
||||
if (!session?.user?.nickname) {
|
||||
throw error(401, 'Authentication required');
|
||||
}
|
||||
|
||||
await dbConnect();
|
||||
|
||||
try {
|
||||
const streak = await AngelusStreak.findOne({
|
||||
username: session.user.nickname
|
||||
}).lean() as any;
|
||||
|
||||
return json({
|
||||
streak: streak?.streak ?? 0,
|
||||
lastComplete: streak?.lastComplete ?? null,
|
||||
todayPrayed: streak?.todayPrayed ?? 0,
|
||||
todayDate: streak?.todayDate ?? null
|
||||
});
|
||||
} catch (e) {
|
||||
throw error(500, 'Failed to fetch angelus streak');
|
||||
}
|
||||
};
|
||||
|
||||
export const POST: RequestHandler = async ({ request, locals }) => {
|
||||
const session = await locals.auth();
|
||||
|
||||
if (!session?.user?.nickname) {
|
||||
throw error(401, 'Authentication required');
|
||||
}
|
||||
|
||||
const { streak, lastComplete, todayPrayed, todayDate } = await request.json();
|
||||
|
||||
if (typeof streak !== 'number' || streak < 0) {
|
||||
throw error(400, 'Valid streak required');
|
||||
}
|
||||
|
||||
if (lastComplete !== null && typeof lastComplete !== 'string') {
|
||||
throw error(400, 'Invalid lastComplete format');
|
||||
}
|
||||
|
||||
if (typeof todayPrayed !== 'number' || todayPrayed < 0 || todayPrayed > 7) {
|
||||
throw error(400, 'Invalid todayPrayed bitmask');
|
||||
}
|
||||
|
||||
if (todayDate !== null && typeof todayDate !== 'string') {
|
||||
throw error(400, 'Invalid todayDate format');
|
||||
}
|
||||
|
||||
await dbConnect();
|
||||
|
||||
try {
|
||||
const updated = await AngelusStreak.findOneAndUpdate(
|
||||
{ username: session.user.nickname },
|
||||
{ streak, lastComplete, todayPrayed, todayDate },
|
||||
{ upsert: true, new: true }
|
||||
).lean() as any;
|
||||
|
||||
return json({
|
||||
streak: updated?.streak ?? 0,
|
||||
lastComplete: updated?.lastComplete ?? null,
|
||||
todayPrayed: updated?.todayPrayed ?? 0,
|
||||
todayDate: updated?.todayDate ?? null
|
||||
});
|
||||
} catch (e) {
|
||||
throw error(500, 'Failed to update angelus streak');
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user