diff --git a/src/lib/components/StickyImage.svelte b/src/lib/components/StickyImage.svelte new file mode 100644 index 0000000..6638700 --- /dev/null +++ b/src/lib/components/StickyImage.svelte @@ -0,0 +1,334 @@ + + +
+ +
+ +
+
+ {@render children()} +
+
+ + diff --git a/src/routes/[faithLang=faithLang]/[prayers=prayersLang]/[prayer]/+page.svelte b/src/routes/[faithLang=faithLang]/[prayers=prayersLang]/[prayer]/+page.svelte index 2c9c03f..eb93029 100644 --- a/src/routes/[faithLang=faithLang]/[prayers=prayersLang]/[prayer]/+page.svelte +++ b/src/routes/[faithLang=faithLang]/[prayers=prayersLang]/[prayer]/+page.svelte @@ -17,6 +17,7 @@ import JosephGebet from "$lib/components/prayers/JosephGebet.svelte"; import Confiteor from "$lib/components/prayers/Confiteor.svelte"; import AblassGebete from "$lib/components/prayers/AblassGebete.svelte"; + import StickyImage from "$lib/components/StickyImage.svelte"; let { data } = $props(); @@ -66,150 +67,11 @@ // Toggle href for no-JS fallback (navigates to opposite latin state) 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(() => { // Clean up URL params after hydration (state is now in component state) if (window.location.search) { 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); }); @@ -273,92 +135,6 @@ h1 { 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; - } -} {#if prayerId === 'ablassgebete'} @@ -372,26 +148,13 @@ h1 { /> -
- -
- Crucifix -
-
-
-
- -
+ +
+
+
-
+ {:else}

{prayerName}

diff --git a/src/routes/[faithLang=faithLang]/[rosary=rosaryLang]/+page.svelte b/src/routes/[faithLang=faithLang]/[rosary=rosaryLang]/+page.svelte index 01e68c2..02e0c30 100644 --- a/src/routes/[faithLang=faithLang]/[rosary=rosaryLang]/+page.svelte +++ b/src/routes/[faithLang=faithLang]/[rosary=rosaryLang]/+page.svelte @@ -18,7 +18,6 @@ import Toggle from "$lib/components/Toggle.svelte"; import LanguageToggle from "$lib/components/LanguageToggle.svelte"; import StreakCounter from "$lib/components/StreakCounter.svelte"; import MysteryIcon from "$lib/components/MysteryIcon.svelte"; - let { data } = $props(); // Mystery variations for each type of rosary @@ -314,6 +313,75 @@ let activeSection = $state("cross"); let sectionElements = {}; 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) let decadeCounters = $state({ secret1: 0, @@ -782,6 +850,9 @@ onMount(() => { margin: 0 auto; padding: 2rem 1rem; } +.page-container:has(.has-mystery-image) { + max-width: calc(1400px + 25vw + 3rem); +} .rosary-layout { position: relative; @@ -1281,6 +1352,68 @@ h1 { .scroll-padding { 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; + } +} {labels.pageTitle} @@ -1370,7 +1503,7 @@ h1 {
-
+
@@ -1562,7 +1695,6 @@ h1 { >

{decadeNum}. {labels.decade}: {currentMysteryTitles[decadeNum - 1]}

-

{labels.hailMary} (10×)

-
{#if currentMysteryDescriptions[decadeNum - 1]} {@const description = currentMysteryDescriptions[decadeNum - 1]} @@ -1673,8 +1804,27 @@ h1 {
+ + +
+ {#if hasMysteryImages} +
+ {isEnglish +
+ {isEnglish +
+ {/if} +
+ + {#if hasMysteryImages} +
+ {#if mysteryPipSrc} + + {/if} +
+ {/if}
diff --git a/static/glaube/mocking.jpg b/static/glaube/mocking.jpg new file mode 100644 index 0000000..ba91c5a Binary files /dev/null and b/static/glaube/mocking.jpg differ diff --git a/static/glaube/sorrowful/2-3.mocking.webp b/static/glaube/sorrowful/2-3.mocking.webp new file mode 100644 index 0000000..c7beb0e Binary files /dev/null and b/static/glaube/sorrowful/2-3.mocking.webp differ diff --git a/static/glaube/sorrowful/5.crucification.webp b/static/glaube/sorrowful/5.crucification.webp new file mode 100644 index 0000000..b162486 Binary files /dev/null and b/static/glaube/sorrowful/5.crucification.webp differ