rosary: add mystery images with scrollable sticky column and mobile PiP
Add a third grid column for sorrowful mystery images (mocking for mysteries 2-3, crucifixion for mystery 5). Desktop uses a scrollable sticky sidebar synced to prayer scroll position. Mobile shows a floating PiP thumbnail. Extract prayer page PiP logic into reusable StickyImage component.
This commit is contained in:
334
src/lib/components/StickyImage.svelte
Normal file
334
src/lib/components/StickyImage.svelte
Normal file
@@ -0,0 +1,334 @@
|
|||||||
|
<script>
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {'layout' | 'overlay'} mode
|
||||||
|
* - '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 pipEl = $state(null);
|
||||||
|
let contentEl = $state(null);
|
||||||
|
let corner = $state('bottom-right');
|
||||||
|
let dragging = $state(false);
|
||||||
|
let enlarged = $state(false);
|
||||||
|
let inView = $state(false);
|
||||||
|
let dragOffset = { x: 0, y: 0 };
|
||||||
|
let dragPos = $state({ x: 0, y: 0 });
|
||||||
|
let dragMoved = false;
|
||||||
|
let lastTapTime = 0;
|
||||||
|
const MARGIN = 16;
|
||||||
|
const TAP_THRESHOLD = 10;
|
||||||
|
const DOUBLE_TAP_MS = 400;
|
||||||
|
|
||||||
|
function isMobile() {
|
||||||
|
return !window.matchMedia('(min-width: 1024px)').matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
// PiP drag behavior only on mobile for both modes
|
||||||
|
function isPipActive() {
|
||||||
|
return isMobile();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whether the image visibility is controlled by IntersectionObserver
|
||||||
|
function isObserverControlled() {
|
||||||
|
return mode === 'overlay';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCornerPos(c, el) {
|
||||||
|
const vw = window.innerWidth;
|
||||||
|
const vh = window.innerHeight;
|
||||||
|
const r = el.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
'top-left': { x: MARGIN, y: MARGIN },
|
||||||
|
'top-right': { x: vw - r.width - MARGIN, y: MARGIN },
|
||||||
|
'bottom-left': { x: MARGIN, y: vh - r.height - MARGIN },
|
||||||
|
'bottom-right': { x: vw - r.width - MARGIN, y: vh - r.height - MARGIN },
|
||||||
|
}[c];
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapToCorner(el, c) {
|
||||||
|
const pos = getCornerPos(c, el);
|
||||||
|
corner = c;
|
||||||
|
dragPos = pos;
|
||||||
|
el.style.transition = 'transform 0.25s ease';
|
||||||
|
el.style.transform = `translate(${pos.x}px, ${pos.y}px)`;
|
||||||
|
el.addEventListener('transitionend', () => { el.style.transition = ''; }, { once: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function nearestCorner(x, y, el) {
|
||||||
|
const vw = window.innerWidth;
|
||||||
|
const vh = window.innerHeight;
|
||||||
|
const r = el.getBoundingClientRect();
|
||||||
|
const cx = x + r.width / 2;
|
||||||
|
const cy = y + r.height / 2;
|
||||||
|
const left = cx < vw / 2;
|
||||||
|
const top = cy < vh / 2;
|
||||||
|
return `${top ? 'top' : 'bottom'}-${left ? 'left' : 'right'}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerDown(e) {
|
||||||
|
if (!pipEl || !isPipActive()) return;
|
||||||
|
dragging = true;
|
||||||
|
dragMoved = false;
|
||||||
|
const r = pipEl.getBoundingClientRect();
|
||||||
|
dragOffset = { x: e.clientX - r.left, y: e.clientY - r.top };
|
||||||
|
pipEl.setPointerCapture(e.pointerId);
|
||||||
|
pipEl.style.transition = '';
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerMove(e) {
|
||||||
|
if (!dragging || !pipEl) return;
|
||||||
|
const x = e.clientX - dragOffset.x;
|
||||||
|
const y = e.clientY - dragOffset.y;
|
||||||
|
if (!dragMoved) {
|
||||||
|
const dx = Math.abs(x - dragPos.x);
|
||||||
|
const dy = Math.abs(y - dragPos.y);
|
||||||
|
if (dx > TAP_THRESHOLD || dy > TAP_THRESHOLD) dragMoved = true;
|
||||||
|
}
|
||||||
|
dragPos = { x, y };
|
||||||
|
pipEl.style.transform = `translate(${x}px, ${y}px)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleEnlarged() {
|
||||||
|
if (!pipEl) return;
|
||||||
|
const rect = pipEl.getBoundingClientRect();
|
||||||
|
const vh = window.innerHeight / 100;
|
||||||
|
const currentH = enlarged ? 37.5 * vh : 25 * vh;
|
||||||
|
const targetH = enlarged ? 25 * vh : 37.5 * vh;
|
||||||
|
const ratio = targetH / currentH;
|
||||||
|
|
||||||
|
enlarged = !enlarged;
|
||||||
|
|
||||||
|
const newW = rect.width * ratio;
|
||||||
|
const newH = rect.height * ratio;
|
||||||
|
let newX = rect.left;
|
||||||
|
let newY = rect.top;
|
||||||
|
if (corner.includes('right')) newX = rect.right - newW;
|
||||||
|
if (corner.includes('bottom')) newY = rect.bottom - newH;
|
||||||
|
|
||||||
|
dragPos = { x: newX, y: newY };
|
||||||
|
pipEl.style.transition = 'transform 0.25s ease';
|
||||||
|
pipEl.style.transform = `translate(${newX}px, ${newY}px)`;
|
||||||
|
pipEl.addEventListener('transitionend', () => {
|
||||||
|
pipEl.style.transition = '';
|
||||||
|
}, { once: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerUp(e) {
|
||||||
|
if (!dragging || !pipEl) return;
|
||||||
|
dragging = false;
|
||||||
|
|
||||||
|
if (!dragMoved) {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastTapTime < DOUBLE_TAP_MS) {
|
||||||
|
lastTapTime = 0;
|
||||||
|
toggleEnlarged();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastTapTime = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
const r = pipEl.getBoundingClientRect();
|
||||||
|
snapToCorner(pipEl, nearestCorner(r.left, r.top, pipEl));
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateVisibility() {
|
||||||
|
if (!pipEl) return;
|
||||||
|
if (isPipActive()) {
|
||||||
|
// Mobile PiP mode
|
||||||
|
if (inView) {
|
||||||
|
const pos = getCornerPos(corner, pipEl);
|
||||||
|
dragPos = pos;
|
||||||
|
pipEl.style.transform = `translate(${pos.x}px, ${pos.y}px)`;
|
||||||
|
pipEl.style.opacity = '1';
|
||||||
|
} else {
|
||||||
|
pipEl.style.opacity = '0';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Desktop (both modes): CSS handles everything
|
||||||
|
pipEl.style.opacity = '';
|
||||||
|
pipEl.style.transform = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
inView;
|
||||||
|
updateVisibility();
|
||||||
|
});
|
||||||
|
|
||||||
|
function onResize() {
|
||||||
|
if (!pipEl) return;
|
||||||
|
updateVisibility();
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
updateVisibility();
|
||||||
|
|
||||||
|
window.addEventListener('resize', onResize);
|
||||||
|
|
||||||
|
let observer;
|
||||||
|
if (contentEl) {
|
||||||
|
observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
inView = entry.isIntersecting;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ threshold: 0 }
|
||||||
|
);
|
||||||
|
observer.observe(contentEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', onResize);
|
||||||
|
observer?.disconnect();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="sticky-image-layout" class:overlay={mode === 'overlay'}>
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
class="image-wrap"
|
||||||
|
class:enlarged
|
||||||
|
bind:this={pipEl}
|
||||||
|
onpointerdown={onPointerDown}
|
||||||
|
onpointermove={onPointerMove}
|
||||||
|
onpointerup={onPointerUp}
|
||||||
|
>
|
||||||
|
<img {src} {alt}>
|
||||||
|
</div>
|
||||||
|
<div class="content-scroll" bind:this={contentEl}>
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.sticky-image-layout {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
margin: auto;
|
||||||
|
padding: 0 1em;
|
||||||
|
}
|
||||||
|
.sticky-image-layout.overlay {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
.image-wrap {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
z-index: 10000;
|
||||||
|
width: auto;
|
||||||
|
opacity: 0;
|
||||||
|
touch-action: none;
|
||||||
|
cursor: grab;
|
||||||
|
user-select: none;
|
||||||
|
transition: opacity 0.25s ease;
|
||||||
|
}
|
||||||
|
.image-wrap:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
.image-wrap img {
|
||||||
|
height: 25vh;
|
||||||
|
width: auto;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||||
|
pointer-events: none;
|
||||||
|
transition: height 0.25s ease;
|
||||||
|
}
|
||||||
|
.image-wrap.enlarged img {
|
||||||
|
height: 37.5vh;
|
||||||
|
}
|
||||||
|
.content-scroll {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 700px;
|
||||||
|
}
|
||||||
|
.overlay .content-scroll {
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.sticky-image-layout.overlay {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
gap: 2rem;
|
||||||
|
width: calc(100% + 25vw + 2rem);
|
||||||
|
}
|
||||||
|
.overlay .image-wrap {
|
||||||
|
position: sticky;
|
||||||
|
top: 4rem;
|
||||||
|
left: auto;
|
||||||
|
transform: none !important;
|
||||||
|
width: auto;
|
||||||
|
align-self: start;
|
||||||
|
order: 1;
|
||||||
|
opacity: 1;
|
||||||
|
z-index: auto;
|
||||||
|
cursor: default;
|
||||||
|
touch-action: auto;
|
||||||
|
user-select: auto;
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
.overlay .image-wrap img {
|
||||||
|
height: auto;
|
||||||
|
max-height: calc(100vh - 5rem);
|
||||||
|
width: auto;
|
||||||
|
max-width: 25vw;
|
||||||
|
border-radius: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
.sticky-image-layout:not(.overlay) {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 2em;
|
||||||
|
}
|
||||||
|
.sticky-image-layout:not(.overlay) .content-scroll {
|
||||||
|
flex: 0 1 700px;
|
||||||
|
}
|
||||||
|
.sticky-image-layout:not(.overlay) .image-wrap {
|
||||||
|
position: sticky;
|
||||||
|
top: 4rem;
|
||||||
|
left: auto;
|
||||||
|
transform: none !important;
|
||||||
|
opacity: 1;
|
||||||
|
flex: 1;
|
||||||
|
background-color: transparent;
|
||||||
|
padding: 0;
|
||||||
|
order: 1;
|
||||||
|
cursor: default;
|
||||||
|
touch-action: auto;
|
||||||
|
user-select: auto;
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
.sticky-image-layout:not(.overlay) .image-wrap img {
|
||||||
|
max-height: calc(100vh - 4rem);
|
||||||
|
height: auto;
|
||||||
|
width: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
.sticky-image-layout:not(.overlay) .image-wrap {
|
||||||
|
background-color: var(--nord5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: light) and (min-width: 1024px) {
|
||||||
|
.sticky-image-layout:not(.overlay) .image-wrap {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (min-width: 1400px) {
|
||||||
|
.sticky-image-layout:not(.overlay)::before {
|
||||||
|
content: '';
|
||||||
|
flex: 1;
|
||||||
|
order: -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -17,6 +17,7 @@
|
|||||||
import JosephGebet from "$lib/components/prayers/JosephGebet.svelte";
|
import JosephGebet from "$lib/components/prayers/JosephGebet.svelte";
|
||||||
import Confiteor from "$lib/components/prayers/Confiteor.svelte";
|
import Confiteor from "$lib/components/prayers/Confiteor.svelte";
|
||||||
import AblassGebete from "$lib/components/prayers/AblassGebete.svelte";
|
import AblassGebete from "$lib/components/prayers/AblassGebete.svelte";
|
||||||
|
import StickyImage from "$lib/components/StickyImage.svelte";
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
|
||||||
@@ -66,150 +67,11 @@
|
|||||||
// Toggle href for no-JS fallback (navigates to opposite latin state)
|
// Toggle href for no-JS fallback (navigates to opposite latin state)
|
||||||
const latinToggleHref = $derived(data.initialLatin ? '?latin=0' : '?');
|
const latinToggleHref = $derived(data.initialLatin ? '?latin=0' : '?');
|
||||||
|
|
||||||
// PiP drag-to-corner logic
|
|
||||||
let pipEl = $state(null);
|
|
||||||
let corner = $state('bottom-right');
|
|
||||||
let dragging = $state(false);
|
|
||||||
let enlarged = $state(false);
|
|
||||||
let dragOffset = { x: 0, y: 0 };
|
|
||||||
let dragPos = $state({ x: 0, y: 0 });
|
|
||||||
let dragMoved = false;
|
|
||||||
let lastTapTime = 0;
|
|
||||||
const MARGIN = 16;
|
|
||||||
const TAP_THRESHOLD = 10; // px movement to distinguish tap from drag
|
|
||||||
const DOUBLE_TAP_MS = 400;
|
|
||||||
|
|
||||||
function getCornerPos(c, el) {
|
|
||||||
const vw = window.innerWidth;
|
|
||||||
const vh = window.innerHeight;
|
|
||||||
const r = el.getBoundingClientRect();
|
|
||||||
const positions = {
|
|
||||||
'top-left': { x: MARGIN, y: MARGIN },
|
|
||||||
'top-right': { x: vw - r.width - MARGIN, y: MARGIN },
|
|
||||||
'bottom-left': { x: MARGIN, y: vh - r.height - MARGIN },
|
|
||||||
'bottom-right': { x: vw - r.width - MARGIN, y: vh - r.height - MARGIN },
|
|
||||||
};
|
|
||||||
return positions[c];
|
|
||||||
}
|
|
||||||
|
|
||||||
function snapToCorner(el, c) {
|
|
||||||
const pos = getCornerPos(c, el);
|
|
||||||
corner = c;
|
|
||||||
dragPos = pos;
|
|
||||||
el.style.transition = 'transform 0.25s ease';
|
|
||||||
el.style.transform = `translate(${pos.x}px, ${pos.y}px)`;
|
|
||||||
el.addEventListener('transitionend', () => { el.style.transition = ''; }, { once: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
function nearestCorner(x, y, el) {
|
|
||||||
const vw = window.innerWidth;
|
|
||||||
const vh = window.innerHeight;
|
|
||||||
const r = el.getBoundingClientRect();
|
|
||||||
const cx = x + r.width / 2;
|
|
||||||
const cy = y + r.height / 2;
|
|
||||||
const left = cx < vw / 2;
|
|
||||||
const top = cy < vh / 2;
|
|
||||||
return `${top ? 'top' : 'bottom'}-${left ? 'left' : 'right'}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function onPointerDown(e) {
|
|
||||||
if (!pipEl || window.matchMedia('(min-width: 1024px)').matches) return;
|
|
||||||
dragging = true;
|
|
||||||
dragMoved = false;
|
|
||||||
const r = pipEl.getBoundingClientRect();
|
|
||||||
dragOffset = { x: e.clientX - r.left, y: e.clientY - r.top };
|
|
||||||
pipEl.setPointerCapture(e.pointerId);
|
|
||||||
pipEl.style.transition = '';
|
|
||||||
e.preventDefault();
|
|
||||||
}
|
|
||||||
|
|
||||||
function onPointerMove(e) {
|
|
||||||
if (!dragging || !pipEl) return;
|
|
||||||
const x = e.clientX - dragOffset.x;
|
|
||||||
const y = e.clientY - dragOffset.y;
|
|
||||||
if (!dragMoved) {
|
|
||||||
const dx = Math.abs(x - dragPos.x);
|
|
||||||
const dy = Math.abs(y - dragPos.y);
|
|
||||||
if (dx > TAP_THRESHOLD || dy > TAP_THRESHOLD) dragMoved = true;
|
|
||||||
}
|
|
||||||
dragPos = { x, y };
|
|
||||||
pipEl.style.transform = `translate(${x}px, ${y}px)`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleEnlarged() {
|
|
||||||
if (!pipEl) return;
|
|
||||||
const rect = pipEl.getBoundingClientRect();
|
|
||||||
const vh = window.innerHeight / 100;
|
|
||||||
const currentH = enlarged ? 37.5 * vh : 25 * vh;
|
|
||||||
const targetH = enlarged ? 25 * vh : 37.5 * vh;
|
|
||||||
const ratio = targetH / currentH;
|
|
||||||
|
|
||||||
enlarged = !enlarged;
|
|
||||||
|
|
||||||
// Calculate new size and keep the anchored corner fixed
|
|
||||||
const newW = rect.width * ratio;
|
|
||||||
const newH = rect.height * ratio;
|
|
||||||
let newX = rect.left;
|
|
||||||
let newY = rect.top;
|
|
||||||
if (corner.includes('right')) newX = rect.right - newW;
|
|
||||||
if (corner.includes('bottom')) newY = rect.bottom - newH;
|
|
||||||
|
|
||||||
dragPos = { x: newX, y: newY };
|
|
||||||
pipEl.style.transition = 'transform 0.25s ease';
|
|
||||||
pipEl.style.transform = `translate(${newX}px, ${newY}px)`;
|
|
||||||
pipEl.addEventListener('transitionend', () => {
|
|
||||||
pipEl.style.transition = '';
|
|
||||||
}, { once: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
function onPointerUp(e) {
|
|
||||||
if (!dragging || !pipEl) return;
|
|
||||||
dragging = false;
|
|
||||||
|
|
||||||
if (!dragMoved) {
|
|
||||||
// It was a tap, check for double-tap
|
|
||||||
const now = Date.now();
|
|
||||||
if (now - lastTapTime < DOUBLE_TAP_MS) {
|
|
||||||
lastTapTime = 0;
|
|
||||||
toggleEnlarged();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
lastTapTime = now;
|
|
||||||
}
|
|
||||||
|
|
||||||
const r = pipEl.getBoundingClientRect();
|
|
||||||
snapToCorner(pipEl, nearestCorner(r.left, r.top, pipEl));
|
|
||||||
}
|
|
||||||
|
|
||||||
function onResize() {
|
|
||||||
if (!pipEl) return;
|
|
||||||
const isDesktop = window.matchMedia('(min-width: 1024px)').matches;
|
|
||||||
if (isDesktop) {
|
|
||||||
pipEl.style.opacity = '';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const pos = getCornerPos(corner, pipEl);
|
|
||||||
dragPos = pos;
|
|
||||||
pipEl.style.transform = `translate(${pos.x}px, ${pos.y}px)`;
|
|
||||||
pipEl.style.opacity = '1';
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
// Clean up URL params after hydration (state is now in component state)
|
// Clean up URL params after hydration (state is now in component state)
|
||||||
if (window.location.search) {
|
if (window.location.search) {
|
||||||
history.replaceState({}, '', window.location.pathname);
|
history.replaceState({}, '', window.location.pathname);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initial position for PiP
|
|
||||||
if (pipEl && !window.matchMedia('(min-width: 1024px)').matches) {
|
|
||||||
const pos = getCornerPos(corner, pipEl);
|
|
||||||
dragPos = pos;
|
|
||||||
pipEl.style.transform = `translate(${pos.x}px, ${pos.y}px)`;
|
|
||||||
pipEl.style.opacity = '1';
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener('resize', onResize);
|
|
||||||
return () => window.removeEventListener('resize', onResize);
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -273,92 +135,6 @@ h1 {
|
|||||||
background-color: var(--nord5);
|
background-color: var(--nord5);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.crucifix-layout {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
margin: auto;
|
|
||||||
padding: 0 1em;
|
|
||||||
}
|
|
||||||
.crucifix-layout .crucifix-wrap {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
z-index: 10000;
|
|
||||||
width: auto;
|
|
||||||
opacity: 0;
|
|
||||||
touch-action: none;
|
|
||||||
cursor: grab;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
.crucifix-layout .crucifix-wrap:active {
|
|
||||||
cursor: grabbing;
|
|
||||||
}
|
|
||||||
.crucifix-layout .crucifix-wrap img {
|
|
||||||
height: 25vh;
|
|
||||||
width: auto;
|
|
||||||
object-fit: contain;
|
|
||||||
border-radius: 6px;
|
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
|
||||||
pointer-events: none;
|
|
||||||
transition: height 0.25s ease;
|
|
||||||
}
|
|
||||||
.crucifix-layout .crucifix-wrap.enlarged img {
|
|
||||||
height: 37.5vh;
|
|
||||||
}
|
|
||||||
.crucifix-layout .prayer-scroll {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 700px;
|
|
||||||
}
|
|
||||||
@media (min-width: 1024px) {
|
|
||||||
.crucifix-layout {
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 2em;
|
|
||||||
}
|
|
||||||
.crucifix-layout .prayer-scroll {
|
|
||||||
flex: 0 1 700px;
|
|
||||||
}
|
|
||||||
.crucifix-layout .crucifix-wrap {
|
|
||||||
position: sticky;
|
|
||||||
top: 4rem;
|
|
||||||
left: auto;
|
|
||||||
transform: none !important;
|
|
||||||
opacity: 1;
|
|
||||||
flex: 1;
|
|
||||||
background-color: transparent;
|
|
||||||
padding: 0;
|
|
||||||
order: 1;
|
|
||||||
cursor: default;
|
|
||||||
touch-action: auto;
|
|
||||||
user-select: auto;
|
|
||||||
}
|
|
||||||
.crucifix-layout .crucifix-wrap img {
|
|
||||||
max-height: calc(100vh - 4rem);
|
|
||||||
height: auto;
|
|
||||||
width: 100%;
|
|
||||||
object-fit: contain;
|
|
||||||
border-radius: 0;
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@media (prefers-color-scheme: light) {
|
|
||||||
.crucifix-layout .crucifix-wrap {
|
|
||||||
background-color: var(--nord5);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@media (prefers-color-scheme: light) and (min-width: 1024px) {
|
|
||||||
.crucifix-layout .crucifix-wrap {
|
|
||||||
background-color: transparent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@media (min-width: 1400px) {
|
|
||||||
.crucifix-layout::before {
|
|
||||||
content: '';
|
|
||||||
flex: 1;
|
|
||||||
order: -1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
{#if prayerId === 'ablassgebete'}
|
{#if prayerId === 'ablassgebete'}
|
||||||
|
|
||||||
@@ -372,26 +148,13 @@ h1 {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="crucifix-layout">
|
<StickyImage src="/glaube/crucifix.webp" alt="Crucifix">
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
||||||
<div
|
|
||||||
class="crucifix-wrap"
|
|
||||||
class:enlarged
|
|
||||||
bind:this={pipEl}
|
|
||||||
onpointerdown={onPointerDown}
|
|
||||||
onpointermove={onPointerMove}
|
|
||||||
onpointerup={onPointerUp}
|
|
||||||
>
|
|
||||||
<img src="/glaube/crucifix.webp" alt="Crucifix">
|
|
||||||
</div>
|
|
||||||
<div class="prayer-scroll">
|
|
||||||
<div class="gebet-wrapper">
|
<div class="gebet-wrapper">
|
||||||
<div class="gebet" class:bilingue={isBilingue}>
|
<div class="gebet" class:bilingue={isBilingue}>
|
||||||
<AblassGebete verbose={true} />
|
<AblassGebete verbose={true} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</StickyImage>
|
||||||
</div>
|
|
||||||
{:else}
|
{:else}
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h1>{prayerName}</h1>
|
<h1>{prayerName}</h1>
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import Toggle from "$lib/components/Toggle.svelte";
|
|||||||
import LanguageToggle from "$lib/components/LanguageToggle.svelte";
|
import LanguageToggle from "$lib/components/LanguageToggle.svelte";
|
||||||
import StreakCounter from "$lib/components/StreakCounter.svelte";
|
import StreakCounter from "$lib/components/StreakCounter.svelte";
|
||||||
import MysteryIcon from "$lib/components/MysteryIcon.svelte";
|
import MysteryIcon from "$lib/components/MysteryIcon.svelte";
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
|
||||||
// Mystery variations for each type of rosary
|
// Mystery variations for each type of rosary
|
||||||
@@ -314,6 +313,75 @@ let activeSection = $state("cross");
|
|||||||
let sectionElements = {};
|
let sectionElements = {};
|
||||||
let svgContainer;
|
let svgContainer;
|
||||||
|
|
||||||
|
// Whether the rosary has mystery images (stable, doesn't change during scroll)
|
||||||
|
const hasMysteryImages = $derived(selectedMystery === 'schmerzhaften');
|
||||||
|
|
||||||
|
// Mystery image scroll target based on active section
|
||||||
|
function getMysteryScrollTarget(section) {
|
||||||
|
switch (section) {
|
||||||
|
case 'secret1_transition':
|
||||||
|
case 'secret2':
|
||||||
|
case 'secret2_transition':
|
||||||
|
case 'secret3':
|
||||||
|
return 'mocking';
|
||||||
|
case 'secret3_transition':
|
||||||
|
case 'secret4':
|
||||||
|
return 'between';
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mobile PiP: which image to show (null = hide)
|
||||||
|
function getMysteryImage(mystery, section) {
|
||||||
|
if (mystery !== 'schmerzhaften') return null;
|
||||||
|
const target = getMysteryScrollTarget(section);
|
||||||
|
if (target === 'mocking') return '/glaube/sorrowful/2-3.mocking.webp';
|
||||||
|
if (target === 'crucifixion') return '/glaube/sorrowful/5.crucification.webp';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const mysteryPipSrc = $derived(getMysteryImage(selectedMystery, activeSection));
|
||||||
|
|
||||||
|
let mysteryImageContainer;
|
||||||
|
let mysteryScrollRaf = null;
|
||||||
|
|
||||||
|
function scrollMysteryImage(targetY, duration = 1200) {
|
||||||
|
if (!mysteryImageContainer) return;
|
||||||
|
if (mysteryScrollRaf) cancelAnimationFrame(mysteryScrollRaf);
|
||||||
|
const startY = mysteryImageContainer.scrollTop;
|
||||||
|
const distance = targetY - startY;
|
||||||
|
if (Math.abs(distance) < 1) return;
|
||||||
|
const startTime = performance.now();
|
||||||
|
const ease = (t) => t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t;
|
||||||
|
const step = (now) => {
|
||||||
|
const progress = Math.min((now - startTime) / duration, 1);
|
||||||
|
mysteryImageContainer.scrollTop = startY + distance * ease(progress);
|
||||||
|
if (progress < 1) mysteryScrollRaf = requestAnimationFrame(step);
|
||||||
|
else mysteryScrollRaf = null;
|
||||||
|
};
|
||||||
|
mysteryScrollRaf = requestAnimationFrame(step);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scroll the mystery image column to the relevant image
|
||||||
|
$effect(() => {
|
||||||
|
if (!mysteryImageContainer || !hasMysteryImages) return;
|
||||||
|
const targetName = getMysteryScrollTarget(activeSection);
|
||||||
|
const targetEl = mysteryImageContainer.querySelector(`[data-target="${targetName}"]`);
|
||||||
|
if (targetEl) {
|
||||||
|
scrollMysteryImage(targetEl.offsetTop);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Counter for tracking Ave Maria progress in each decade (0-10 for each)
|
// Counter for tracking Ave Maria progress in each decade (0-10 for each)
|
||||||
let decadeCounters = $state({
|
let decadeCounters = $state({
|
||||||
secret1: 0,
|
secret1: 0,
|
||||||
@@ -782,6 +850,9 @@ onMount(() => {
|
|||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 2rem 1rem;
|
padding: 2rem 1rem;
|
||||||
}
|
}
|
||||||
|
.page-container:has(.has-mystery-image) {
|
||||||
|
max-width: calc(1400px + 25vw + 3rem);
|
||||||
|
}
|
||||||
|
|
||||||
.rosary-layout {
|
.rosary-layout {
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -1281,6 +1352,68 @@ h1 {
|
|||||||
.scroll-padding {
|
.scroll-padding {
|
||||||
height: 50vh;
|
height: 50vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Mystery images: third grid column (desktop), PiP (mobile) */
|
||||||
|
.mystery-image-column {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 900px) {
|
||||||
|
.rosary-layout.has-mystery-image {
|
||||||
|
grid-template-columns: clamp(250px, 30vw, 400px) 1fr auto;
|
||||||
|
}
|
||||||
|
.mystery-image-column {
|
||||||
|
display: block;
|
||||||
|
position: sticky;
|
||||||
|
top: 6rem;
|
||||||
|
align-self: start;
|
||||||
|
max-height: calc(100vh - 7rem);
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
.mystery-image-column::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.mystery-image-pad {
|
||||||
|
height: calc(100vh - 5rem);
|
||||||
|
}
|
||||||
|
.mystery-image-column img {
|
||||||
|
max-height: calc(100vh - 5rem);
|
||||||
|
width: auto;
|
||||||
|
max-width: 25vw;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-right: 2rem;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile PiP for mystery images */
|
||||||
|
.mystery-pip {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 16px;
|
||||||
|
right: 16px;
|
||||||
|
z-index: 10000;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
.mystery-pip.visible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.mystery-pip img {
|
||||||
|
height: 25vh;
|
||||||
|
width: auto;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
@media (min-width: 900px) {
|
||||||
|
.mystery-pip {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>{labels.pageTitle}</title>
|
<title>{labels.pageTitle}</title>
|
||||||
@@ -1370,7 +1503,7 @@ h1 {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="rosary-layout">
|
<div class="rosary-layout" class:has-mystery-image={hasMysteryImages}>
|
||||||
<!-- Sidebar: Rosary Visualization -->
|
<!-- Sidebar: Rosary Visualization -->
|
||||||
<div class="rosary-sidebar">
|
<div class="rosary-sidebar">
|
||||||
<div class="rosary-visualization" bind:this={svgContainer}>
|
<div class="rosary-visualization" bind:this={svgContainer}>
|
||||||
@@ -1562,7 +1695,6 @@ h1 {
|
|||||||
>
|
>
|
||||||
<h2>{decadeNum}. {labels.decade}: {currentMysteryTitles[decadeNum - 1]}</h2>
|
<h2>{decadeNum}. {labels.decade}: {currentMysteryTitles[decadeNum - 1]}</h2>
|
||||||
|
|
||||||
<!-- Mystery description with Bible reference button -->
|
|
||||||
<h3>{labels.hailMary} <span class="repeat-count">(10×)</span></h3>
|
<h3>{labels.hailMary} <span class="repeat-count">(10×)</span></h3>
|
||||||
<AveMaria
|
<AveMaria
|
||||||
mysteryLatin={currentMysteriesLatin[decadeNum - 1]}
|
mysteryLatin={currentMysteriesLatin[decadeNum - 1]}
|
||||||
@@ -1570,7 +1702,6 @@ h1 {
|
|||||||
mysteryEnglish={currentMysteriesEnglish[decadeNum - 1]}
|
mysteryEnglish={currentMysteriesEnglish[decadeNum - 1]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Bible reference and counter buttons -->
|
|
||||||
<div class="decade-buttons">
|
<div class="decade-buttons">
|
||||||
{#if currentMysteryDescriptions[decadeNum - 1]}
|
{#if currentMysteryDescriptions[decadeNum - 1]}
|
||||||
{@const description = currentMysteryDescriptions[decadeNum - 1]}
|
{@const description = currentMysteryDescriptions[decadeNum - 1]}
|
||||||
@@ -1673,8 +1804,27 @@ h1 {
|
|||||||
</button>
|
</button>
|
||||||
<div class="scroll-padding"></div>
|
<div class="scroll-padding"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Third column: Mystery images (desktop scrollable sticky) -->
|
||||||
|
<div class="mystery-image-column" bind:this={mysteryImageContainer}>
|
||||||
|
{#if hasMysteryImages}
|
||||||
|
<div class="mystery-image-pad" data-target="before"></div>
|
||||||
|
<img src="/glaube/sorrowful/2-3.mocking.webp" alt={isEnglish ? 'Mocking of Christ' : 'Verspottung Christi'} data-target="mocking">
|
||||||
|
<div class="mystery-image-pad" data-target="between"></div>
|
||||||
|
<img src="/glaube/sorrowful/5.crucification.webp" alt={isEnglish ? 'Crucifixion of Christ' : 'Kreuzigung Christi'} data-target="crucifixion">
|
||||||
|
<div class="mystery-image-pad" data-target="after"></div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Mobile PiP for mystery images -->
|
||||||
|
{#if hasMysteryImages}
|
||||||
|
<div class="mystery-pip" class:visible={!!mysteryPipSrc}>
|
||||||
|
{#if mysteryPipSrc}
|
||||||
|
<img src={mysteryPipSrc} alt="">
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Bible citation modal -->
|
<!-- Bible citation modal -->
|
||||||
|
|||||||
BIN
static/glaube/mocking.jpg
Normal file
BIN
static/glaube/mocking.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 57 KiB |
BIN
static/glaube/sorrowful/2-3.mocking.webp
Normal file
BIN
static/glaube/sorrowful/2-3.mocking.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
BIN
static/glaube/sorrowful/5.crucification.webp
Normal file
BIN
static/glaube/sorrowful/5.crucification.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
Reference in New Issue
Block a user