rosary: progressive enhancement for no-JS browsers
All checks were successful
CI / update (push) Successful in 1m33s
All checks were successful
CI / update (push) Successful in 1m33s
SVG beads are now anchor links to prayer sections, with CSS :has(:target) highlighting the active bead. Inline mystery images render in each decade by default and hide when JS takes over. StreakCounter uses a form action fallback for logged-in users and hides entirely for anonymous no-JS users. Show images toggle now works via ?images= URL param like the other toggles.
This commit is contained in:
@@ -10,9 +10,10 @@ let streak = $state<ReturnType<typeof getRosaryStreak> | null>(null);
|
||||
interface Props {
|
||||
streakData?: { length: number; lastPrayed: string | null } | null;
|
||||
lang?: 'de' | 'en';
|
||||
isLoggedIn?: boolean;
|
||||
}
|
||||
|
||||
let { streakData = null, lang = 'de' }: Props = $props();
|
||||
let { streakData = null, lang = 'de', isLoggedIn = false }: Props = $props();
|
||||
|
||||
const isEnglish = $derived(lang === 'en');
|
||||
|
||||
@@ -42,14 +43,15 @@ async function pray() {
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="streak-container">
|
||||
<div class="streak-container" class:no-js-hidden={!isLoggedIn}>
|
||||
<div class="streak-display">
|
||||
<StreakAura value={displayLength} {burst} />
|
||||
<span class="streak-label">{labels.days}</span>
|
||||
</div>
|
||||
<form method="POST" action="?/pray" onsubmit={(e) => { e.preventDefault(); pray(); }}>
|
||||
<button
|
||||
class="streak-button"
|
||||
onclick={pray}
|
||||
type="submit"
|
||||
disabled={prayedToday}
|
||||
aria-label={labels.ariaLabel}
|
||||
>
|
||||
@@ -59,6 +61,7 @@ async function pray() {
|
||||
{labels.prayed}
|
||||
{/if}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@@ -119,6 +122,15 @@ async function pray() {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* Hide for non-logged-in users without JS (no form action available) */
|
||||
.no-js-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:global(html.js-enabled) .no-js-hidden {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
.streak-button:disabled {
|
||||
background: var(--nord4);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { mysteryVerseDataDe, mysteryVerseDataEn } from '$lib/data/mysteryVerseData';
|
||||
import type { PageServerLoad } from './$types';
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
|
||||
interface StreakData {
|
||||
length: number;
|
||||
@@ -43,13 +43,16 @@ export const load: PageServerLoad = async ({ url, fetch, locals, params }) => {
|
||||
const luminousParam = url.searchParams.get('luminous');
|
||||
const latinParam = url.searchParams.get('latin');
|
||||
const mysteryParam = url.searchParams.get('mystery');
|
||||
const imagesParam = url.searchParams.get('images');
|
||||
|
||||
const hasUrlLuminous = luminousParam !== null;
|
||||
const hasUrlLatin = latinParam !== null;
|
||||
const hasUrlMystery = mysteryParam !== null;
|
||||
const hasUrlImages = imagesParam !== null;
|
||||
|
||||
const initialLuminous = hasUrlLuminous ? luminousParam !== '0' : true;
|
||||
const initialLatin = hasUrlLatin ? latinParam !== '0' : true;
|
||||
const initialShowImages = hasUrlImages ? imagesParam !== '0' : true;
|
||||
|
||||
const todaysMystery = getMysteryForWeekday(new Date(), initialLuminous);
|
||||
|
||||
@@ -78,12 +81,46 @@ export const load: PageServerLoad = async ({ url, fetch, locals, params }) => {
|
||||
return {
|
||||
mysteryDescriptions: params.faithLang === 'faith' ? mysteryVerseDataEn : mysteryVerseDataDe,
|
||||
streakData,
|
||||
isLoggedIn: !!session?.user?.nickname,
|
||||
initialMystery,
|
||||
todaysMystery,
|
||||
initialLuminous,
|
||||
initialLatin,
|
||||
hasUrlMystery,
|
||||
hasUrlLuminous,
|
||||
hasUrlLatin
|
||||
hasUrlLatin,
|
||||
initialShowImages,
|
||||
hasUrlImages
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
pray: async ({ locals, fetch }) => {
|
||||
const session = await locals.auth();
|
||||
if (!session?.user?.nickname) {
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
const res = await fetch('/api/glaube/rosary-streak');
|
||||
const current = res.ok ? await res.json() : { length: 0, lastPrayed: null };
|
||||
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
if (current.lastPrayed === today) {
|
||||
return { success: true, alreadyPrayed: true };
|
||||
}
|
||||
|
||||
const yesterday = new Date();
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
const yesterdayStr = yesterday.toISOString().split('T')[0];
|
||||
|
||||
const newLength = current.lastPrayed === yesterdayStr ? current.length + 1 : 1;
|
||||
|
||||
await fetch('/api/glaube/rosary-streak', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ length: newLength, lastPrayed: today })
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -29,8 +29,8 @@ let { data } = $props();
|
||||
// Toggle for including Luminous mysteries (initialized from URL param or default)
|
||||
let includeLuminous = $state(data.initialLuminous);
|
||||
|
||||
// Toggle for showing mystery images
|
||||
let showImages = $state(true);
|
||||
// Toggle for showing mystery images (initialized from URL param or default)
|
||||
let showImages = $state(data.initialShowImages);
|
||||
|
||||
// Flag to prevent saving before we've loaded from localStorage
|
||||
let hasLoadedFromStorage = $state(false);
|
||||
@@ -76,11 +76,12 @@ function selectMystery(mysteryType) {
|
||||
}
|
||||
|
||||
// Build URLs preserving full state (for no-JS fallback)
|
||||
function buildHref({ mystery = selectedMystery, luminous = includeLuminous, latin = data.initialLatin } = {}) {
|
||||
function buildHref({ mystery = selectedMystery, luminous = includeLuminous, latin = data.initialLatin, images = showImages } = {}) {
|
||||
const params = new URLSearchParams();
|
||||
params.set('mystery', mystery);
|
||||
if (!luminous) params.set('luminous', '0');
|
||||
if (!latin) params.set('latin', '0');
|
||||
if (!images) params.set('images', '0');
|
||||
return `?${params.toString()}`;
|
||||
}
|
||||
|
||||
@@ -91,6 +92,7 @@ function mysteryHref(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 }));
|
||||
let imagesToggleHref = $derived(buildHref({ images: !showImages }));
|
||||
|
||||
// When luminous toggle changes, update today's mystery and fix invalid selection
|
||||
$effect(() => {
|
||||
@@ -264,10 +266,12 @@ onMount(() => {
|
||||
includeLuminous = savedIncludeLuminous === 'true';
|
||||
}
|
||||
}
|
||||
if (!data.hasUrlImages) {
|
||||
const savedShowImages = localStorage.getItem('rosary_showImages');
|
||||
if (savedShowImages !== null) {
|
||||
showImages = savedShowImages === 'true';
|
||||
}
|
||||
}
|
||||
|
||||
// If no mystery was specified in URL, recompute based on loaded preferences
|
||||
if (!data.hasUrlMystery) {
|
||||
@@ -283,6 +287,9 @@ onMount(() => {
|
||||
// Now allow saving to localStorage
|
||||
hasLoadedFromStorage = true;
|
||||
|
||||
// Mark JS as active so no-JS fallback images hide
|
||||
document.documentElement.classList.add('js-enabled');
|
||||
|
||||
// PiP resize handler — show/hide when crossing the breakpoint
|
||||
const onPipResize = () => {
|
||||
if (!rosaryPipEl) return;
|
||||
@@ -306,6 +313,7 @@ onMount(() => {
|
||||
return () => {
|
||||
cleanupScrollSync();
|
||||
window.removeEventListener('resize', onPipResize);
|
||||
document.documentElement.classList.remove('js-enabled');
|
||||
};
|
||||
});
|
||||
</script>
|
||||
@@ -407,6 +415,7 @@ onMount(() => {
|
||||
|
||||
.prayer-section {
|
||||
scroll-snap-align: start;
|
||||
scroll-margin-top: 3rem;
|
||||
padding: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
background: var(--accent-dark);
|
||||
@@ -415,6 +424,40 @@ onMount(() => {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* No-JS: highlight SVG beads when a prayer section is :target */
|
||||
.rosary-layout:has(#start1:target) :global(.bead[data-section="start1"]),
|
||||
.rosary-layout:has(#start2:target) :global(.bead[data-section="start2"]),
|
||||
.rosary-layout:has(#start3:target) :global(.bead[data-section="start3"]),
|
||||
.rosary-layout:has(#secret1:target) :global(.bead[data-section="secret1"]),
|
||||
.rosary-layout:has(#secret2:target) :global(.bead[data-section="secret2"]),
|
||||
.rosary-layout:has(#secret3:target) :global(.bead[data-section="secret3"]),
|
||||
.rosary-layout:has(#secret4:target) :global(.bead[data-section="secret4"]),
|
||||
.rosary-layout:has(#secret5:target) :global(.bead[data-section="secret5"]),
|
||||
.rosary-layout:has(#final_salve:target) :global(.bead[data-section="final_salve"]),
|
||||
.rosary-layout:has(#final_schlussgebet:target) :global(.bead[data-section="final_schlussgebet"]),
|
||||
.rosary-layout:has(#final_michael:target) :global(.bead[data-section="final_michael"]) {
|
||||
fill: var(--nord11) !important;
|
||||
filter: drop-shadow(0 0 8px var(--nord11));
|
||||
}
|
||||
|
||||
.rosary-layout:has(#lbead1:target) :global(.large-bead[data-section="lbead1"]),
|
||||
.rosary-layout:has(#lbead2:target) :global(.large-bead[data-section="lbead2"]),
|
||||
.rosary-layout:has(#secret1_transition:target) :global(.large-bead[data-section="secret1_transition"]),
|
||||
.rosary-layout:has(#secret2_transition:target) :global(.large-bead[data-section="secret2_transition"]),
|
||||
.rosary-layout:has(#secret3_transition:target) :global(.large-bead[data-section="secret3_transition"]),
|
||||
.rosary-layout:has(#secret4_transition:target) :global(.large-bead[data-section="secret4_transition"]),
|
||||
.rosary-layout:has(#final_transition:target) :global(.large-bead[data-section="final_transition"]),
|
||||
.rosary-layout:has(#final_paternoster:target) :global(.large-bead[data-section="final_paternoster"]) {
|
||||
fill: var(--nord13) !important;
|
||||
filter: drop-shadow(0 0 10px var(--nord13));
|
||||
}
|
||||
|
||||
.rosary-layout:has(#cross:target) :global([data-section="cross"] .cross-symbol),
|
||||
.rosary-layout:has(#final_cross:target) :global([data-section="final_cross"] .cross-symbol) {
|
||||
fill: var(--nord11) !important;
|
||||
filter: drop-shadow(0 0 10px var(--nord11));
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
.prayer-section {
|
||||
background: var(--nord5);
|
||||
@@ -597,6 +640,28 @@ h1 {
|
||||
height: 50vh;
|
||||
}
|
||||
|
||||
/* Inline mystery images: visible without JS, hidden when JS takes over */
|
||||
.decade-inline-image {
|
||||
margin: 1rem auto;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.decade-inline-image img {
|
||||
max-width: 100%;
|
||||
max-height: 40vh;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.decade-inline-image figcaption {
|
||||
font-size: 0.85rem;
|
||||
color: var(--nord4);
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
:global(html.js-enabled) .decade-inline-image {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Mystery images: third grid column (desktop), PiP (mobile) */
|
||||
.mystery-image-column {
|
||||
display: none;
|
||||
@@ -633,7 +698,7 @@ h1 {
|
||||
|
||||
<!-- Toggle Controls & Streak Counter -->
|
||||
<div class="controls-row">
|
||||
<StreakCounter streakData={data.streakData} lang={data.lang} />
|
||||
<StreakCounter streakData={data.streakData} lang={data.lang} isLoggedIn={data.isLoggedIn} />
|
||||
<div class="toggle-controls">
|
||||
<!-- Luminous Mysteries Toggle (link for no-JS, enhanced with onclick for JS) -->
|
||||
<Toggle
|
||||
@@ -645,6 +710,7 @@ h1 {
|
||||
<Toggle
|
||||
bind:checked={showImages}
|
||||
label={labels.showImages}
|
||||
href={imagesToggleHref}
|
||||
/>
|
||||
|
||||
<!-- Language Toggle (link for no-JS, enhanced with onclick for JS) -->
|
||||
@@ -669,6 +735,7 @@ h1 {
|
||||
<!-- Cross & Credo -->
|
||||
<div
|
||||
class="prayer-section"
|
||||
id="cross"
|
||||
bind:this={sectionElements.cross}
|
||||
data-section="cross"
|
||||
>
|
||||
@@ -686,6 +753,7 @@ h1 {
|
||||
<!-- First Large Bead -->
|
||||
<div
|
||||
class="prayer-section"
|
||||
id="lbead1"
|
||||
bind:this={sectionElements.lbead1}
|
||||
data-section="lbead1"
|
||||
>
|
||||
@@ -696,6 +764,7 @@ h1 {
|
||||
<!-- First Ave Maria (Faith) -->
|
||||
<div
|
||||
class="prayer-section"
|
||||
id="start1"
|
||||
bind:this={sectionElements.start1}
|
||||
data-section="start1"
|
||||
>
|
||||
@@ -710,6 +779,7 @@ h1 {
|
||||
<!-- Second Ave Maria (Hope) -->
|
||||
<div
|
||||
class="prayer-section"
|
||||
id="start2"
|
||||
bind:this={sectionElements.start2}
|
||||
data-section="start2"
|
||||
>
|
||||
@@ -724,6 +794,7 @@ h1 {
|
||||
<!-- Third Ave Maria (Love) -->
|
||||
<div
|
||||
class="prayer-section"
|
||||
id="start3"
|
||||
bind:this={sectionElements.start3}
|
||||
data-section="start3"
|
||||
>
|
||||
@@ -738,6 +809,7 @@ h1 {
|
||||
<!-- Gloria Patri before decades -->
|
||||
<div
|
||||
class="prayer-section"
|
||||
id="lbead2"
|
||||
bind:this={sectionElements.lbead2}
|
||||
data-section="lbead2"
|
||||
>
|
||||
@@ -752,11 +824,20 @@ h1 {
|
||||
<!-- Ave Maria decade (Gesätz) -->
|
||||
<div
|
||||
class="prayer-section decade"
|
||||
id={`secret${decadeNum}`}
|
||||
bind:this={sectionElements[`secret${decadeNum}`]}
|
||||
data-section={`secret${decadeNum}`}
|
||||
>
|
||||
<h2>{decadeNum}. {labels.decade}: {currentMysteryTitles[decadeNum - 1]}</h2>
|
||||
|
||||
{#if showImages && allMysteryImages[selectedMystery]?.get(decadeNum)}
|
||||
{@const img = allMysteryImages[selectedMystery].get(decadeNum)}
|
||||
<figure class="decade-inline-image">
|
||||
<img src={img.src} alt={isEnglish ? img.title : img.titleDe} loading="lazy" />
|
||||
<figcaption>{img.artist ? `${img.artist}, ` : ''}<em>{isEnglish ? img.title : img.titleDe}</em>{img.year ? `, ${img.year}` : ''}</figcaption>
|
||||
</figure>
|
||||
{/if}
|
||||
|
||||
<h3>{labels.hailMary} <span class="repeat-count">(10×)</span></h3>
|
||||
<AveMaria
|
||||
mysteryLatin={currentMysteriesLatin[decadeNum - 1]}
|
||||
@@ -784,6 +865,7 @@ h1 {
|
||||
{#if decadeNum < 5}
|
||||
<div
|
||||
class="prayer-section"
|
||||
id={`secret${decadeNum}_transition`}
|
||||
bind:this={sectionElements[`secret${decadeNum}_transition`]}
|
||||
data-section={`secret${decadeNum}_transition`}
|
||||
>
|
||||
@@ -802,6 +884,7 @@ h1 {
|
||||
<!-- Final prayers after 5th decade -->
|
||||
<div
|
||||
class="prayer-section"
|
||||
id="final_transition"
|
||||
bind:this={sectionElements.final_transition}
|
||||
data-section="final_transition"
|
||||
>
|
||||
@@ -816,6 +899,7 @@ h1 {
|
||||
|
||||
<div
|
||||
class="prayer-section"
|
||||
id="final_salve"
|
||||
bind:this={sectionElements.final_salve}
|
||||
data-section="final_salve"
|
||||
>
|
||||
@@ -825,6 +909,7 @@ h1 {
|
||||
|
||||
<div
|
||||
class="prayer-section"
|
||||
id="final_schlussgebet"
|
||||
bind:this={sectionElements.final_schlussgebet}
|
||||
data-section="final_schlussgebet"
|
||||
>
|
||||
@@ -834,6 +919,7 @@ h1 {
|
||||
|
||||
<div
|
||||
class="prayer-section"
|
||||
id="final_michael"
|
||||
bind:this={sectionElements.final_michael}
|
||||
data-section="final_michael"
|
||||
>
|
||||
@@ -843,6 +929,7 @@ h1 {
|
||||
|
||||
<div
|
||||
class="prayer-section"
|
||||
id="final_paternoster"
|
||||
bind:this={sectionElements.final_paternoster}
|
||||
data-section="final_paternoster"
|
||||
>
|
||||
@@ -852,6 +939,7 @@ h1 {
|
||||
|
||||
<div
|
||||
class="prayer-section"
|
||||
id="final_cross"
|
||||
bind:this={sectionElements.final_cross}
|
||||
data-section="final_cross"
|
||||
>
|
||||
|
||||
@@ -128,34 +128,34 @@
|
||||
class="cross-symbol" class:active-cross={activeSection === 'final_cross'} />
|
||||
</g>
|
||||
|
||||
<!-- Invisible hitboxes for larger tap targets -->
|
||||
<!-- Invisible hitboxes for larger tap targets (anchor links for no-JS fallback) -->
|
||||
<g class="hitboxes">
|
||||
<!-- Cross hitbox -->
|
||||
<rect x="-15" y="-30" width="80" height="80" data-section="cross" />
|
||||
<a href="#cross"><rect x="-15" y="-30" width="80" height="80" data-section="cross" /></a>
|
||||
|
||||
<!-- Individual bead hitboxes -->
|
||||
<circle cx="25" cy={pos.lbead1} r="25" data-section="lbead1" />
|
||||
<circle cx="25" cy={pos.start1} r="20" data-section="start1" />
|
||||
<circle cx="25" cy={pos.start2} r="20" data-section="start2" />
|
||||
<circle cx="25" cy={pos.start3} r="20" data-section="start3" />
|
||||
<circle cx="25" cy={pos.lbead2} r="25" data-section="lbead2" />
|
||||
<a href="#lbead1"><circle cx="25" cy={pos.lbead1} r="25" data-section="lbead1" /></a>
|
||||
<a href="#start1"><circle cx="25" cy={pos.start1} r="20" data-section="start1" /></a>
|
||||
<a href="#start2"><circle cx="25" cy={pos.start2} r="20" data-section="start2" /></a>
|
||||
<a href="#start3"><circle cx="25" cy={pos.start3} r="20" data-section="start3" /></a>
|
||||
<a href="#lbead2"><circle cx="25" cy={pos.lbead2} r="25" data-section="lbead2" /></a>
|
||||
|
||||
<!-- Decade hitboxes -->
|
||||
{#each [1, 2, 3, 4, 5] as d (d)}
|
||||
{@const decadePos = pos[`secret${d}`]}
|
||||
<rect x="-15" y={decadePos - 2} width="80" height={DECADE_OFFSET + 9 * BEAD_SPACING + 12} data-section={`secret${d}`} />
|
||||
<a href={`#secret${d}`}><rect x="-15" y={decadePos - 2} width="80" height={DECADE_OFFSET + 9 * BEAD_SPACING + 12} data-section={`secret${d}`} /></a>
|
||||
{/each}
|
||||
|
||||
<!-- Transition bead hitboxes -->
|
||||
{#each [1, 2, 3, 4] as d (d)}
|
||||
<circle cx="25" cy={pos[`secret${d}_transition`]} r="25" data-section={`secret${d}_transition`} />
|
||||
<a href={`#secret${d}_transition`}><circle cx="25" cy={pos[`secret${d}_transition`]} r="25" data-section={`secret${d}_transition`} /></a>
|
||||
{/each}
|
||||
<circle cx="25" cy={pos.final_transition} r="25" data-section="final_transition" />
|
||||
<circle cx="25" cy={pos.final_salve} r="20" data-section="final_salve" />
|
||||
<circle cx="25" cy={pos.final_schlussgebet} r="20" data-section="final_schlussgebet" />
|
||||
<circle cx="25" cy={pos.final_michael} r="20" data-section="final_michael" />
|
||||
<circle cx="25" cy={pos.final_paternoster} r="25" data-section="final_paternoster" />
|
||||
<rect x="-15" y={pos.final_cross - 50} width="80" height="80" data-section="final_cross" />
|
||||
<a href="#final_transition"><circle cx="25" cy={pos.final_transition} r="25" data-section="final_transition" /></a>
|
||||
<a href="#final_salve"><circle cx="25" cy={pos.final_salve} r="20" data-section="final_salve" /></a>
|
||||
<a href="#final_schlussgebet"><circle cx="25" cy={pos.final_schlussgebet} r="20" data-section="final_schlussgebet" /></a>
|
||||
<a href="#final_michael"><circle cx="25" cy={pos.final_michael} r="20" data-section="final_michael" /></a>
|
||||
<a href="#final_paternoster"><circle cx="25" cy={pos.final_paternoster} r="25" data-section="final_paternoster" /></a>
|
||||
<a href="#final_cross"><rect x="-15" y={pos.final_cross - 50} width="80" height="80" data-section="final_cross" /></a>
|
||||
</g>
|
||||
|
||||
</svg>
|
||||
|
||||
@@ -194,6 +194,7 @@ export function setupScrollSync({
|
||||
}
|
||||
|
||||
// Handle clicks on SVG elements to jump to prayers
|
||||
// preventDefault() overrides the anchor-link fallback when JS is enabled
|
||||
const handleSvgClick = (e) => {
|
||||
const svgContainer = getSvgContainer();
|
||||
const sectionElements = getSectionElements();
|
||||
@@ -201,6 +202,7 @@ export function setupScrollSync({
|
||||
while (target && target !== svgContainer) {
|
||||
const section = target.dataset.section;
|
||||
if (section && sectionElements[section]) {
|
||||
e.preventDefault();
|
||||
setActiveSection(section);
|
||||
setScrollLock('click', 1500);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user