rosary: add mystery images for all four mystery types with PiP fullscreen
All checks were successful
CI / update (push) Successful in 1m29s

Generalize mystery images from sorrowful-only to all mystery types (joyful,
sorrowful, glorious, luminous). Add PiP fullscreen mode with tap-to-show
controls and double-tap to toggle enlarged/fullscreen.
This commit is contained in:
2026-02-09 23:02:32 +01:00
parent 07554f16df
commit 6eaf0bb4f4
17 changed files with 227 additions and 61 deletions

View File

@@ -10,6 +10,7 @@ interface PipOptions {
initialCorner?: Corner; initialCorner?: Corner;
smallHeight?: number; smallHeight?: number;
largeHeight?: number; largeHeight?: number;
fullscreenEnabled?: boolean;
} }
type Corner = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'; type Corner = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
@@ -17,13 +18,16 @@ type Corner = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
export function createPip(opts: PipOptions = {}) { export function createPip(opts: PipOptions = {}) {
const margin = opts.margin ?? 16; const margin = opts.margin ?? 16;
const tapThreshold = opts.tapThreshold ?? 10; const tapThreshold = opts.tapThreshold ?? 10;
const doubleTapMs = opts.doubleTapMs ?? 400; const doubleTapMs = opts.doubleTapMs ?? 250;
const smallVh = opts.smallHeight ?? 25; const smallVh = opts.smallHeight ?? 25;
const largeVh = opts.largeHeight ?? 37.5; const largeVh = opts.largeHeight ?? 37.5;
const fullscreenEnabled = opts.fullscreenEnabled ?? false;
let corner: Corner = $state(opts.initialCorner ?? 'bottom-right'); let corner: Corner = $state(opts.initialCorner ?? 'bottom-right');
let dragging = $state(false); let dragging = $state(false);
let enlarged = $state(false); let enlarged = $state(false);
let fullscreen = $state(false);
let showControls = $state(false);
let dragOffset = { x: 0, y: 0 }; let dragOffset = { x: 0, y: 0 };
let dragPos = { x: 0, y: 0 }; let dragPos = { x: 0, y: 0 };
@@ -59,7 +63,7 @@ export function createPip(opts: PipOptions = {}) {
const pos = getCornerPos(c, target); const pos = getCornerPos(c, target);
corner = c; corner = c;
dragPos = pos; dragPos = pos;
target.style.transition = 'transform 0.25s ease'; target.style.transition = 'transform 0.25s cubic-bezier(0.4, 0, 0.2, 1)';
target.style.transform = `translate(${pos.x}px, ${pos.y}px)`; target.style.transform = `translate(${pos.x}px, ${pos.y}px)`;
target.addEventListener('transitionend', () => { target.style.transition = ''; }, { once: true }); target.addEventListener('transitionend', () => { target.style.transition = ''; }, { once: true });
} }
@@ -82,15 +86,77 @@ export function createPip(opts: PipOptions = {}) {
if (corner.includes('bottom')) newY = rect.bottom - newH; if (corner.includes('bottom')) newY = rect.bottom - newH;
dragPos = { x: newX, y: newY }; dragPos = { x: newX, y: newY };
el.style.transition = 'transform 0.25s ease'; el.style.transition = 'transform 0.25s cubic-bezier(0.4, 0, 0.2, 1)';
el.style.transform = `translate(${newX}px, ${newY}px)`; el.style.transform = `translate(${newX}px, ${newY}px)`;
el.addEventListener('transitionend', () => { el.addEventListener('transitionend', () => {
el!.style.transition = ''; el!.style.transition = '';
}, { once: true }); }, { once: true });
} }
function toggleFullscreen() {
if (!el) return;
showControls = false;
lastTapTime = 0; // cancel any pending single-tap timeout
const img = el.querySelector('img') as HTMLImageElement | null;
if (!fullscreen) {
// Enter fullscreen: dark bg appears, image grows + moves to center
fullscreen = true;
el.style.transition = 'transform 0.3s cubic-bezier(0.4, 0, 0.2, 1)';
el.style.transform = 'translate(0px, 0px)';
if (img) {
img.style.transition = 'height 0.3s cubic-bezier(0.4, 0, 0.2, 1)';
img.style.height = '90vh';
img.style.maxWidth = '95vw';
img.style.maxHeight = '90vh';
}
setTimeout(() => {
if (el) el.style.transition = '';
if (img) img.style.transition = '';
}, 350);
} else {
// Exit fullscreen: image shrinks + moves to corner, then bg removed
const vh = window.innerHeight;
const vw = window.innerWidth;
const pipH = vh * (smallVh / 100);
let pipW = pipH;
if (img && img.naturalHeight > 0) {
pipW = pipH * (img.naturalWidth / img.naturalHeight);
}
const pos = {
x: corner.includes('right') ? vw - pipW - margin : margin,
y: corner.includes('bottom') ? vh - pipH - margin : margin,
};
dragPos = pos;
el.style.transition = 'transform 0.3s cubic-bezier(0.4, 0, 0.2, 1)';
el.style.transform = `translate(${pos.x}px, ${pos.y}px)`;
if (img) {
img.style.transition = 'height 0.3s cubic-bezier(0.4, 0, 0.2, 1)';
img.style.height = `${smallVh}vh`;
img.style.maxWidth = '';
img.style.maxHeight = '';
}
setTimeout(() => {
fullscreen = false;
enlarged = false;
if (el) el.style.transition = '';
if (img) {
img.style.transition = '';
img.style.height = '';
}
}, 350);
}
}
function show(target: HTMLElement) { function show(target: HTMLElement) {
el = target; el = target;
showControls = false;
if (fullscreen) {
target.style.opacity = '1';
return;
}
const pos = getCornerPos(corner, target); const pos = getCornerPos(corner, target);
dragPos = pos; dragPos = pos;
target.style.transform = `translate(${pos.x}px, ${pos.y}px)`; target.style.transform = `translate(${pos.x}px, ${pos.y}px)`;
@@ -98,11 +164,25 @@ export function createPip(opts: PipOptions = {}) {
} }
function hide() { function hide() {
if (fullscreen) {
fullscreen = false;
showControls = false;
if (el) {
const img = el.querySelector('img') as HTMLElement | null;
if (img) {
img.style.transition = '';
img.style.height = '';
img.style.maxWidth = '';
img.style.maxHeight = '';
}
el.style.transition = '';
}
}
if (el) el.style.opacity = '0'; if (el) el.style.opacity = '0';
} }
function reposition() { function reposition() {
if (!el) return; if (!el || fullscreen) return;
const pos = getCornerPos(corner, el); const pos = getCornerPos(corner, el);
dragPos = pos; dragPos = pos;
el.style.transform = `translate(${pos.x}px, ${pos.y}px)`; el.style.transform = `translate(${pos.x}px, ${pos.y}px)`;
@@ -112,15 +192,19 @@ export function createPip(opts: PipOptions = {}) {
if (!el) return; if (!el) return;
dragging = true; dragging = true;
dragMoved = false; dragMoved = false;
if (!fullscreen) {
const r = el.getBoundingClientRect(); const r = el.getBoundingClientRect();
dragOffset = { x: e.clientX - r.left, y: e.clientY - r.top }; dragOffset = { x: e.clientX - r.left, y: e.clientY - r.top };
el.setPointerCapture(e.pointerId);
el.style.transition = ''; el.style.transition = '';
}
el.setPointerCapture(e.pointerId);
e.preventDefault(); e.preventDefault();
} }
function onpointermove(e: PointerEvent) { function onpointermove(e: PointerEvent) {
if (!dragging || !el) return; if (!dragging || !el || fullscreen) return;
const x = e.clientX - dragOffset.x; const x = e.clientX - dragOffset.x;
const y = e.clientY - dragOffset.y; const y = e.clientY - dragOffset.y;
if (!dragMoved) { if (!dragMoved) {
@@ -140,23 +224,40 @@ export function createPip(opts: PipOptions = {}) {
const now = Date.now(); const now = Date.now();
if (now - lastTapTime < doubleTapMs) { if (now - lastTapTime < doubleTapMs) {
lastTapTime = 0; lastTapTime = 0;
if (fullscreen) {
toggleFullscreen(); // exit fullscreen
} else {
toggleEnlarged(); toggleEnlarged();
}
return; return;
} }
lastTapTime = now; lastTapTime = now;
if (fullscreenEnabled) {
// Delayed single tap: toggle controls (skipped if double-tap follows)
const tapTime = now;
setTimeout(() => {
if (lastTapTime === tapTime) showControls = !showControls;
}, doubleTapMs);
}
return;
} }
if (!fullscreen) {
const r = el.getBoundingClientRect(); const r = el.getBoundingClientRect();
snapToCorner(el, nearestCorner(r.left, r.top, el)); snapToCorner(el, nearestCorner(r.left, r.top, el));
} }
}
return { return {
get corner() { return corner; }, get corner() { return corner; },
get dragging() { return dragging; }, get dragging() { return dragging; },
get enlarged() { return enlarged; }, get enlarged() { return enlarged; },
get fullscreen() { return fullscreen; },
get showControls() { return showControls; },
show, show,
hide, hide,
reposition, reposition,
toggleFullscreen,
onpointerdown, onpointerdown,
onpointermove, onpointermove,
onpointerup onpointerup

View File

@@ -323,63 +323,65 @@ let activeSection = $state("cross");
let sectionElements = {}; let sectionElements = {};
let svgContainer; let svgContainer;
// Whether the rosary has mystery images (stable, doesn't change during scroll) // Mystery images with captions (per mystery type, keyed by decade number 1-5)
const hasMysteryImages = $derived(showImages && selectedMystery === 'schmerzhaften'); const allMysteryImages = {
freudenreich: new Map([
[1, { src: "/glaube/joyful/1-murilllo-annunciation.webp", artist: "Bartolomé Esteban Murillo", title: "The Annunciation", titleDe: "Die Verkündigung" }],
[2, { src: "/glaube/joyful/2-carl-bloch.the-visitation.1866.webp", artist: "Carl Bloch", title: "The Visitation", titleDe: "Die Heimsuchung", year: 1866 }],
[3, { src: "/glaube/joyful/3-adoration-of-the-shepards.webp", title: "Adoration of the Shepherds", titleDe: "Die Anbetung der Hirten" }],
[4, { src: "/glaube/joyful/4-vouet.presentation-in-the-temple.webp", artist: "Simon Vouet", title: "The Presentation in the Temple", titleDe: "Die Darstellung im Tempel" }],
[5, { src: "/glaube/joyful/5-carl-bloch.the-twelve-year-old-jesus-in-the-temple.1869.webp", artist: "Carl Bloch", title: "The Twelve-Year-Old Jesus in the Temple", titleDe: "Der zwölfjährige Jesus im Tempel", year: 1869 }],
]),
schmerzhaften: new Map([
[1, { src: "/glaube/sorrowful/1.carl-bloch.gethsemane.webp", artist: "Carl Bloch", title: "Gethsemane", titleDe: "Gethsemane", year: 1873 }],
[2, { src: "/glaube/sorrowful/2.wiliam-bouguereau.flagellation.webp", artist: "William-Adolphe Bouguereau", title: "The Flagellation of Our Lord Jesus Christ", titleDe: "Die Geisselung unseres Herrn Jesus Christus", year: 1880 }],
[3, { src: "/glaube/sorrowful/3.carl-bloch.mocking.webp", artist: "Carl Bloch", title: "The Mocking of Christ", titleDe: "Die Verspottung Christi", year: 1880 }],
[4, { src: "/glaube/sorrowful/4.lorenzo-lotto.carrying-the-cross.webp", artist: "Lorenzo Lotto", title: "Carrying the Cross", titleDe: "Die Kreuztragung", year: 1526 }],
[5, { src: "/glaube/sorrowful/5.alonso-cano.the-crucifixion.webp", artist: "Diego Velázquez", title: "Christ Crucified", titleDe: "Der gekreuzigte Christus", year: 1632 }],
]),
glorreichen: new Map([
[1, { src: "/glaube/glorious/1-carl-bloch.resurrection.webp", artist: "Carl Bloch", title: "The Resurrection", titleDe: "Die Auferstehung" }],
[2, { src: "/glaube/glorious/2-ascension.webp", title: "The Ascension", titleDe: "Die Himmelfahrt" }],
[3, { src: "/glaube/glorious/3-pentecost.webp", title: "Pentecost", titleDe: "Die Geistsendung" }],
[4, { src: "/glaube/glorious/4-giovanni-tiepolo.the-immaculate-conception.webp", artist: "Giovanni Battista Tiepolo", title: "The Immaculate Conception", titleDe: "Die Aufnahme Mariens in den Himmel" }],
[5, { src: "/glaube/glorious/5-diego-veazquez.coronation-mary.webp", artist: "Diego Velázquez", title: "Coronation of the Virgin", titleDe: "Die Krönung der Jungfrau", year: 1641 }],
]),
lichtreichen: new Map([
[1, { src: "/glaube/luminous/1-carl-bloch.the-baptism-of-christ.1870.webp", artist: "Carl Bloch", title: "The Baptism of Christ", titleDe: "Die Taufe Christi", year: 1870 }],
[2, { src: "/glaube/luminous/2-carl-bloch.the-wedding-at-cana.1870.webp", artist: "Carl Bloch", title: "The Wedding at Cana", titleDe: "Die Hochzeit zu Kana", year: 1870 }],
[3, { src: "/glaube/luminous/3-carl-bloch.the-sermon-on-the-mount.1877.jpg", artist: "Carl Bloch", title: "The Sermon on the Mount", titleDe: "Die Bergpredigt", year: 1877 }],
[4, { src: "/glaube/luminous/4-carl-bloch.transfiguration-of-christ.webp", artist: "Carl Bloch", title: "Transfiguration of Christ", titleDe: "Die Verklärung Christi" }],
[5, { src: "/glaube/luminous/5-carl-bloch.the-last-supper.webp", artist: "Carl Bloch", title: "The Last Supper", titleDe: "Das letzte Abendmahl" }],
]),
};
// Mystery image scroll target based on active section // Whether the rosary has mystery images (stable, doesn't change during scroll)
const hasMysteryImages = $derived(showImages && (allMysteryImages[selectedMystery]?.size ?? 0) > 0);
// Mystery image scroll target based on active section (returns decade number 1-5, or 'before'/'after')
function getMysteryScrollTarget(section) { function getMysteryScrollTarget(section) {
switch (section) { if (section === 'lbead2') return 1;
case 'cross': const secretMatch = section.match(/^secret(\d)/);
return 'before'; if (secretMatch) {
case 'lbead2': const num = parseInt(secretMatch[1]);
return 'garden'; return section.includes('_transition') ? num + 1 : num;
case 'secret1':
return 'garden';
case 'secret1_transition':
return 'flagellation';
case 'secret2':
return 'flagellation';
case 'secret2_transition':
return 'mocking';
case 'secret3':
return 'mocking';
case 'secret3_transition':
return 'carry';
case 'secret4':
return 'carry';
case 'secret4_transition':
case 'secret5':
return 'crucifixion';
case 'final_transition':
case 'final_salve':
case 'final_schlussgebet':
case 'final_michael':
case 'final_paternoster':
case 'final_cross':
return 'after';
default:
return 'before';
} }
if (section.startsWith('final_')) return 'after';
return 'before';
} }
// Mystery images with captions
const mysteryImages = new Map([
["garden", { src: "/glaube/sorrowful/1.carl-bloch.gethsemane.webp", artist: "Carl Bloch", title: "Gethsemane", titleDe: "Gethsemane", year: 1873 }],
["flagellation", { src: "/glaube/sorrowful/2.wiliam-bouguereau.flagellation.webp", artist: "William-Adolphe Bouguereau", title: "The Flagellation of Our Lord Jesus Christ", titleDe: "Die Geisselung unseres Herrn Jesus Christus", year: 1880 }],
["mocking", { src: "/glaube/sorrowful/3.carl-bloch.mocking.webp", artist: "Carl Bloch", title: "The Mocking of Christ", titleDe: "Die Verspottung Christi", year: 1880 }],
["carry", { src: "/glaube/sorrowful/4.lorenzo-lotto.carrying-the-cross.webp", artist: "Lorenzo Lotto", title: "Carrying the Cross", titleDe: "Die Kreuztragung", year: 1526 }],
["crucifixion", { src: "/glaube/sorrowful/5.alonso-cano.the-crucifixion.webp", artist: "Diego Velázquez", title: "Christ Crucified", titleDe: "Der gekreuzigte Christus", year: 1632 }]
]);
// Mobile PiP: which image src to show (null = hide) // Mobile PiP: which image src to show (null = hide)
function getMysteryImage(mystery, section) { function getMysteryImage(mystery, section) {
if (mystery !== 'schmerzhaften') return null; const images = allMysteryImages[mystery];
if (!images || images.size === 0) return null;
const target = getMysteryScrollTarget(section); const target = getMysteryScrollTarget(section);
return mysteryImages.get(target)?.src ?? null; if (target === 'before' || target === 'after') return null;
return images.get(target)?.src ?? null;
} }
const mysteryPipSrc = $derived(getMysteryImage(selectedMystery, activeSection)); const mysteryPipSrc = $derived(getMysteryImage(selectedMystery, activeSection));
// Mobile PiP drag/enlarge // Mobile PiP drag/enlarge
const pip = createPip(); const pip = createPip({ fullscreenEnabled: true });
let rosaryPipEl = $state(null); let rosaryPipEl = $state(null);
let lastPipSrc = $state(null); let lastPipSrc = $state(null);
@@ -1487,6 +1489,54 @@ h1 {
.mystery-pip.enlarged img { .mystery-pip.enlarged img {
height: 37.5vh; height: 37.5vh;
} }
.mystery-pip.fullscreen {
width: 100vw;
height: 100vh;
background: rgba(0, 0, 0, 0.95);
display: flex;
align-items: center;
justify-content: center;
cursor: default;
}
.mystery-pip.fullscreen img {
border-radius: 0;
box-shadow: none;
}
.pip-fullscreen-btn {
all: unset;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: transparent;
filter: drop-shadow(0 0 1px black);
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
padding: 0;
z-index: 1;
pointer-events: auto;
outline: none;
transition: transform 0.15s ease;
}
.pip-fullscreen-btn:hover,
.pip-fullscreen-btn:active {
transform: translate(-50%, -50%) scale(1.2);
}
.mystery-pip.fullscreen .pip-fullscreen-btn {
top: auto;
left: auto;
bottom: 10vw;
right: 10vw;
transform: none;
}
.mystery-pip.fullscreen .pip-fullscreen-btn:hover,
.mystery-pip.fullscreen .pip-fullscreen-btn:active {
transform: scale(0.85);
}
@media (min-width: 1200px) { @media (min-width: 1200px) {
.mystery-pip { .mystery-pip {
display: none; display: none;
@@ -1891,13 +1941,13 @@ h1 {
<!-- Third column: Mystery images (desktop scrollable sticky) --> <!-- Third column: Mystery images (desktop scrollable sticky) -->
<div class="mystery-image-column" bind:this={mysteryImageContainer}> <div class="mystery-image-column" bind:this={mysteryImageContainer}>
{#if hasMysteryImages} {#if hasMysteryImages}
{@const images = allMysteryImages[selectedMystery]}
<div class="mystery-image-pad" data-target="before"></div> <div class="mystery-image-pad" data-target="before"></div>
{#each ["garden", "flagellation", "mocking", "carry", "crucifixion"] as target, i} {#each [...images.entries()] as [num, img], i}
{@const img = mysteryImages.get(target)}
{#if i > 0}<div class="mystery-image-pad" data-target="between{i}"></div>{/if} {#if i > 0}<div class="mystery-image-pad" data-target="between{i}"></div>{/if}
<figure data-target={target}> <figure data-target={num}>
<img src={img.src} alt="{img.artist} {isEnglish ? img.title : img.titleDe}"> <img src={img.src} alt="{img.artist ? `${img.artist} ` : ''}{isEnglish ? img.title : img.titleDe}">
<figcaption>{img.artist}, <em>{isEnglish ? img.title : img.titleDe}</em>, {img.year}</figcaption> <figcaption>{#if img.artist}{img.artist}, {/if}<em>{isEnglish ? img.title : img.titleDe}</em>{#if img.year}, {img.year}{/if}</figcaption>
</figure> </figure>
{/each} {/each}
<div class="mystery-image-pad" data-target="after"></div> <div class="mystery-image-pad" data-target="after"></div>
@@ -1912,6 +1962,7 @@ h1 {
class="mystery-pip" class="mystery-pip"
class:visible={!!mysteryPipSrc} class:visible={!!mysteryPipSrc}
class:enlarged={pip.enlarged} class:enlarged={pip.enlarged}
class:fullscreen={pip.fullscreen}
bind:this={rosaryPipEl} bind:this={rosaryPipEl}
onpointerdown={pip.onpointerdown} onpointerdown={pip.onpointerdown}
onpointermove={pip.onpointermove} onpointermove={pip.onpointermove}
@@ -1920,6 +1971,20 @@ h1 {
{#if lastPipSrc} {#if lastPipSrc}
<img src={lastPipSrc} alt="" onload={() => pip.reposition()}> <img src={lastPipSrc} alt="" onload={() => pip.reposition()}>
{/if} {/if}
{#if pip.showControls}
<button
class="pip-fullscreen-btn"
onpointerdown={(e) => e.stopPropagation()}
onclick={(e) => { e.stopPropagation(); pip.toggleFullscreen(); }}
>
<svg viewBox="0 0 24 24" width="28" height="28" fill="none" stroke="white" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="8 3 3 3 3 8"/>
<polyline points="16 3 21 3 21 8"/>
<polyline points="8 21 3 21 3 16"/>
<polyline points="16 21 21 21 21 16"/>
</svg>
</button>
{/if}
</div> </div>
{/if} {/if}
</div> </div>

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 347 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 434 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 274 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB