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;
smallHeight?: number;
largeHeight?: number;
fullscreenEnabled?: boolean;
}
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 = {}) {
const margin = opts.margin ?? 16;
const tapThreshold = opts.tapThreshold ?? 10;
const doubleTapMs = opts.doubleTapMs ?? 400;
const doubleTapMs = opts.doubleTapMs ?? 250;
const smallVh = opts.smallHeight ?? 25;
const largeVh = opts.largeHeight ?? 37.5;
const fullscreenEnabled = opts.fullscreenEnabled ?? false;
let corner: Corner = $state(opts.initialCorner ?? 'bottom-right');
let dragging = $state(false);
let enlarged = $state(false);
let fullscreen = $state(false);
let showControls = $state(false);
let dragOffset = { x: 0, y: 0 };
let dragPos = { x: 0, y: 0 };
@@ -59,7 +63,7 @@ export function createPip(opts: PipOptions = {}) {
const pos = getCornerPos(c, target);
corner = c;
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.addEventListener('transitionend', () => { target.style.transition = ''; }, { once: true });
}
@@ -82,15 +86,77 @@ export function createPip(opts: PipOptions = {}) {
if (corner.includes('bottom')) newY = rect.bottom - newH;
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.addEventListener('transitionend', () => {
el!.style.transition = '';
}, { 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) {
el = target;
showControls = false;
if (fullscreen) {
target.style.opacity = '1';
return;
}
const pos = getCornerPos(corner, target);
dragPos = pos;
target.style.transform = `translate(${pos.x}px, ${pos.y}px)`;
@@ -98,11 +164,25 @@ export function createPip(opts: PipOptions = {}) {
}
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';
}
function reposition() {
if (!el) return;
if (!el || fullscreen) return;
const pos = getCornerPos(corner, el);
dragPos = pos;
el.style.transform = `translate(${pos.x}px, ${pos.y}px)`;
@@ -112,15 +192,19 @@ export function createPip(opts: PipOptions = {}) {
if (!el) return;
dragging = true;
dragMoved = false;
const r = el.getBoundingClientRect();
dragOffset = { x: e.clientX - r.left, y: e.clientY - r.top };
if (!fullscreen) {
const r = el.getBoundingClientRect();
dragOffset = { x: e.clientX - r.left, y: e.clientY - r.top };
el.style.transition = '';
}
el.setPointerCapture(e.pointerId);
el.style.transition = '';
e.preventDefault();
}
function onpointermove(e: PointerEvent) {
if (!dragging || !el) return;
if (!dragging || !el || fullscreen) return;
const x = e.clientX - dragOffset.x;
const y = e.clientY - dragOffset.y;
if (!dragMoved) {
@@ -140,23 +224,40 @@ export function createPip(opts: PipOptions = {}) {
const now = Date.now();
if (now - lastTapTime < doubleTapMs) {
lastTapTime = 0;
toggleEnlarged();
if (fullscreen) {
toggleFullscreen(); // exit fullscreen
} else {
toggleEnlarged();
}
return;
}
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;
}
const r = el.getBoundingClientRect();
snapToCorner(el, nearestCorner(r.left, r.top, el));
if (!fullscreen) {
const r = el.getBoundingClientRect();
snapToCorner(el, nearestCorner(r.left, r.top, el));
}
}
return {
get corner() { return corner; },
get dragging() { return dragging; },
get enlarged() { return enlarged; },
get fullscreen() { return fullscreen; },
get showControls() { return showControls; },
show,
hide,
reposition,
toggleFullscreen,
onpointerdown,
onpointermove,
onpointerup

View File

@@ -323,63 +323,65 @@ let activeSection = $state("cross");
let sectionElements = {};
let svgContainer;
// Whether the rosary has mystery images (stable, doesn't change during scroll)
const hasMysteryImages = $derived(showImages && selectedMystery === 'schmerzhaften');
// Mystery images with captions (per mystery type, keyed by decade number 1-5)
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) {
switch (section) {
case 'cross':
return 'before';
case 'lbead2':
return 'garden';
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 === 'lbead2') return 1;
const secretMatch = section.match(/^secret(\d)/);
if (secretMatch) {
const num = parseInt(secretMatch[1]);
return section.includes('_transition') ? num + 1 : num;
}
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)
function getMysteryImage(mystery, section) {
if (mystery !== 'schmerzhaften') return null;
const images = allMysteryImages[mystery];
if (!images || images.size === 0) return null;
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));
// Mobile PiP drag/enlarge
const pip = createPip();
const pip = createPip({ fullscreenEnabled: true });
let rosaryPipEl = $state(null);
let lastPipSrc = $state(null);
@@ -1487,6 +1489,54 @@ h1 {
.mystery-pip.enlarged img {
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) {
.mystery-pip {
display: none;
@@ -1891,13 +1941,13 @@ h1 {
<!-- Third column: Mystery images (desktop scrollable sticky) -->
<div class="mystery-image-column" bind:this={mysteryImageContainer}>
{#if hasMysteryImages}
{@const images = allMysteryImages[selectedMystery]}
<div class="mystery-image-pad" data-target="before"></div>
{#each ["garden", "flagellation", "mocking", "carry", "crucifixion"] as target, i}
{@const img = mysteryImages.get(target)}
{#each [...images.entries()] as [num, img], i}
{#if i > 0}<div class="mystery-image-pad" data-target="between{i}"></div>{/if}
<figure data-target={target}>
<img src={img.src} alt="{img.artist} {isEnglish ? img.title : img.titleDe}">
<figcaption>{img.artist}, <em>{isEnglish ? img.title : img.titleDe}</em>, {img.year}</figcaption>
<figure data-target={num}>
<img src={img.src} alt="{img.artist ? `${img.artist} ` : ''}{isEnglish ? img.title : img.titleDe}">
<figcaption>{#if img.artist}{img.artist}, {/if}<em>{isEnglish ? img.title : img.titleDe}</em>{#if img.year}, {img.year}{/if}</figcaption>
</figure>
{/each}
<div class="mystery-image-pad" data-target="after"></div>
@@ -1912,6 +1962,7 @@ h1 {
class="mystery-pip"
class:visible={!!mysteryPipSrc}
class:enlarged={pip.enlarged}
class:fullscreen={pip.fullscreen}
bind:this={rosaryPipEl}
onpointerdown={pip.onpointerdown}
onpointermove={pip.onpointermove}
@@ -1920,6 +1971,20 @@ h1 {
{#if lastPipSrc}
<img src={lastPipSrc} alt="" onload={() => pip.reposition()}>
{/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>
{/if}
</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