feat: enhance interactive rosary with mobile support and counters
Some checks failed
CI / update (push) Has been cancelled
Some checks failed
CI / update (push) Has been cancelled
- Add weekday-based mystery auto-selection with luminous mysteries toggle - Implement iOS-style toggle for including/excluding luminous mysteries - Add mystery selector buttons with visual feedback - Create CounterButton component for tracking Ave Maria progress - Add orange bead highlighting for prayer counting - Implement auto-scroll to next section after completing decade - Optimize mobile layout with responsive sidebar (20px-80px width) - Scale rosary visualization 3.5x on mobile for better visibility - Fix scroll synchronization accounting for CSS transform scale - Increase cross size for better visibility - Extract BenedictusMedal as reusable component - Add smooth scroll polyfills for better browser compatibility - Improve SVG interaction with click handlers and scroll locking
This commit is contained in:
@@ -9,6 +9,8 @@ import AveMaria from "$lib/components/prayers/AveMaria.svelte";
|
||||
import GloriaPatri from "$lib/components/prayers/GloriaPatri.svelte";
|
||||
import FatimaGebet from "$lib/components/prayers/FatimaGebet.svelte";
|
||||
import SalveRegina from "$lib/components/prayers/SalveRegina.svelte";
|
||||
import BenedictusMedal from "$lib/components/BenedictusMedal.svelte";
|
||||
import CounterButton from "$lib/components/CounterButton.svelte";
|
||||
|
||||
// Mystery variations for each type of rosary
|
||||
const mysteries = {
|
||||
@@ -73,15 +75,107 @@ const mysteriesLatin = {
|
||||
]
|
||||
};
|
||||
|
||||
// Determine which mystery to use (for now using schmerzhaften)
|
||||
let currentMysteries = mysteries.schmerzhaften;
|
||||
let currentMysteriesLatin = mysteriesLatin.schmerzhaften;
|
||||
// Toggle for including Luminous mysteries
|
||||
let includeLuminous = true;
|
||||
|
||||
// Function to get the appropriate mystery for a given weekday
|
||||
function getMysteryForWeekday(date, includeLuminous) {
|
||||
const dayOfWeek = date.getDay(); // 0 = Sunday, 1 = Monday, etc.
|
||||
|
||||
if (includeLuminous) {
|
||||
// With Luminous mysteries schedule
|
||||
const schedule = {
|
||||
0: 'glorreichen', // Sunday
|
||||
1: 'freudenreich', // Monday
|
||||
2: 'schmerzhaften', // Tuesday
|
||||
3: 'glorreichen', // Wednesday
|
||||
4: 'lichtreichen', // Thursday
|
||||
5: 'schmerzhaften', // Friday
|
||||
6: 'freudenreich' // Saturday
|
||||
};
|
||||
return schedule[dayOfWeek];
|
||||
} else {
|
||||
// Without Luminous mysteries schedule
|
||||
const schedule = {
|
||||
0: 'glorreichen', // Sunday
|
||||
1: 'freudenreich', // Monday
|
||||
2: 'schmerzhaften', // Tuesday
|
||||
3: 'glorreichen', // Wednesday
|
||||
4: 'freudenreich', // Thursday
|
||||
5: 'schmerzhaften', // Friday
|
||||
6: 'glorreichen' // Saturday
|
||||
};
|
||||
return schedule[dayOfWeek];
|
||||
}
|
||||
}
|
||||
|
||||
// Determine which mystery to use based on current weekday
|
||||
let selectedMystery = getMysteryForWeekday(new Date(), includeLuminous);
|
||||
let currentMysteries = mysteries[selectedMystery];
|
||||
let currentMysteriesLatin = mysteriesLatin[selectedMystery];
|
||||
|
||||
// Function to switch mysteries
|
||||
function selectMystery(mysteryType) {
|
||||
selectedMystery = mysteryType;
|
||||
currentMysteries = mysteries[mysteryType];
|
||||
currentMysteriesLatin = mysteriesLatin[mysteryType];
|
||||
}
|
||||
|
||||
// Function to handle toggle change
|
||||
function handleToggleChange() {
|
||||
// Recalculate the default mystery for today
|
||||
const todaysMystery = getMysteryForWeekday(new Date(), includeLuminous);
|
||||
// Update to today's mystery
|
||||
selectMystery(todaysMystery);
|
||||
}
|
||||
|
||||
// Active section tracking
|
||||
let activeSection = "cross";
|
||||
let sectionElements = {};
|
||||
let svgContainer;
|
||||
|
||||
// Counter for tracking Ave Maria progress in each decade (0-10 for each)
|
||||
let decadeCounters = {
|
||||
secret1: 0,
|
||||
secret2: 0,
|
||||
secret3: 0,
|
||||
secret4: 0,
|
||||
secret5: 0
|
||||
};
|
||||
|
||||
// Function to advance the counter for a specific decade
|
||||
function advanceDecade(decadeNum) {
|
||||
const key = `secret${decadeNum}`;
|
||||
if (decadeCounters[key] < 10) {
|
||||
decadeCounters[key] += 1;
|
||||
|
||||
// When we reach 10, auto-scroll to next section after a brief delay
|
||||
// and reset the counter
|
||||
if (decadeCounters[key] === 10) {
|
||||
setTimeout(() => {
|
||||
// Reset counter to clear highlighting
|
||||
decadeCounters[key] = 0;
|
||||
|
||||
// Determine next section
|
||||
let nextSection;
|
||||
if (decadeNum < 5) {
|
||||
nextSection = `secret${decadeNum}_transition`;
|
||||
} else {
|
||||
nextSection = 'final_transition';
|
||||
}
|
||||
|
||||
// Scroll to next section
|
||||
const nextElement = sectionElements[nextSection];
|
||||
if (nextElement) {
|
||||
const elementTop = nextElement.getBoundingClientRect().top + window.scrollY;
|
||||
const offset = parseFloat(getComputedStyle(document.documentElement).fontSize) * 3;
|
||||
window.scrollTo({ top: elementTop - offset, behavior: 'smooth' });
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Map sections to their vertical positions in the SVG
|
||||
const sectionPositions = {
|
||||
cross: 35,
|
||||
@@ -90,7 +184,7 @@ const sectionPositions = {
|
||||
start2: 135,
|
||||
start3: 160,
|
||||
lbead2: 200,
|
||||
secret1: 280,
|
||||
secret1: 270,
|
||||
secret1_transition: 520,
|
||||
secret2: 560,
|
||||
secret2_transition: 800,
|
||||
@@ -103,7 +197,100 @@ const sectionPositions = {
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
// Set up Intersection Observer for scroll tracking
|
||||
let scrollLock = null; // Track which side initiated the scroll: 'prayer', 'svg', or 'click'
|
||||
let scrollLockTimeout = null;
|
||||
|
||||
const setScrollLock = (source, duration = 1000) => {
|
||||
scrollLock = source;
|
||||
clearTimeout(scrollLockTimeout);
|
||||
scrollLockTimeout = setTimeout(() => {
|
||||
scrollLock = null;
|
||||
}, duration);
|
||||
};
|
||||
|
||||
// Check if browser supports smooth scrolling
|
||||
// Test both CSS property and actual scrollTo API support
|
||||
const supportsNativeSmoothScroll = (() => {
|
||||
if (!('scrollBehavior' in document.documentElement.style)) {
|
||||
return false;
|
||||
}
|
||||
// Additional check: some browsers have the CSS property but not the JS API
|
||||
try {
|
||||
const testElement = document.createElement('div');
|
||||
testElement.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
|
||||
// Smooth scroll polyfill for window scrolling
|
||||
const smoothScrollTo = (targetY, duration = 500) => {
|
||||
if (supportsNativeSmoothScroll) {
|
||||
try {
|
||||
window.scrollTo({ top: targetY, behavior: 'smooth' });
|
||||
return;
|
||||
} catch (e) {
|
||||
// Fall through to polyfill
|
||||
}
|
||||
}
|
||||
|
||||
const startY = window.scrollY || window.pageYOffset;
|
||||
const distance = targetY - startY;
|
||||
const startTime = performance.now();
|
||||
|
||||
const easeInOutQuad = (t) => {
|
||||
return t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t;
|
||||
};
|
||||
|
||||
const scroll = (currentTime) => {
|
||||
const elapsed = currentTime - startTime;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
const ease = easeInOutQuad(progress);
|
||||
window.scrollTo(0, startY + distance * ease);
|
||||
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(scroll);
|
||||
}
|
||||
};
|
||||
|
||||
requestAnimationFrame(scroll);
|
||||
};
|
||||
|
||||
// Smooth scroll polyfill for element scrolling (for SVG container)
|
||||
const smoothScrollElement = (element, targetY, duration = 500) => {
|
||||
if (supportsNativeSmoothScroll) {
|
||||
try {
|
||||
element.scrollTo({ top: targetY, behavior: 'smooth' });
|
||||
return;
|
||||
} catch (e) {
|
||||
// Fall through to polyfill
|
||||
}
|
||||
}
|
||||
|
||||
const startY = element.scrollTop;
|
||||
const distance = targetY - startY;
|
||||
const startTime = performance.now();
|
||||
|
||||
const easeInOutQuad = (t) => {
|
||||
return t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t;
|
||||
};
|
||||
|
||||
const scroll = (currentTime) => {
|
||||
const elapsed = currentTime - startTime;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
const ease = easeInOutQuad(progress);
|
||||
element.scrollTop = startY + distance * ease;
|
||||
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(scroll);
|
||||
}
|
||||
};
|
||||
|
||||
requestAnimationFrame(scroll);
|
||||
};
|
||||
|
||||
// Set up Intersection Observer for scroll tracking (prayers -> SVG)
|
||||
const options = {
|
||||
root: null,
|
||||
rootMargin: "-20% 0px -60% 0px", // Trigger when section is near top
|
||||
@@ -112,7 +299,7 @@ onMount(() => {
|
||||
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
if (entry.isIntersecting && scrollLock !== 'svg' && scrollLock !== 'click') {
|
||||
activeSection = entry.target.dataset.section;
|
||||
|
||||
// Scroll SVG to keep active section visible at top
|
||||
@@ -125,17 +312,20 @@ onMount(() => {
|
||||
const svgHeight = svg.clientHeight;
|
||||
const viewBoxHeight = viewBox.height;
|
||||
|
||||
// Get CSS transform scale (3.5 on mobile, 1 on desktop)
|
||||
const computedStyle = window.getComputedStyle(svg);
|
||||
const matrix = new DOMMatrix(computedStyle.transform);
|
||||
const cssScale = matrix.a || 1;
|
||||
|
||||
// Convert SVG coordinates to pixel coordinates
|
||||
const scale = svgHeight / viewBoxHeight;
|
||||
const scale = (svgHeight / viewBoxHeight) * cssScale;
|
||||
const pixelPosition = svgYPosition * scale;
|
||||
|
||||
// Position with some padding to show context above
|
||||
const targetScroll = pixelPosition - 100;
|
||||
|
||||
svgContainer.scrollTo({
|
||||
top: Math.max(0, targetScroll),
|
||||
behavior: 'smooth'
|
||||
});
|
||||
setScrollLock('prayer');
|
||||
smoothScrollElement(svgContainer, Math.max(0, targetScroll));
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -146,7 +336,165 @@ onMount(() => {
|
||||
if (el) observer.observe(el);
|
||||
});
|
||||
|
||||
return () => observer.disconnect();
|
||||
// Detect when user scrolls past all prayers and snap SVG to bottom or top
|
||||
const handleWindowScroll = () => {
|
||||
if (scrollLock === 'svg' || scrollLock === 'click' || !svgContainer) return;
|
||||
|
||||
const viewportHeight = window.innerHeight;
|
||||
|
||||
// Get the first and final prayer sections
|
||||
const firstSection = sectionElements.cross;
|
||||
const finalSection = sectionElements.final_transition;
|
||||
if (!firstSection || !finalSection) return;
|
||||
|
||||
const firstSectionRect = firstSection.getBoundingClientRect();
|
||||
const finalSectionRect = finalSection.getBoundingClientRect();
|
||||
|
||||
// Check if we've scrolled above the first section (it's completely below viewport)
|
||||
if (firstSectionRect.top > viewportHeight * 0.6) {
|
||||
// Scroll SVG to top
|
||||
if (svgContainer.scrollTop > 10) { // Only if not already at top
|
||||
smoothScrollElement(svgContainer, 0);
|
||||
}
|
||||
}
|
||||
// Check if we've scrolled past the final section (it's completely above viewport)
|
||||
else if (finalSectionRect.bottom < viewportHeight * 0.4) {
|
||||
// Scroll SVG to bottom
|
||||
const maxScroll = svgContainer.scrollHeight - svgContainer.clientHeight;
|
||||
if (svgContainer.scrollTop < maxScroll - 10) { // Only if not already at bottom
|
||||
smoothScrollElement(svgContainer, maxScroll);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('scroll', handleWindowScroll, { passive: true });
|
||||
|
||||
// Debounce SVG scroll handler to avoid excessive updates
|
||||
let svgScrollTimeout = null;
|
||||
const handleSvgScroll = () => {
|
||||
if (scrollLock === 'prayer' || scrollLock === 'click' || !svgContainer) return;
|
||||
|
||||
clearTimeout(svgScrollTimeout);
|
||||
svgScrollTimeout = setTimeout(() => {
|
||||
const svg = svgContainer.querySelector('svg');
|
||||
if (!svg) return;
|
||||
|
||||
const scrollTop = svgContainer.scrollTop;
|
||||
const viewBox = svg.viewBox.baseVal;
|
||||
const svgHeight = svg.clientHeight;
|
||||
const viewBoxHeight = viewBox.height;
|
||||
|
||||
// Get CSS transform scale (3.5 on mobile, 1 on desktop)
|
||||
const computedStyle = window.getComputedStyle(svg);
|
||||
const matrix = new DOMMatrix(computedStyle.transform);
|
||||
const cssScale = matrix.a || 1;
|
||||
|
||||
const scale = (svgHeight / viewBoxHeight) * cssScale;
|
||||
|
||||
// Convert scroll position back to SVG coordinates
|
||||
const svgY = scrollTop / scale;
|
||||
|
||||
// Find the closest section based on scroll position
|
||||
let closestSection = 'cross';
|
||||
let closestDistance = Infinity;
|
||||
|
||||
for (const [section, position] of Object.entries(sectionPositions)) {
|
||||
const distance = Math.abs(svgY - position);
|
||||
if (distance < closestDistance) {
|
||||
closestDistance = distance;
|
||||
closestSection = section;
|
||||
}
|
||||
}
|
||||
|
||||
// Scroll to the corresponding prayer section
|
||||
if (closestSection !== activeSection && sectionElements[closestSection]) {
|
||||
activeSection = closestSection;
|
||||
setScrollLock('svg');
|
||||
|
||||
// Calculate scroll position with offset for spacing at top
|
||||
const element = sectionElements[closestSection];
|
||||
const elementTop = element.getBoundingClientRect().top + window.scrollY;
|
||||
const offset = parseFloat(getComputedStyle(document.documentElement).fontSize) * 3; // 3em in pixels
|
||||
|
||||
smoothScrollTo(elementTop - offset);
|
||||
}
|
||||
}, 150); // Debounce by 150ms
|
||||
};
|
||||
|
||||
if (svgContainer) {
|
||||
svgContainer.addEventListener('scroll', handleSvgScroll);
|
||||
}
|
||||
|
||||
// Handle clicks on SVG elements to jump to prayers
|
||||
const handleSvgClick = (e) => {
|
||||
// Find the clicked element or its parent with a data-section attribute
|
||||
let target = e.target;
|
||||
while (target && target !== svgContainer) {
|
||||
const section = target.dataset.section;
|
||||
if (section && sectionElements[section]) {
|
||||
// Update active section immediately
|
||||
activeSection = section;
|
||||
|
||||
// Lock scrolling for clicks
|
||||
setScrollLock('click', 1500);
|
||||
|
||||
// Scroll the SVG visualization to the clicked section
|
||||
if (sectionPositions[section] !== undefined) {
|
||||
const svg = svgContainer.querySelector('svg');
|
||||
if (svg) {
|
||||
const svgYPosition = sectionPositions[section];
|
||||
const viewBox = svg.viewBox.baseVal;
|
||||
const svgHeight = svg.clientHeight;
|
||||
const viewBoxHeight = viewBox.height;
|
||||
|
||||
// Get CSS transform scale (3.5 on mobile, 1 on desktop)
|
||||
const computedStyle = window.getComputedStyle(svg);
|
||||
const matrix = new DOMMatrix(computedStyle.transform);
|
||||
const cssScale = matrix.a || 1;
|
||||
|
||||
// Convert SVG coordinates to pixel coordinates
|
||||
const scale = (svgHeight / viewBoxHeight) * cssScale;
|
||||
const pixelPosition = svgYPosition * scale;
|
||||
|
||||
// Position with some padding to show context above
|
||||
const targetScroll = pixelPosition - 100;
|
||||
|
||||
smoothScrollElement(svgContainer, Math.max(0, targetScroll));
|
||||
}
|
||||
}
|
||||
|
||||
// Scroll the prayers to the corresponding section
|
||||
const element = sectionElements[section];
|
||||
const elementTop = element.getBoundingClientRect().top + window.scrollY;
|
||||
const offset = parseFloat(getComputedStyle(document.documentElement).fontSize) * 3;
|
||||
|
||||
smoothScrollTo(elementTop - offset);
|
||||
|
||||
break;
|
||||
}
|
||||
target = target.parentElement;
|
||||
}
|
||||
};
|
||||
|
||||
const svg = svgContainer?.querySelector('svg');
|
||||
if (svg) {
|
||||
svg.addEventListener('click', handleSvgClick);
|
||||
// Make it clear elements are clickable
|
||||
svg.style.cursor = 'pointer';
|
||||
}
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
clearTimeout(scrollLockTimeout);
|
||||
clearTimeout(svgScrollTimeout);
|
||||
window.removeEventListener('scroll', handleWindowScroll);
|
||||
if (svgContainer) {
|
||||
svgContainer.removeEventListener('scroll', handleSvgScroll);
|
||||
}
|
||||
if (svg) {
|
||||
svg.removeEventListener('click', handleSvgClick);
|
||||
}
|
||||
};
|
||||
});
|
||||
</script>
|
||||
<style>
|
||||
@@ -157,6 +505,7 @@ onMount(() => {
|
||||
}
|
||||
|
||||
.rosary-layout {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 2rem;
|
||||
@@ -174,14 +523,53 @@ onMount(() => {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Mobile layout: fixed left sidebar for visualization */
|
||||
@media (max-width: 1023px) {
|
||||
.rosary-layout {
|
||||
grid-template-columns: clamp(20px, 10vw, 80px) 1fr;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.rosary-sidebar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.rosary-visualization {
|
||||
height: 100%;
|
||||
padding: 1rem 0;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Make SVG beads larger on mobile by scaling up */
|
||||
.rosary-visualization svg {
|
||||
transform: scale(3.5) translateX(-5px);
|
||||
transform-origin: center top;
|
||||
}
|
||||
|
||||
/* Disable mask on mobile to show full visualization */
|
||||
.rosary-visualization {
|
||||
-webkit-mask-image: none;
|
||||
mask-image: none;
|
||||
}
|
||||
|
||||
.prayers-content {
|
||||
max-width: 100%;
|
||||
padding-left: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.rosary-visualization {
|
||||
padding: 2rem 0;
|
||||
position: sticky;
|
||||
top: 2rem;
|
||||
max-height: calc(100vh - 4rem);
|
||||
max-height: calc(100vh - 2rem);
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
scroll-behavior: smooth;
|
||||
scrollbar-width: none; /* Firefox */
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
/* Mask to hide portions where curve goes off-screen (left side) */
|
||||
@@ -223,6 +611,7 @@ onMount(() => {
|
||||
background: var(--accent-dark);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@media(prefers-color-scheme: light) {
|
||||
@@ -353,6 +742,196 @@ h1 {
|
||||
font-size: 3em;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
/* Luminous mysteries toggle */
|
||||
.luminous-toggle {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
padding: 1rem;
|
||||
background: var(--nord1);
|
||||
border-radius: 8px;
|
||||
max-width: 600px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
@media(prefers-color-scheme: light) {
|
||||
.luminous-toggle {
|
||||
background: var(--nord5);
|
||||
}
|
||||
}
|
||||
|
||||
.luminous-toggle label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
cursor: pointer;
|
||||
font-size: 1.1rem;
|
||||
color: var(--nord4);
|
||||
}
|
||||
|
||||
@media(prefers-color-scheme: light) {
|
||||
.luminous-toggle label {
|
||||
color: var(--nord0);
|
||||
}
|
||||
}
|
||||
|
||||
.luminous-toggle span {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* iOS-style toggle switch */
|
||||
.luminous-toggle input[type="checkbox"] {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
width: 51px;
|
||||
height: 31px;
|
||||
background: var(--nord2);
|
||||
border-radius: 31px;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
transition: background 0.3s ease;
|
||||
outline: none;
|
||||
border: none;
|
||||
}
|
||||
|
||||
@media(prefers-color-scheme: light) {
|
||||
.luminous-toggle input[type="checkbox"] {
|
||||
background: var(--nord4);
|
||||
}
|
||||
}
|
||||
|
||||
.luminous-toggle input[type="checkbox"]:checked {
|
||||
background: var(--nord14);
|
||||
}
|
||||
|
||||
.luminous-toggle input[type="checkbox"]::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 27px;
|
||||
height: 27px;
|
||||
border-radius: 50%;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
background: white;
|
||||
transition: transform 0.3s ease;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.luminous-toggle input[type="checkbox"]:checked::before {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
.luminous-toggle .toggle-description {
|
||||
margin-top: 1rem;
|
||||
font-size: 0.95rem;
|
||||
color: var(--nord8);
|
||||
line-height: 1.6;
|
||||
text-align: center;
|
||||
max-width: 500px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
@media(prefers-color-scheme: light) {
|
||||
.luminous-toggle .toggle-description {
|
||||
color: var(--nord3);
|
||||
}
|
||||
}
|
||||
|
||||
/* Mystery selector grid */
|
||||
.mystery-selector {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 3rem;
|
||||
max-width: 1000px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.mystery-button {
|
||||
background: var(--nord1);
|
||||
border: 2px solid transparent;
|
||||
border-radius: 8px;
|
||||
padding: 2rem 1.5rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
@media(prefers-color-scheme: light) {
|
||||
.mystery-button {
|
||||
background: var(--nord6);
|
||||
}
|
||||
}
|
||||
|
||||
.mystery-button:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.mystery-button.selected {
|
||||
border-color: var(--nord10);
|
||||
background: var(--nord2);
|
||||
}
|
||||
|
||||
@media(prefers-color-scheme: light) {
|
||||
.mystery-button.selected {
|
||||
border-color: var(--nord10);
|
||||
background: var(--nord5);
|
||||
}
|
||||
}
|
||||
|
||||
.mystery-button:nth-child(1):hover { background: var(--nord15); }
|
||||
.mystery-button:nth-child(2):hover { background: var(--nord13); }
|
||||
.mystery-button:nth-child(3):hover { background: var(--nord14); }
|
||||
.mystery-button:nth-child(4):hover { background: var(--nord12); }
|
||||
|
||||
.mystery-button svg {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
fill: var(--nord4);
|
||||
transition: fill 0.3s ease;
|
||||
}
|
||||
|
||||
@media(prefers-color-scheme: light) {
|
||||
.mystery-button svg {
|
||||
fill: var(--nord0);
|
||||
}
|
||||
}
|
||||
|
||||
.mystery-button.selected svg {
|
||||
fill: var(--nord10);
|
||||
}
|
||||
|
||||
.mystery-button h3 {
|
||||
margin: 0;
|
||||
font-size: 1.2rem;
|
||||
color: var(--nord6);
|
||||
}
|
||||
|
||||
@media(prefers-color-scheme: light) {
|
||||
.mystery-button h3 {
|
||||
color: var(--nord0);
|
||||
}
|
||||
}
|
||||
|
||||
.mystery-button.selected h3 {
|
||||
color: var(--nord10);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Highlighted bead (orange for counting) */
|
||||
.rosary-visualization :global(.counted-bead) {
|
||||
fill: var(--nord13) !important;
|
||||
filter: drop-shadow(0 0 8px var(--nord13));
|
||||
}
|
||||
</style>
|
||||
<svelte:head>
|
||||
<title>Rosenkranz - Interaktiv</title>
|
||||
@@ -362,86 +941,153 @@ h1 {
|
||||
<div class="page-container">
|
||||
<h1>Interaktiver Rosenkranz</h1>
|
||||
|
||||
<!-- Luminous Mysteries Toggle -->
|
||||
<div class="luminous-toggle">
|
||||
<label>
|
||||
<input type="checkbox" bind:checked={includeLuminous} on:change={handleToggleChange} />
|
||||
<span>Lichtreiche Geheimnisse einbeziehen</span>
|
||||
</label>
|
||||
<p class="toggle-description">
|
||||
Die Geheimnisse werden automatisch nach dem Wochenplan ausgewählt.
|
||||
{#if includeLuminous}
|
||||
Mit lichtreichen Geheimnissen: Do=Lichtreich, andere Tage folgen dem traditionellen Plan.
|
||||
{:else}
|
||||
Traditioneller Plan ohne lichtreiche Geheimnisse.
|
||||
{/if}
|
||||
Sie können jederzeit manuell ein anderes Geheimnis wählen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Mystery Selector -->
|
||||
<div class="mystery-selector">
|
||||
<button
|
||||
class="mystery-button"
|
||||
class:selected={selectedMystery === 'freudenreich'}
|
||||
on:click={() => selectMystery('freudenreich')}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Placeholder: Star for joyful mysteries -->
|
||||
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
|
||||
</svg>
|
||||
<h3>Freudenreich</h3>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="mystery-button"
|
||||
class:selected={selectedMystery === 'schmerzhaften'}
|
||||
on:click={() => selectMystery('schmerzhaften')}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Placeholder: Cross for sorrowful mysteries -->
|
||||
<path d="M10 2h4v7h7v4h-7v9h-4v-9H3v-4h7V2z"/>
|
||||
</svg>
|
||||
<h3>Schmerzhaften</h3>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="mystery-button"
|
||||
class:selected={selectedMystery === 'glorreichen'}
|
||||
on:click={() => selectMystery('glorreichen')}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Placeholder: Crown for glorious mysteries -->
|
||||
<path d="M5 16l3-6.5 4 4 4-4 3 6.5v4H5v-4zm0-13l2 2-2 2V5zm14 0l-2 2 2 2V5zM12 3l-2 2 2 2 2-2-2-2z"/>
|
||||
</svg>
|
||||
<h3>Glorreichen</h3>
|
||||
</button>
|
||||
|
||||
{#if includeLuminous}
|
||||
<button
|
||||
class="mystery-button"
|
||||
class:selected={selectedMystery === 'lichtreichen'}
|
||||
on:click={() => selectMystery('lichtreichen')}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Placeholder: Candle/Light for luminous mysteries -->
|
||||
<path d="M9 2h6v2H9V2zm3 3c-2.76 0-5 2.24-5 5 0 2.04 1.23 3.79 3 4.58V21h4v-6.42c1.77-.79 3-2.54 3-4.58 0-2.76-2.24-5-5-5zm0 2c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3z"/>
|
||||
</svg>
|
||||
<h3>Lichtreichen</h3>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="rosary-layout">
|
||||
<!-- Sidebar: Rosary Visualization -->
|
||||
<div class="rosary-sidebar">
|
||||
<div class="rosary-visualization" bind:this={svgContainer}>
|
||||
<svg class="linear-rosary" viewBox="-100 -100 250 2200" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMin meet">
|
||||
<!-- Vertical chain -->
|
||||
<line x1="50" y1="50" x2="50" y2="1655" class="chain" />
|
||||
|
||||
<!-- Circular connection: from last bead to medal (bezier curve) -->
|
||||
<path d="M 50 1640 Q -3000 4000, -3000 140 Q -1550 580, 50 235"
|
||||
class="chain"
|
||||
fill="none" />
|
||||
<line x1="50" y1="35" x2="50" y2="1655" class="chain" />
|
||||
|
||||
<!-- Cross (at top) -->
|
||||
<g id="cross-section">
|
||||
<text x="50" y="35" text-anchor="middle" font-size="50"
|
||||
<g id="cross-section" data-section="cross">
|
||||
<text x="50" y="35" text-anchor="middle" font-size="80"
|
||||
class="cross-symbol" class:active-cross={activeSection === 'cross'}>♱</text>
|
||||
</g>
|
||||
|
||||
<!-- First large bead (Paternoster) -->
|
||||
<circle cx="50" cy="80" r="15" class="large-bead" class:active-large-bead={activeSection === 'lbead1'} />
|
||||
<circle cx="50" cy="80" r="15" class="large-bead" class:active-large-bead={activeSection === 'lbead1'} data-section="lbead1" />
|
||||
|
||||
<!-- Three small beads -->
|
||||
<circle cx="50" cy="110" r="10" class="bead" class:active-bead={activeSection === 'start1'} />
|
||||
<circle cx="50" cy="135" r="10" class="bead" class:active-bead={activeSection === 'start2'} />
|
||||
<circle cx="50" cy="160" r="10" class="bead" class:active-bead={activeSection === 'start3'} />
|
||||
<circle cx="50" cy="110" r="10" class="bead" class:active-bead={activeSection === 'start1'} data-section="start1" />
|
||||
<circle cx="50" cy="135" r="10" class="bead" class:active-bead={activeSection === 'start2'} data-section="start2" />
|
||||
<circle cx="50" cy="160" r="10" class="bead" class:active-bead={activeSection === 'start3'} data-section="start3" />
|
||||
|
||||
<!-- Large bead before decades -->
|
||||
<circle cx="50" cy="200" r="15" class="large-bead" class:active-large-bead={activeSection === 'lbead2'} />
|
||||
<circle cx="50" cy="200" r="15" class="large-bead" class:active-large-bead={activeSection === 'lbead2'} data-section="lbead2" />
|
||||
|
||||
<!-- Benedictus Medal (inline) -->
|
||||
<g id="benedictus-medal">
|
||||
<!-- Medal circle -->
|
||||
<circle cx="50" cy="240" r="20" class="medal" fill="var(--nord9)" stroke="var(--nord4)" stroke-width="2"/>
|
||||
|
||||
<!-- Cross on medal (bar cross) -->
|
||||
<line x1="50" y1="228" x2="50" y2="252" stroke="var(--nord4)" stroke-width="2.5"/>
|
||||
<line x1="38" y1="240" x2="62" y2="240" stroke="var(--nord4)" stroke-width="2.5"/>
|
||||
|
||||
<!-- Letters around the medal: C S S M -->
|
||||
<text x="50" y="223" text-anchor="middle" font-size="8" fill="var(--nord4)" font-weight="bold">C</text>
|
||||
<text x="50" y="260" text-anchor="middle" font-size="8" fill="var(--nord4)" font-weight="bold">S</text>
|
||||
<text x="35" y="244" text-anchor="middle" font-size="8" fill="var(--nord4)" font-weight="bold">S</text>
|
||||
<text x="65" y="244" text-anchor="middle" font-size="8" fill="var(--nord4)" font-weight="bold">M</text>
|
||||
</g>
|
||||
<!-- Benedictus Medal -->
|
||||
<BenedictusMedal x={30} y={220} size={40} />
|
||||
|
||||
<!-- Decade 1: Ave Maria (10 beads) -->
|
||||
{#each Array(10) as _, i}
|
||||
<circle cx="50" cy={280 + i * 22} r="10" class="bead" class:active-bead={activeSection === 'secret1'} />
|
||||
<circle cx="50" cy={280 + i * 22} r="10" class="bead"
|
||||
class:active-bead={activeSection === 'secret1'}
|
||||
class:counted-bead={i < decadeCounters.secret1}
|
||||
data-section="secret1" />
|
||||
{/each}
|
||||
<!-- Transition 1: Gloria + Fatima + Paternoster (large bead) -->
|
||||
<circle cx="50" cy="520" r="15" class="large-bead" class:active-large-bead={activeSection === 'secret1_transition'} />
|
||||
<circle cx="50" cy="520" r="15" class="large-bead" class:active-large-bead={activeSection === 'secret1_transition'} data-section="secret1_transition" />
|
||||
|
||||
<!-- Decade 2: Ave Maria (10 beads) -->
|
||||
{#each Array(10) as _, i}
|
||||
<circle cx="50" cy={560 + i * 22} r="10" class="bead" class:active-bead={activeSection === 'secret2'} />
|
||||
<circle cx="50" cy={560 + i * 22} r="10" class="bead"
|
||||
class:active-bead={activeSection === 'secret2'}
|
||||
class:counted-bead={i < decadeCounters.secret2}
|
||||
data-section="secret2" />
|
||||
{/each}
|
||||
<!-- Transition 2: Gloria + Fatima + Paternoster (large bead) -->
|
||||
<circle cx="50" cy="800" r="15" class="large-bead" class:active-large-bead={activeSection === 'secret2_transition'} />
|
||||
<circle cx="50" cy="800" r="15" class="large-bead" class:active-large-bead={activeSection === 'secret2_transition'} data-section="secret2_transition" />
|
||||
|
||||
<!-- Decade 3: Ave Maria (10 beads) -->
|
||||
{#each Array(10) as _, i}
|
||||
<circle cx="50" cy={840 + i * 22} r="10" class="bead" class:active-bead={activeSection === 'secret3'} />
|
||||
<circle cx="50" cy={840 + i * 22} r="10" class="bead"
|
||||
class:active-bead={activeSection === 'secret3'}
|
||||
class:counted-bead={i < decadeCounters.secret3}
|
||||
data-section="secret3" />
|
||||
{/each}
|
||||
<!-- Transition 3: Gloria + Fatima + Paternoster (large bead) -->
|
||||
<circle cx="50" cy="1080" r="15" class="large-bead" class:active-large-bead={activeSection === 'secret3_transition'} />
|
||||
<circle cx="50" cy="1080" r="15" class="large-bead" class:active-large-bead={activeSection === 'secret3_transition'} data-section="secret3_transition" />
|
||||
|
||||
<!-- Decade 4: Ave Maria (10 beads) -->
|
||||
{#each Array(10) as _, i}
|
||||
<circle cx="50" cy={1120 + i * 22} r="10" class="bead" class:active-bead={activeSection === 'secret4'} />
|
||||
<circle cx="50" cy={1120 + i * 22} r="10" class="bead"
|
||||
class:active-bead={activeSection === 'secret4'}
|
||||
class:counted-bead={i < decadeCounters.secret4}
|
||||
data-section="secret4" />
|
||||
{/each}
|
||||
<!-- Transition 4: Gloria + Fatima + Paternoster (large bead) -->
|
||||
<circle cx="50" cy="1360" r="15" class="large-bead" class:active-large-bead={activeSection === 'secret4_transition'} />
|
||||
<circle cx="50" cy="1360" r="15" class="large-bead" class:active-large-bead={activeSection === 'secret4_transition'} data-section="secret4_transition" />
|
||||
|
||||
<!-- Decade 5: Ave Maria (10 beads) -->
|
||||
{#each Array(10) as _, i}
|
||||
<circle cx="50" cy={1400 + i * 22} r="10" class="bead" class:active-bead={activeSection === 'secret5'} />
|
||||
<circle cx="50" cy={1400 + i * 22} r="10" class="bead"
|
||||
class:active-bead={activeSection === 'secret5'}
|
||||
class:counted-bead={i < decadeCounters.secret5}
|
||||
data-section="secret5" />
|
||||
{/each}
|
||||
<!-- Final transition: Gloria + Fatima -->
|
||||
<circle cx="50" cy="1640" r="15" class="large-bead" class:active-large-bead={activeSection === 'final_transition'} />
|
||||
<circle cx="50" cy="1640" r="15" class="large-bead" class:active-large-bead={activeSection === 'final_transition'} data-section="final_transition" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
@@ -454,7 +1100,8 @@ h1 {
|
||||
bind:this={sectionElements.cross}
|
||||
data-section="cross"
|
||||
>
|
||||
<h2>♱ Das Kreuzzeichen</h2>
|
||||
<h2>Anfang</h2>
|
||||
<h3>♱ Das Kreuzzeichen</h3>
|
||||
<Kreuzzeichen />
|
||||
<h3>Credo</h3>
|
||||
<Credo />
|
||||
@@ -466,7 +1113,7 @@ h1 {
|
||||
bind:this={sectionElements.lbead1}
|
||||
data-section="lbead1"
|
||||
>
|
||||
<h2>Vater unser</h2>
|
||||
<h3>Vater unser</h3>
|
||||
<Paternoster />
|
||||
</div>
|
||||
|
||||
@@ -476,7 +1123,7 @@ h1 {
|
||||
bind:this={sectionElements.start1}
|
||||
data-section="start1"
|
||||
>
|
||||
<h2>Ave Maria</h2>
|
||||
<h3>Ave Maria</h3>
|
||||
<AveMaria
|
||||
mysteryLatin="Jesus, qui fidem in nobis augeat"
|
||||
mystery="Jesus, der in uns den Glauben vermehre"
|
||||
@@ -489,7 +1136,7 @@ h1 {
|
||||
bind:this={sectionElements.start2}
|
||||
data-section="start2"
|
||||
>
|
||||
<h2>Ave Maria</h2>
|
||||
<h3>Ave Maria</h3>
|
||||
<AveMaria
|
||||
mysteryLatin="Jesus, qui spem in nobis firmet"
|
||||
mystery="Jesus, der in uns die Hoffnung stärke"
|
||||
@@ -502,7 +1149,7 @@ h1 {
|
||||
bind:this={sectionElements.start3}
|
||||
data-section="start3"
|
||||
>
|
||||
<h2>Ave Maria</h2>
|
||||
<h3>Ave Maria</h3>
|
||||
<AveMaria
|
||||
mysteryLatin="Jesus, qui caritatem in nobis accendat"
|
||||
mystery="Jesus, der in uns die Liebe entzünde"
|
||||
@@ -535,6 +1182,9 @@ h1 {
|
||||
mysteryLatin={currentMysteriesLatin[decadeNum - 1]}
|
||||
mystery={currentMysteries[decadeNum - 1]}
|
||||
/>
|
||||
|
||||
<!-- Counter button -->
|
||||
<CounterButton onClick={() => advanceDecade(decadeNum)} />
|
||||
</div>
|
||||
|
||||
<!-- Transition prayers (Gloria, Fatima, Paternoster) -->
|
||||
|
||||
Reference in New Issue
Block a user