Initial commit — Rosary Presenter App

Full source for loveandrosary.com: slide-based Rosary/novena/Divine Mercy
Chaplet presentation tool with multi-user roles, SVG bead ring, audio uploads,
donate strip, and public session profiles.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-13 18:44:08 -07:00
commit 663fde3909
46 changed files with 10902 additions and 0 deletions
+668
View File
@@ -0,0 +1,668 @@
/**
* assets/js/presenter.js (v11)
*
* Controls slide navigation and DOM updates for the presentation view.
* Depends on:
* - SLIDES (global, JSON array from PHP)
* - SESSION_ID (global, integer session ID)
* - BACK_URL (global, URL to return to on exit)
* - RosaryRing (global, from rosary.js)
*/
(function () {
'use strict';
if (!window.SLIDES || !SLIDES.length) {
console.error('No slides data found.');
return;
}
var currentIndex = 0;
// -----------------------------------------------------------------------
// Slide expansion — pre-split long prayers into "Part 1 / 2" / "Part 2 / 2"
// so text stays large and readable on Zoom / projector rather than shrinking
// to illegibility. Prayers with more than SPLIT_THRESHOLD combined lines are
// automatically split at the nearest paragraph break.
// -----------------------------------------------------------------------
var SPLIT_THRESHOLD = 10;
function countLines(text) {
if (!text || !text.trim()) return 0;
return text.split('\n').length;
}
function splitText(text) {
// Prefer splitting at paragraph breaks (\n\n)
var paras = text.split('\n\n');
if (paras.length >= 2) {
var mid = Math.ceil(paras.length / 2);
var p1 = paras.slice(0, mid).join('\n\n').trim();
var p2 = paras.slice(mid).join('\n\n').trim();
if (p1 && p2) return [p1, p2];
}
// Fall back: split at line midpoint
var lines = text.split('\n');
var mid = Math.ceil(lines.length / 2);
return [lines.slice(0, mid).join('\n').trim(), lines.slice(mid).join('\n').trim()];
}
function expandSlides(rawSlides) {
var result = [];
for (var i = 0; i < rawSlides.length; i++) {
var s = rawSlides[i];
var leaderLines = countLines(s.leader);
var allLines = countLines(s.all);
if (leaderLines + allLines <= SPLIT_THRESHOLD) {
result.push(s);
continue;
}
// Decide which field to split (the longer one)
var field = (allLines >= leaderLines) ? 'all' : 'leader';
var halves = splitText(s[field]);
if (!halves[0] || !halves[1]) { result.push(s); continue; } // safety
var p1 = Object.assign({}, s);
var p2 = Object.assign({}, s);
p1._partNum = 1; p1._partTotal = 2;
p2._partNum = 2; p2._partTotal = 2;
p2.bead_index = null; // bead was "prayed" in part 1
if (field === 'all') {
p1.all = halves[0];
p2.leader = '';
p2.all = halves[1];
} else {
p1.leader = halves[0];
p1.all = '';
p2.leader = halves[1];
}
result.push(p1, p2);
}
return result;
}
var slides = expandSlides(SLIDES);
var total = slides.length;
// localStorage key for resume position
var RESUME_KEY = 'rosary_pos_' + (window.SESSION_ID || '0');
// Pre-compute repeat-run info for every (possibly expanded) slide.
// Group consecutive slides by prayer content (leader + all text), not title —
// so opening Hail Marys "in Faith / Hope / Charity" and decade Hail Marys all
// detect as runs even though their titles differ.
var slideRunInfo = (function () {
var info = new Array(total).fill(null);
var i = 0;
while (i < total) {
var s = slides[i];
var key = s.type + '||' + (s.leader || '') + '||' + (s.all || '');
var start = i;
while (i < total) {
var si = slides[i];
var ki = si.type + '||' + (si.leader || '') + '||' + (si.all || '');
if (ki !== key) break;
i++;
}
var len = i - start;
if (len > 1) {
for (var j = start; j < i; j++) {
info[j] = { pos: j - start + 1, total: len };
}
}
}
return info;
})();
// Map bead index → first slide index that uses that bead.
// Used by bead click navigation so clicking a bead jumps to the right slide.
var beadToSlide = {};
(function () {
for (var i = 0; i < total; i++) {
var bi = slides[i].bead_index;
if (bi !== null && bi !== undefined && !(bi in beadToSlide)) {
beadToSlide[bi] = i;
}
}
})();
// Scan slides 0..upToIndex and return the highest non-null bead_index seen.
// Used so "between-bead" slides (Glory Be, Fatima, litanies) keep prayed
// beads red instead of resetting everything to gray.
function getLastPrayedBead(upToIndex) {
var last = null;
for (var i = 0; i <= upToIndex; i++) {
var bi = slides[i] ? slides[i].bead_index : null;
if (bi !== null && bi !== undefined) {
last = bi;
}
}
return last;
}
// DOM refs
var coverSlide = document.getElementById('cover-slide');
var prayerSlide = document.getElementById('prayer-slide');
var mysterySlide = document.getElementById('mystery-slide');
var litanySlide = document.getElementById('litany-slide');
var closingSlide = document.getElementById('closing-slide');
var btnPrev = document.getElementById('btn-prev');
var btnNext = document.getElementById('btn-next');
var btnExit = document.getElementById('btn-exit');
var btnAudio = document.getElementById('btn-audio');
var slideCounter = document.getElementById('slide-counter');
var resumeToast = document.getElementById('resume-toast');
// --------------------------------------------------------------------------
// Audio
// AUDIO_MANIFEST: {key: ext, ...} — provided by present.php
// AUDIO_BASE_URL: '/uploads/audio/'
// --------------------------------------------------------------------------
var AUDIO_PREF_KEY = 'rosary_audio'; // localStorage — global pref, not per-session
var audioEnabled = false;
var currentAudio = null;
var hasAudioFiles = window.AUDIO_MANIFEST && Object.keys(AUDIO_MANIFEST).length > 0;
if (hasAudioFiles) {
var stored = localStorage.getItem(AUDIO_PREF_KEY);
// Default ON when audio is available; user can mute
audioEnabled = (stored === null) ? true : (stored === 'on');
}
function updateAudioBtn() {
if (!btnAudio) return;
if (audioEnabled) {
btnAudio.innerHTML = '&#128264;'; // 🔊
btnAudio.title = 'Audio on — click to mute';
} else {
btnAudio.innerHTML = '&#128263;'; // 🔇
btnAudio.title = 'Audio off — click to enable';
}
}
function stopAudio() {
if (currentAudio) {
currentAudio.pause();
currentAudio.currentTime = 0;
currentAudio = null;
}
}
// Map a slide's id to a canonical audio key.
// Many slides share one recording (e.g. all 55 Hail Marys → 'hail_mary').
function getAudioKey(slide) {
if (!slide) return null;
var id = slide.id || '';
if (!id || id === 'cover') return null;
// Common prayers — many slides → one key
if (id === 'sign_of_cross' || id === 'dm_sign_of_cross') return 'sign_of_cross';
if (id === 'apostles_creed' || id === 'dm_apostles_creed') return 'apostles_creed';
if (id.indexOf('our_father') === 0 || id === 'dm_our_father_opening') return 'our_father';
if (id.indexOf('hail_mary') === 0 || id === 'dm_hail_mary_opening') return 'hail_mary';
if (id.indexOf('glory_be') === 0) return 'glory_be';
if (id.indexOf('fatima_') === 0) return 'fatima_prayer';
// Mysteries — unique per mystery
if (id.indexOf('mystery_') === 0) return id;
// Hail Holy Queen — both slides share one audio
if (id.indexOf('hail_holy_queen') === 0) return 'hail_holy_queen';
// Rosary closing prayer + generic closing
if (id === 'rosary_closing_prayer') return 'rosary_closing_prayer';
if (id === 'closing') return 'closing';
// Litany of Passion — unique per entry
if (id.indexOf('litany_passion_') === 0) return id;
// Novena deceased day prayers — unique per day
if (id.indexOf('novena_day_') === 0) return id;
// Litany for Departed — unique per entry
if (id.indexOf('litany_departed_') === 0) return id;
// Divine Mercy
if (id === 'dm_opening') return 'dm_opening';
if (id.indexOf('dm_blood_water') === 0) return 'dm_blood_water';
if (id.indexOf('dm_intention_day_') === 0) return id;
if (id.indexOf('dm_prayer_day_') === 0) return id;
if (id.indexOf('dm_eternal_father') === 0) return 'dm_eternal_father';
if (id.indexOf('dm_for_sake') === 0) return 'dm_for_sake';
if (id.indexOf('dm_holy_god') === 0) return 'dm_holy_god';
return id; // fallback: use slide ID directly
}
function playSlideAudio(slide) {
stopAudio();
if (!audioEnabled || !hasAudioFiles) return;
var key = getAudioKey(slide);
if (!key || !AUDIO_MANIFEST[key]) return;
var audio = new Audio(window.AUDIO_BASE_URL + key + '.' + AUDIO_MANIFEST[key]);
audio.play().catch(function () { /* silently skip — file may be absent */ });
currentAudio = audio;
}
if (btnAudio) {
btnAudio.addEventListener('click', function () {
audioEnabled = !audioEnabled;
try { localStorage.setItem(AUDIO_PREF_KEY, audioEnabled ? 'on' : 'off'); } catch (e) {}
updateAudioBtn();
if (!audioEnabled) stopAudio();
});
}
updateAudioBtn();
// Hide all slide panels
function hideAll() {
coverSlide.style.display = 'none';
prayerSlide.style.display = 'none';
mysterySlide.style.display = 'none';
litanySlide.style.display = 'none';
closingSlide.style.display = 'none';
}
// Helpers
function setText(id, value) {
var el = document.getElementById(id);
if (el) el.textContent = value || '';
}
function setHTML(id, value) {
var el = document.getElementById(id);
if (el) el.innerHTML = value || '';
}
function showEl(id) {
var el = document.getElementById(id);
if (el) el.style.display = '';
}
function hideEl(id) {
var el = document.getElementById(id);
if (el) el.style.display = 'none';
}
// Convert newlines to <br> for display, keeping pre-line behavior
function nl(text) {
if (!text) return '';
return text.replace(/\n/g, '\n'); // already handled by white-space: pre-line
}
// --------------------------------------------------------------------------
// Render a slide
// --------------------------------------------------------------------------
function render(index) {
hideAll();
var slide = slides[index];
if (!slide) return;
// Trigger CSS fade-in by re-inserting / touching the element
switch (slide.type) {
case 'cover':
renderCover(slide);
coverSlide.style.display = '';
break;
case 'mystery':
renderMystery(slide);
mysterySlide.style.display = '';
break;
case 'litany':
renderLitany(slide);
litanySlide.style.display = '';
break;
case 'closing':
renderClosing(slide);
closingSlide.style.display = '';
break;
default: // 'prayer'
renderPrayer(slide);
prayerSlide.style.display = '';
break;
}
// Update counter (cover is slide 0, not counted in "Prayer X of Y")
updateCounter(index);
// Show repeat badge when the same content repeats (e.g. Agnus Dei × 3)
updateRepeatBadge(index);
// Shrink text so the slide fits on screen without scrolling
fitTextToScreen();
// Update rosary bead ring — pass both the current bead and the last prayed bead
// so "between-bead" slides (Glory Be, Fatima, litanies) keep the ring colored.
if (typeof RosaryRing !== 'undefined') {
var currentBead = (slide.bead_index !== undefined) ? slide.bead_index : null;
var lastBead = getLastPrayedBead(index);
RosaryRing.update(currentBead, lastBead);
}
// Scroll slide content back to top on each navigation
var content = document.getElementById('slide-content');
if (content) content.scrollTop = 0;
// Update nav buttons
btnPrev.disabled = (index === 0);
btnNext.disabled = (index === total - 1);
}
function renderCover(slide) {
setText('cover-title', slide.title);
setText('cover-body', slide.all);
var photoWrap = document.getElementById('cover-photo-wrap');
var photoImg = document.getElementById('cover-photo');
if (slide.photo_path && photoImg) {
photoImg.src = slide.photo_path;
photoImg.style.display = '';
photoWrap.classList.remove('no-photo');
} else {
photoWrap.classList.add('no-photo');
}
}
function renderPrayer(slide) {
setText('slide-title', slide.title);
var hasLeader = !!(slide.leader && slide.leader.trim());
var hasAll = !!(slide.all && slide.all.trim());
if (hasLeader) {
showEl('leader-wrap');
setText('leader-text', slide.leader);
} else {
hideEl('leader-wrap');
}
if (hasAll) {
showEl('all-wrap');
setText('all-text', slide.all);
} else {
hideEl('all-wrap');
}
// Bump font size when only one section is present
var slideText = document.getElementById('slide-text');
if (slideText) {
if (hasLeader !== hasAll) {
slideText.classList.add('single-section');
} else {
slideText.classList.remove('single-section');
}
}
}
function renderMystery(slide) {
// title is like "The Third Sorrowful Mystery"
// leader is the name of the mystery
// all is the fruit/intention
setText('mystery-number', slide.title);
setText('mystery-title', slide.leader);
setText('mystery-fruit', slide.all);
}
function renderLitany(slide) {
setText('litany-title', slide.title);
var hasLeader = !!(slide.leader && slide.leader.trim());
var hasAll = !!(slide.all && slide.all.trim());
if (hasLeader) {
showEl('litany-leader-wrap');
setText('litany-leader', slide.leader);
} else {
hideEl('litany-leader-wrap');
}
if (hasAll) {
showEl('litany-all-wrap');
setText('litany-all', slide.all);
} else {
hideEl('litany-all-wrap');
}
}
function renderClosing(slide) {
setText('closing-title', slide.title);
setText('closing-subtitle', slide.subtitle || '');
setText('closing-body', slide.all);
var photoWrap = document.getElementById('closing-photo-wrap');
var photoImg = document.getElementById('closing-photo');
if (slide.photo_path && photoImg) {
photoImg.src = slide.photo_path;
photoWrap.style.display = '';
} else if (photoWrap) {
photoWrap.style.display = 'none';
}
}
// --------------------------------------------------------------------------
// fitTextToScreen — shrink prayer/litany text until the slide fits on screen
// without a scrollbar. Uses binary search over pixel font-size.
// Called after every render so long prayers (e.g. Day 5 novena, Concluding
// Prayer) automatically scale down rather than requiring a scroll.
// --------------------------------------------------------------------------
var FIT_TEXT_ELS = '#leader-text, #all-text, #litany-leader, #litany-all, #closing-body';
var FIT_MIN_PX = 22; // floor — stay readable for Zoom / projector audiences
function fitTextToScreen() {
var container = document.getElementById('slide-content');
if (!container) return;
var textEls = container.querySelectorAll(FIT_TEXT_ELS);
// Reset any previous override so we measure the natural (CSS) size
for (var k = 0; k < textEls.length; k++) {
textEls[k].style.fontSize = '';
}
// Already fits — nothing to do
if (container.scrollHeight <= container.clientHeight + 2) return;
if (!textEls.length) return; // no adjustable text (cover, mystery, etc.)
// Upper bound = current computed size; lower bound = readable minimum
var naturalPx = parseFloat(window.getComputedStyle(textEls[0]).fontSize);
var lo = FIT_MIN_PX;
var hi = naturalPx;
// Binary search: 20 iterations converges to < 0.1 px accuracy
for (var iter = 0; iter < 20; iter++) {
var mid = (lo + hi) / 2;
for (var k = 0; k < textEls.length; k++) {
textEls[k].style.fontSize = mid + 'px';
}
if (container.scrollHeight <= container.clientHeight + 2) {
lo = mid; // fits — try a touch larger
} else {
hi = mid; // overflows — try smaller
}
}
// Lock in the converged size
for (var k = 0; k < textEls.length; k++) {
textEls[k].style.fontSize = lo + 'px';
}
}
function updateCounter(index) {
var prayerTotal = total - 1; // exclude cover
if (index === 0) {
slideCounter.textContent = '';
} else {
slideCounter.textContent = 'Prayer ' + index + ' of ' + prayerTotal;
}
}
function updateRepeatBadge(index) {
// Hide every badge first (avoid NodeList.forEach for compatibility)
var allBadges = document.querySelectorAll('.repeat-badge');
for (var k = 0; k < allBadges.length; k++) {
allBadges[k].style.display = 'none';
allBadges[k].textContent = '';
}
var slide = slides[index];
// Part badge takes priority (long prayer split across slides)
var badgeText = '';
if (slide._partNum) {
badgeText = 'Part ' + slide._partNum + ' / ' + slide._partTotal;
} else {
var run = slideRunInfo[index];
if (run) badgeText = run.pos + ' of ' + run.total;
}
if (!badgeText) return;
// Show the badge inside the currently-visible slide panel
var panelId = (slide.type === 'litany') ? 'litany-slide' : 'prayer-slide';
var panel = document.getElementById(panelId);
var badge = panel ? panel.querySelector('.repeat-badge') : null;
if (badge) {
badge.textContent = badgeText;
badge.style.display = '';
}
}
// --------------------------------------------------------------------------
// Navigation
// --------------------------------------------------------------------------
function goTo(index) {
if (index < 0 || index >= total) return;
currentIndex = index;
render(currentIndex);
// Play audio for this slide (stops previous audio automatically)
playSlideAudio(slides[index]);
// Persist position so the user can resume after exiting.
// Clear it when they reach the closing slide (rosary complete).
try {
if (index > 0 && index < total - 1) {
localStorage.setItem(RESUME_KEY, index);
} else {
localStorage.removeItem(RESUME_KEY);
}
} catch (e) { /* storage unavailable */ }
}
function prev() { goTo(currentIndex - 1); }
function next() { goTo(currentIndex + 1); }
btnPrev.addEventListener('click', prev);
btnNext.addEventListener('click', next);
if (btnExit) {
btnExit.addEventListener('click', function () {
// Position already saved by goTo; navigate back
window.location.href = window.BACK_URL || '/';
});
}
// Keyboard
document.addEventListener('keydown', function (e) {
switch (e.key) {
case 'ArrowRight':
case 'ArrowDown':
case ' ':
e.preventDefault();
next();
break;
case 'ArrowLeft':
case 'ArrowUp':
e.preventDefault();
prev();
break;
case 'f':
case 'F':
toggleFullscreen();
break;
}
});
// Touch swipe
var touchStartX = null;
var touchStartY = null;
document.addEventListener('touchstart', function (e) {
touchStartX = e.touches[0].clientX;
touchStartY = e.touches[0].clientY;
}, { passive: true });
document.addEventListener('touchend', function (e) {
if (touchStartX === null) return;
var dx = e.changedTouches[0].clientX - touchStartX;
var dy = e.changedTouches[0].clientY - touchStartY;
// Only respond to mostly-horizontal swipes
if (Math.abs(dx) > Math.abs(dy) && Math.abs(dx) > 40) {
if (dx < 0) next();
else prev();
}
touchStartX = null;
touchStartY = null;
}, { passive: true });
// Fullscreen
function toggleFullscreen() {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen().catch(function () {});
} else {
document.exitFullscreen().catch(function () {});
}
}
// Re-fit text when the window is resized (e.g. projector moved to a
// different monitor, fullscreen toggled, or phone rotated).
var resizeTimer = null;
window.addEventListener('resize', function () {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(function () { render(currentIndex); }, 150);
});
// --------------------------------------------------------------------------
// Initialize
// --------------------------------------------------------------------------
// Check for a saved resume position
var resumeIndex = 0;
try {
var saved = localStorage.getItem(RESUME_KEY);
if (saved !== null) {
var parsed = parseInt(saved, 10);
if (!isNaN(parsed) && parsed > 0 && parsed < total - 1) {
resumeIndex = parsed;
}
}
} catch (e) { /* storage unavailable */ }
render(resumeIndex);
currentIndex = resumeIndex;
// Show fade-out toast if resuming mid-prayer
if (resumeIndex > 0 && resumeToast) {
resumeToast.textContent = 'Resumed at prayer ' + resumeIndex + ' of ' + (total - 1);
resumeToast.classList.add('visible');
setTimeout(function () {
resumeToast.classList.remove('visible');
}, 2800);
}
// Wire bead clicks: jump to the first slide that uses that bead
if (typeof RosaryRing !== 'undefined' && RosaryRing.onBeadClick) {
RosaryRing.onBeadClick(function (beadIndex) {
if (beadIndex in beadToSlide) {
goTo(beadToSlide[beadIndex]);
}
});
}
})();
+364
View File
@@ -0,0 +1,364 @@
/**
* assets/js/rosary.js
*
* Draws an SVG rosary ring around the viewport edges.
*
* Layout: 60 beads distributed clockwise, starting at the bottom-center
* with the crucifix, then going right → up the right edge → left along top
* → down the left edge → back to bottom-center.
*
* Bead index mapping (60 beads total):
* 0 = Crucifix (bottom center, stem)
* 1 = large (Our Father, stem)
* 24 = small (3 Hail Marys, stem)
* 5 = large (Our Father, Decade 1)
* 615 = small (10 HMs, Decade 1)
* 16 = large (Our Father, Decade 2)
* 1726 = small (Decade 2)
* 27 = large (Our Father, Decade 3)
* 2837 = small (Decade 3)
* 38 = large (Our Father, Decade 4)
* 3948 = small (Decade 4)
* 49 = large (Our Father, Decade 5)
* 5059 = small (Decade 5)
*
* Colors:
* Unprayed : #555555 (dark gray)
* Current : #FFD700 (gold, glow)
* Prayed : #990000 (deep red)
*/
var RosaryRing = (function () {
'use strict';
var NS = 'http://www.w3.org/2000/svg';
var MARGIN = 28; // px from edge
var R_SMALL = 6;
var R_LARGE = 10;
var R_CRUCIFIX = 12;
var COLOR_UNPRAYED = '#555555';
var COLOR_CURRENT = '#FFD700';
var COLOR_PRAYED = '#990000';
var svg = null;
var beadEls = []; // SVG <circle> elements, indexed 0-59
var polyline = null;
var beadPositions = []; // [{x, y}] for each bead
var _beadClickHandler = null; // registered by presenter.js
// Which bead indices are "large" (Our Father) beads
var LARGE_BEADS = new Set([1, 5, 16, 27, 38, 49]);
var CRUCIFIX_BEADS = new Set([0]);
// --------------------------------------------------------------------------
// Calculate bead positions around the viewport edges
// --------------------------------------------------------------------------
function calcPositions(W, H) {
var m = MARGIN;
// Total path length around the rectangle (at margin inset)
// Corners: (m,m), (W-m,m), (W-m,H-m), (m,H-m)
var innerW = W - 2 * m;
var innerH = H - 2 * m;
var perimeter = 2 * (innerW + innerH);
// The ring uses beads 059 (60 total).
// Bead 0 (crucifix) is at the bottom-center.
// We distribute beads clockwise starting from bottom-center,
// going RIGHT first along the bottom edge.
// Reference point for bead 0: bottom center of inset rectangle
var startX = W / 2;
var startY = H - m;
var positions = [];
for (var i = 0; i < 60; i++) {
// Fraction of the perimeter for this bead (clockwise from bottom-center)
var frac = i / 60;
var dist = frac * perimeter;
var pt = pointOnRect(dist, startX, startY, m, W, H, innerW, innerH, perimeter);
positions.push(pt);
}
return positions;
}
/**
* Given a distance along the perimeter (starting at bottom-center, clockwise),
* return the {x, y} point on the inset rectangle.
*/
function pointOnRect(dist, startX, startY, m, W, H, innerW, innerH, perimeter) {
var x, y;
// Segment lengths from bottom-center going clockwise:
// Segment A: bottom-center → bottom-right corner = W/2 - m
// Segment B: bottom-right corner → top-right = H - 2m
// Segment C: top-right → top-left = W - 2m
// Segment D: top-left → bottom-left = H - 2m
// Segment E: bottom-left → bottom-center = W/2 - m
var segA = W / 2 - m; // → bottom-right
var segB = H - 2 * m; // ↑ right edge
var segC = W - 2 * m; // ← top edge
var segD = H - 2 * m; // ↓ left edge
var segE = W / 2 - m; // → back to bottom-center
dist = ((dist % perimeter) + perimeter) % perimeter; // normalize
if (dist <= segA) {
// Bottom edge: left of center → right (going right from bottom-center)
x = startX + dist;
y = H - m;
} else if (dist <= segA + segB) {
// Right edge: bottom → top
x = W - m;
y = (H - m) - (dist - segA);
} else if (dist <= segA + segB + segC) {
// Top edge: right → left
x = (W - m) - (dist - segA - segB);
y = m;
} else if (dist <= segA + segB + segC + segD) {
// Left edge: top → bottom
x = m;
y = m + (dist - segA - segB - segC);
} else {
// Bottom edge: left → bottom-center
x = m + (dist - segA - segB - segC - segD);
y = H - m;
}
return { x: x, y: y };
}
// --------------------------------------------------------------------------
// Build or rebuild the SVG
// --------------------------------------------------------------------------
function build() {
var container = document.getElementById('rosary-overlay');
if (!container) return;
// Remove existing SVG if any
container.innerHTML = '';
var W = window.innerWidth;
var H = window.innerHeight;
svg = document.createElementNS(NS, 'svg');
svg.setAttribute('viewBox', '0 0 ' + W + ' ' + H);
svg.setAttribute('xmlns', NS);
svg.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;pointer-events:none';
// Define glow filter
var defs = document.createElementNS(NS, 'defs');
var filter = document.createElementNS(NS, 'filter');
filter.setAttribute('id', 'glow');
filter.setAttribute('x', '-50%');
filter.setAttribute('y', '-50%');
filter.setAttribute('width', '200%');
filter.setAttribute('height', '200%');
var feGaussian = document.createElementNS(NS, 'feGaussianBlur');
feGaussian.setAttribute('stdDeviation', '3.5');
feGaussian.setAttribute('result', 'coloredBlur');
var feMerge = document.createElementNS(NS, 'feMerge');
var feMergeNode1 = document.createElementNS(NS, 'feMergeNode');
feMergeNode1.setAttribute('in', 'coloredBlur');
var feMergeNode2 = document.createElementNS(NS, 'feMergeNode');
feMergeNode2.setAttribute('in', 'SourceGraphic');
feMerge.appendChild(feMergeNode1);
feMerge.appendChild(feMergeNode2);
filter.appendChild(feGaussian);
filter.appendChild(feMerge);
defs.appendChild(filter);
svg.appendChild(defs);
beadPositions = calcPositions(W, H);
// Draw chain (polyline)
var pts = beadPositions.map(function (p) { return p.x + ',' + p.y; });
// Close the loop
pts.push(pts[0]);
polyline = document.createElementNS(NS, 'polyline');
polyline.setAttribute('points', pts.join(' '));
polyline.setAttribute('fill', 'none');
polyline.setAttribute('stroke', '#333');
polyline.setAttribute('stroke-width', '1.5');
polyline.setAttribute('opacity', '0.7');
svg.appendChild(polyline);
// Draw beads
beadEls = [];
for (var i = 0; i < 60; i++) {
var pos = beadPositions[i];
var isCrucifix = CRUCIFIX_BEADS.has(i);
var isLarge = LARGE_BEADS.has(i);
if (isCrucifix) {
// Draw a simple cross symbol
var g = document.createElementNS(NS, 'g');
g.setAttribute('transform', 'translate(' + pos.x + ',' + pos.y + ')');
// Beads are clickable — enable pointer events on this element
g.style.pointerEvents = 'all';
g.style.cursor = 'pointer';
var vert = document.createElementNS(NS, 'line');
vert.setAttribute('x1', '0'); vert.setAttribute('y1', '-14');
vert.setAttribute('x2', '0'); vert.setAttribute('y2', '14');
vert.setAttribute('stroke', COLOR_UNPRAYED);
vert.setAttribute('stroke-width', '3');
vert.setAttribute('stroke-linecap', 'round');
var horiz = document.createElementNS(NS, 'line');
horiz.setAttribute('x1', '-8'); horiz.setAttribute('y1', '-4');
horiz.setAttribute('x2', '8'); horiz.setAttribute('y2', '-4');
horiz.setAttribute('stroke', COLOR_UNPRAYED);
horiz.setAttribute('stroke-width', '3');
horiz.setAttribute('stroke-linecap', 'round');
// Invisible hit-area circle for color tracking and clicks
var hitCircle = document.createElementNS(NS, 'circle');
hitCircle.setAttribute('cx', '0'); hitCircle.setAttribute('cy', '0');
hitCircle.setAttribute('r', R_CRUCIFIX);
hitCircle.setAttribute('fill', 'transparent');
g.appendChild(vert);
g.appendChild(horiz);
g.appendChild(hitCircle);
g.dataset.beadIndex = i;
g._vertLine = vert;
g._horizLine = horiz;
// Click: emit bead index to registered handler
(function (beadIdx) {
g.addEventListener('click', function () {
if (_beadClickHandler) _beadClickHandler(beadIdx);
});
})(i);
svg.appendChild(g);
beadEls.push(g);
} else {
var r = isLarge ? R_LARGE : R_SMALL;
var circle = document.createElementNS(NS, 'circle');
circle.setAttribute('cx', pos.x);
circle.setAttribute('cy', pos.y);
circle.setAttribute('r', r);
circle.setAttribute('fill', COLOR_UNPRAYED);
circle.dataset.beadIndex = i;
// Beads are clickable — enable pointer events on this element
circle.style.pointerEvents = 'all';
circle.style.cursor = 'pointer';
// Click: emit bead index to registered handler
(function (beadIdx) {
circle.addEventListener('click', function () {
if (_beadClickHandler) _beadClickHandler(beadIdx);
});
})(i);
svg.appendChild(circle);
beadEls.push(circle);
}
}
container.appendChild(svg);
}
// --------------------------------------------------------------------------
// applyColors — internal: paint all beads for the given state.
// currentBeadIndex : bead being prayed NOW (gold glow), or null
// lastPrayedBeadIndex: highest bead index prayed so far (all <= this are red)
// --------------------------------------------------------------------------
function applyColors(currentBeadIndex, lastPrayedBeadIndex) {
if (!svg) return;
var last = (lastPrayedBeadIndex !== null && lastPrayedBeadIndex !== undefined)
? lastPrayedBeadIndex : -1;
// Once the final bead of decade 5 (index 59) has been prayed and there is
// no active bead, the cross turns gold and stays gold for the rest of the
// presentation (litanies, novena prayer, closing slide).
var allDecadesDone = (last >= 59) &&
(currentBeadIndex === null || currentBeadIndex === undefined);
beadEls.forEach(function (el, i) {
var isCrucifix = CRUCIFIX_BEADS.has(i);
var color;
var isActive = false;
if (currentBeadIndex !== null && currentBeadIndex !== undefined) {
// On a bead slide: gold = current, red = prayed, gray = future
if (i === currentBeadIndex) {
color = COLOR_CURRENT;
isActive = true;
} else if (i < currentBeadIndex) {
color = COLOR_PRAYED;
} else {
color = COLOR_UNPRAYED;
}
} else {
// Between-bead slide (Glory Be, Fatima, litany, etc.)
if (isCrucifix && allDecadesDone) {
// All 5 decades complete — crucifix glows gold
color = COLOR_CURRENT;
isActive = true;
} else {
color = (i <= last) ? COLOR_PRAYED : COLOR_UNPRAYED;
}
}
if (isCrucifix) {
el._vertLine.setAttribute('stroke', color);
el._horizLine.setAttribute('stroke', color);
if (isActive) { el.setAttribute('filter', 'url(#glow)'); }
else { el.removeAttribute('filter'); }
} else {
el.setAttribute('fill', color);
if (isActive) { el.setAttribute('filter', 'url(#glow)'); }
else { el.removeAttribute('filter'); }
}
});
}
// --------------------------------------------------------------------------
// update — public API. Saves state so a resize rebuild can restore it.
// --------------------------------------------------------------------------
var _lastCurrent = null;
var _lastPrayed = null;
function update(currentBeadIndex, lastPrayedBeadIndex) {
_lastCurrent = currentBeadIndex;
_lastPrayed = lastPrayedBeadIndex;
applyColors(currentBeadIndex, lastPrayedBeadIndex);
}
// --------------------------------------------------------------------------
// Initialize
// --------------------------------------------------------------------------
function init() {
build();
window.addEventListener('resize', function () {
build();
applyColors(_lastCurrent, _lastPrayed); // restore state after SVG rebuild
});
}
// Run on DOM ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
function onBeadClick(fn) {
_beadClickHandler = fn;
}
return { update: update, onBeadClick: onBeadClick };
})();
+186
View File
@@ -0,0 +1,186 @@
/**
* assets/js/setup.js
* Setup form interactivity: conditional field visibility + form submission.
*/
(function () {
'use strict';
const form = document.getElementById('session-form');
const occasionSel = document.getElementById('occasion');
const photoInput = document.getElementById('photo');
const uploadStatus = document.getElementById('upload-status');
const submitBtn = document.getElementById('submit-btn');
const msgBox = document.getElementById('form-message');
const nameHelp = document.getElementById('name-help');
// Are we editing an existing session?
const isEditMode = !!form.querySelector('[name="id"]');
// Fields to show/hide by occasion (field IDs without the "field-" prefix)
const OCCASION_FIELDS = {
novena_deceased: ['novena_day', 'novena_mystery_mode', 'subject_name', 'subject_pronoun', 'subject_dates'],
divine_mercy_novena:['novena_day'],
memorial: ['subject_name', 'subject_pronoun', 'subject_dates'],
general_rosary: [],
};
const mysteryStandardWrap = document.getElementById('field-mystery_set_standard');
function toggleFields() {
const occasion = occasionSel.value;
const show = OCCASION_FIELDS[occasion] || [];
const isNovenaDeceased = (occasion === 'novena_deceased');
const isDivineMercy = (occasion === 'divine_mercy_novena');
const isAnyNovena = isNovenaDeceased || isDivineMercy;
// Standard mystery dropdown: hide for both novena types
if (mysteryStandardWrap) {
mysteryStandardWrap.style.display = isAnyNovena ? 'none' : '';
const sel = document.getElementById('mystery_set');
if (sel) {
if (isAnyNovena) {
sel.removeAttribute('required');
if (!sel.value) sel.value = 'sorrowful';
} else {
sel.setAttribute('required', '');
}
}
}
// Show/hide conditional fields
document.querySelectorAll('.conditional-field').forEach(el => {
const fieldName = el.id.replace('field-', '');
// novena_mystery_mode only shows for novena_deceased, not divine_mercy_novena
if (fieldName === 'novena_mystery_mode' && !isNovenaDeceased) {
el.style.display = 'none';
el.querySelectorAll('input, select').forEach(input => input.removeAttribute('required'));
return;
}
const visible = show.includes(fieldName);
el.style.display = visible ? '' : 'none';
el.querySelectorAll('input, select').forEach(input => {
if (!visible) input.removeAttribute('required');
});
});
// Update submit button label
if (submitBtn) {
if (isNovenaDeceased) {
submitBtn.textContent = submitBtn.dataset.labelNovenaDeceased;
} else if (isDivineMercy) {
submitBtn.textContent = submitBtn.dataset.labelDivineMercy;
} else {
submitBtn.textContent = submitBtn.dataset.labelDefault;
}
}
// Update session name help text
if (nameHelp) {
nameHelp.style.display = isAnyNovena && !isEditMode ? '' : 'none';
}
}
occasionSel.addEventListener('change', toggleFields);
toggleFields(); // run on page load (handles edit mode pre-selection)
// ------------------------------------------------------------------
// Photo upload on file selection
// ------------------------------------------------------------------
var photoUploading = false;
if (photoInput) {
photoInput.addEventListener('change', async function () {
if (!this.files || !this.files[0]) return;
const file = this.files[0];
uploadStatus.style.display = '';
uploadStatus.className = 'upload-status uploading';
uploadStatus.textContent = 'Uploading\u2026';
// Disable submit while upload is in progress
photoUploading = true;
if (submitBtn) submitBtn.disabled = true;
const fd = new FormData();
fd.append('photo', file);
try {
const res = await fetch(BASE_URL + '/api/upload_photo.php', { method: 'POST', body: fd });
const data = await res.json();
if (data.error) {
uploadStatus.className = 'upload-status error';
uploadStatus.textContent = 'Upload failed: ' + data.error;
} else {
document.getElementById('photo_path').value = data.path;
// Clear the file input so the file is not re-sent with the main form
photoInput.value = '';
uploadStatus.className = 'upload-status success';
uploadStatus.textContent = 'Photo uploaded successfully.';
}
} catch (err) {
uploadStatus.className = 'upload-status error';
uploadStatus.textContent = 'Network error during upload.';
} finally {
photoUploading = false;
if (submitBtn) submitBtn.disabled = false;
}
});
}
// ------------------------------------------------------------------
// Form submission via fetch
// ------------------------------------------------------------------
form.addEventListener('submit', async function (e) {
e.preventDefault();
if (!form.checkValidity()) {
form.reportValidity();
return;
}
submitBtn.disabled = true;
submitBtn.textContent = 'Saving\u2026';
showMessage('', '');
const fd = new FormData(form);
try {
const res = await fetch(BASE_URL + '/api/save_session.php', { method: 'POST', body: fd });
const data = await res.json();
if (data.error) {
showMessage('error', data.error);
submitBtn.disabled = false;
const occ = occasionSel.value;
submitBtn.textContent = isEditMode
? submitBtn.dataset.labelDefault
: (occ === 'novena_deceased' ? submitBtn.dataset.labelNovenaDeceased
: occ === 'divine_mercy_novena' ? submitBtn.dataset.labelDivineMercy
: submitBtn.dataset.labelDefault);
} else if (data.novena) {
// All 9 days created — go to admin dashboard
window.location.href = BASE_URL + '/admin/?novena_created=' + data.ids.length;
} else {
// Single session — go to presentation
window.location.href = BASE_URL + '/present.php?id=' + data.id;
}
} catch (err) {
showMessage('error', 'Network error. Please try again.');
submitBtn.disabled = false;
submitBtn.textContent = submitBtn.dataset.labelDefault;
}
});
function showMessage(type, text) {
if (!text) {
msgBox.style.display = 'none';
return;
}
msgBox.style.display = '';
msgBox.className = 'alert alert-' + type;
msgBox.textContent = text;
}
})();