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:
@@ -0,0 +1,532 @@
|
||||
/* =========================================================
|
||||
present.css — Full-screen presentation styles
|
||||
========================================================= */
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
--bg: #0a0a1a;
|
||||
--text: #f0f0f0;
|
||||
--gold: #FFD700;
|
||||
--blue: #87CEEB;
|
||||
--mystery: #c8a84b;
|
||||
--dim: rgba(255,255,255,0.35);
|
||||
--nav-bg: rgba(0,0,0,0.55);
|
||||
--font-prayer: Georgia, 'Times New Roman', serif;
|
||||
--font-ui: system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: var(--font-prayer);
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
Rosary overlay
|
||||
---------------------------------------------------------------- */
|
||||
#rosary-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
}
|
||||
#rosary-overlay svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
Presenter wrapper
|
||||
---------------------------------------------------------------- */
|
||||
#presenter {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px 72px 80px; /* space for bead ring */
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
Slide content area
|
||||
---------------------------------------------------------------- */
|
||||
#slide-content {
|
||||
width: 100%;
|
||||
max-width: 1100px;
|
||||
flex: 1;
|
||||
min-height: 0; /* CRITICAL: without this, flex:1 never shrinks below
|
||||
content height, so overflow-y:auto never activates */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
/* Thin, dark scrollbar — unobtrusive on a presentation screen */
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(255,255,255,0.15) transparent;
|
||||
-webkit-overflow-scrolling: touch; /* iOS momentum scroll */
|
||||
}
|
||||
#slide-content::-webkit-scrollbar { width: 4px; }
|
||||
#slide-content::-webkit-scrollbar-track { background: transparent; }
|
||||
#slide-content::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.15); border-radius: 2px; }
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
Cover slide
|
||||
---------------------------------------------------------------- */
|
||||
.cover-slide {
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
#cover-photo-wrap {
|
||||
max-height: 38vh;
|
||||
overflow: hidden;
|
||||
border-radius: 6px;
|
||||
position: relative;
|
||||
}
|
||||
#cover-photo {
|
||||
max-height: 38vh;
|
||||
max-width: 60vw;
|
||||
border-radius: 6px;
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
/* Vignette effect */
|
||||
mask-image: radial-gradient(ellipse 90% 90% at center, black 60%, transparent 100%);
|
||||
-webkit-mask-image: radial-gradient(ellipse 90% 90% at center, black 60%, transparent 100%);
|
||||
}
|
||||
#cover-photo-wrap.no-photo {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#cover-title {
|
||||
font-size: 2.6rem;
|
||||
font-weight: normal;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--gold);
|
||||
text-shadow: 0 0 20px rgba(255,215,0,0.4);
|
||||
}
|
||||
|
||||
#cover-body {
|
||||
font-size: 1.5rem;
|
||||
line-height: 1.7;
|
||||
white-space: pre-line;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
Prayer slide
|
||||
---------------------------------------------------------------- */
|
||||
#prayer-slide {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
#slide-title {
|
||||
font-size: 1.1rem;
|
||||
font-family: var(--font-ui);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.15em;
|
||||
color: var(--dim);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
#slide-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 22px;
|
||||
}
|
||||
|
||||
#leader-wrap, #all-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-family: var(--font-ui);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
.leader-label { color: var(--gold); }
|
||||
.all-label { color: var(--blue); }
|
||||
|
||||
#leader-text, #all-text,
|
||||
#litany-leader, #litany-all {
|
||||
font-size: clamp(1.6rem, 3.2vw, 2.4rem);
|
||||
line-height: 1.65;
|
||||
white-space: pre-line;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* When only one section is present, increase size */
|
||||
.single-section #leader-text,
|
||||
.single-section #all-text,
|
||||
.single-section #litany-leader,
|
||||
.single-section #litany-all {
|
||||
font-size: clamp(1.8rem, 3.5vw, 2.6rem);
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
Mystery slide
|
||||
---------------------------------------------------------------- */
|
||||
#mystery-slide {
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.mystery-number {
|
||||
font-family: var(--font-ui);
|
||||
font-size: 0.9rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.2em;
|
||||
color: var(--mystery);
|
||||
}
|
||||
|
||||
#mystery-slide h2 {
|
||||
font-size: 2.4rem;
|
||||
font-weight: normal;
|
||||
color: var(--gold);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
#mystery-fruit {
|
||||
font-size: 1.5rem;
|
||||
color: var(--blue);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
Litany slide
|
||||
---------------------------------------------------------------- */
|
||||
#litany-slide {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
#litany-title {
|
||||
font-size: 1.1rem;
|
||||
font-family: var(--font-ui);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.15em;
|
||||
color: var(--dim);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
#litany-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 22px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#litany-leader-wrap, #litany-all-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
Closing slide
|
||||
---------------------------------------------------------------- */
|
||||
#closing-slide {
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
#closing-photo-wrap {
|
||||
max-height: 34vh;
|
||||
overflow: hidden;
|
||||
border-radius: 6px;
|
||||
}
|
||||
#closing-photo {
|
||||
max-height: 34vh;
|
||||
max-width: 55vw;
|
||||
border-radius: 6px;
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
mask-image: radial-gradient(ellipse 90% 90% at center, black 60%, transparent 100%);
|
||||
-webkit-mask-image: radial-gradient(ellipse 90% 90% at center, black 60%, transparent 100%);
|
||||
}
|
||||
|
||||
.closing-cross {
|
||||
font-size: 2.8rem;
|
||||
color: var(--gold);
|
||||
text-shadow: 0 0 30px rgba(255,215,0,0.5);
|
||||
}
|
||||
|
||||
#closing-slide h2 {
|
||||
font-size: 1.9rem;
|
||||
font-weight: normal;
|
||||
color: var(--gold);
|
||||
}
|
||||
|
||||
#closing-subtitle {
|
||||
font-size: 1.1rem;
|
||||
color: var(--dim);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
#closing-body {
|
||||
font-size: clamp(1.5rem, 3vw, 2.2rem);
|
||||
line-height: 1.75;
|
||||
white-space: pre-line;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
Navigation bar
|
||||
---------------------------------------------------------------- */
|
||||
#presenter-nav {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
background: var(--nav-bg);
|
||||
backdrop-filter: blur(8px);
|
||||
border-radius: 40px;
|
||||
padding: 10px 24px;
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
/* Thin vertical divider inside the nav pill */
|
||||
.nav-sep {
|
||||
width: 1px;
|
||||
height: 18px;
|
||||
background: rgba(255,255,255,0.18);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Exit button — smaller and muted vs the main nav buttons */
|
||||
.nav-exit {
|
||||
font-size: 1.1rem;
|
||||
color: rgba(255,255,255,0.4);
|
||||
padding: 6px 10px;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
.nav-exit:hover:not(:disabled) {
|
||||
background: rgba(255,255,255,0.08);
|
||||
color: rgba(255,255,255,0.85);
|
||||
}
|
||||
|
||||
/* Audio toggle button */
|
||||
.nav-audio {
|
||||
font-size: 1.1rem;
|
||||
color: rgba(255,255,255,0.4);
|
||||
padding: 6px 10px;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
.nav-audio:hover:not(:disabled) {
|
||||
background: rgba(255,255,255,0.08);
|
||||
color: rgba(255,255,255,0.85);
|
||||
}
|
||||
.nav-audio.audio-on {
|
||||
color: #c9973d;
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text);
|
||||
font-size: 1.4rem;
|
||||
cursor: pointer;
|
||||
padding: 6px 14px;
|
||||
border-radius: 6px;
|
||||
transition: background 0.15s;
|
||||
line-height: 1;
|
||||
}
|
||||
.nav-btn:hover:not(:disabled) {
|
||||
background: rgba(255,255,255,0.12);
|
||||
}
|
||||
.nav-btn:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
#slide-counter {
|
||||
font-family: var(--font-ui);
|
||||
font-size: 0.9rem;
|
||||
color: var(--dim);
|
||||
min-width: 100px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
Resume toast — auto-fades after resume
|
||||
---------------------------------------------------------------- */
|
||||
#resume-toast {
|
||||
position: fixed;
|
||||
top: 14px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(255,215,0,0.12);
|
||||
border: 1px solid rgba(255,215,0,0.3);
|
||||
color: var(--gold);
|
||||
font-family: var(--font-ui);
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.06em;
|
||||
padding: 5px 18px;
|
||||
border-radius: 20px;
|
||||
z-index: 30;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.5s ease;
|
||||
}
|
||||
#resume-toast.visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
Keyboard hint
|
||||
---------------------------------------------------------------- */
|
||||
#key-hint {
|
||||
position: fixed;
|
||||
top: 12px;
|
||||
right: 16px;
|
||||
font-family: var(--font-ui);
|
||||
font-size: 0.7rem;
|
||||
color: rgba(255,255,255,0.2);
|
||||
z-index: 20;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
Slide transitions
|
||||
---------------------------------------------------------------- */
|
||||
#slide-content > div {
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(6px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
Fullscreen state — hide nav hint
|
||||
---------------------------------------------------------------- */
|
||||
:fullscreen #key-hint,
|
||||
:-webkit-full-screen #key-hint {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
Repeat-run badge — shows "2 of 3" when same content repeats.
|
||||
Lives inside the slide panel, above the title (inline, not fixed).
|
||||
---------------------------------------------------------------- */
|
||||
.repeat-badge {
|
||||
display: block;
|
||||
align-self: center;
|
||||
background: rgba(255, 215, 0, 0.12);
|
||||
border: 1px solid rgba(255, 215, 0, 0.35);
|
||||
color: var(--gold);
|
||||
font-family: var(--font-ui);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
padding: 4px 14px;
|
||||
border-radius: 20px;
|
||||
pointer-events: none;
|
||||
animation: badgePulse 0.4s ease;
|
||||
}
|
||||
|
||||
@keyframes badgePulse {
|
||||
from { opacity: 0; transform: scale(0.85); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
Mobile — tablets and phones (≤ 768 px)
|
||||
---------------------------------------------------------------- */
|
||||
@media (max-width: 768px) {
|
||||
/* Tighter inset: beads sit 28 px from edge (center) + 10 px radius = 38 px.
|
||||
48 px padding gives 10 px clearance on all sides. */
|
||||
#presenter {
|
||||
padding: 44px 48px 68px;
|
||||
}
|
||||
|
||||
/* Shrink fixed-size text that doesn't use clamp() */
|
||||
#cover-title { font-size: 1.9rem; }
|
||||
#cover-body { font-size: 1.25rem; }
|
||||
#mystery-slide h2 { font-size: 1.8rem; }
|
||||
#mystery-fruit { font-size: 1.15rem; }
|
||||
#closing-slide h2 { font-size: 1.5rem; }
|
||||
#closing-body { font-size: 1.1rem; }
|
||||
|
||||
/* Compact nav pill */
|
||||
#presenter-nav {
|
||||
padding: 8px 14px;
|
||||
gap: 8px;
|
||||
bottom: 14px;
|
||||
}
|
||||
.nav-exit { font-size: 1rem; padding: 6px 8px; }
|
||||
|
||||
/* WCAG 2.5.5 minimum 44 × 44 px touch target */
|
||||
.nav-btn {
|
||||
font-size: 1.4rem;
|
||||
padding: 6px 14px;
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
#slide-counter {
|
||||
min-width: 76px;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
/* Keyboard hint is irrelevant on touch devices */
|
||||
#key-hint { display: none; }
|
||||
|
||||
/* Cover photo — a bit smaller on portrait phones */
|
||||
#cover-photo-wrap,
|
||||
#cover-photo { max-height: 32vh; }
|
||||
|
||||
#closing-photo-wrap,
|
||||
#closing-photo { max-height: 28vh; }
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
Mobile — phones (≤ 480 px)
|
||||
---------------------------------------------------------------- */
|
||||
@media (max-width: 480px) {
|
||||
#presenter {
|
||||
padding: 44px 40px 66px;
|
||||
}
|
||||
|
||||
#cover-title { font-size: 1.4rem; }
|
||||
#mystery-slide h2 { font-size: 1.4rem; }
|
||||
#mystery-fruit { font-size: 1.05rem; }
|
||||
|
||||
/* Slightly smaller slide title label */
|
||||
#slide-title,
|
||||
#litany-title { font-size: 0.9rem; }
|
||||
}
|
||||
@@ -0,0 +1,617 @@
|
||||
/* public.css — Clean public-facing styles */
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
|
||||
:root {
|
||||
--navy: #1e3a5f;
|
||||
--gold: #c9973d;
|
||||
--text: #111827;
|
||||
--muted: #6b7280;
|
||||
--bg: #f9fafb;
|
||||
--white: #ffffff;
|
||||
--border:#e5e7eb;
|
||||
--radius:8px;
|
||||
--shadow:0 1px 4px rgba(0,0,0,.08);
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* ── Nav ── */
|
||||
.pub-nav {
|
||||
background: var(--navy);
|
||||
color: #fff;
|
||||
padding: 0 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 56px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,.15);
|
||||
}
|
||||
|
||||
.pub-nav-brand {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
letter-spacing: .01em;
|
||||
}
|
||||
|
||||
.pub-nav-brand span { color: var(--gold); }
|
||||
|
||||
.pub-nav-links {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.pub-nav-links a {
|
||||
color: rgba(255,255,255,.85);
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
padding: 6px 14px;
|
||||
border-radius: 6px;
|
||||
transition: background .15s;
|
||||
}
|
||||
|
||||
.pub-nav-links a:hover { background: rgba(255,255,255,.12); }
|
||||
|
||||
.pub-nav-links a.btn-nav {
|
||||
background: var(--gold);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.pub-nav-links a.btn-nav:hover { background: #b8872e; }
|
||||
|
||||
/* ── Hero ── */
|
||||
.pub-hero {
|
||||
background: linear-gradient(135deg, var(--navy) 0%, #2d5a8e 100%);
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
padding: 64px 24px 56px;
|
||||
}
|
||||
|
||||
.pub-hero h1 {
|
||||
font-size: clamp(28px, 5vw, 48px);
|
||||
font-weight: 800;
|
||||
margin: 0 0 12px;
|
||||
letter-spacing: -.01em;
|
||||
}
|
||||
|
||||
.pub-hero p {
|
||||
font-size: 18px;
|
||||
color: rgba(255,255,255,.8);
|
||||
margin: 0;
|
||||
max-width: 480px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* ── Section ── */
|
||||
.pub-section {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 48px 24px;
|
||||
}
|
||||
|
||||
.pub-section h2 {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: var(--navy);
|
||||
margin: 0 0 24px;
|
||||
border-bottom: 2px solid var(--gold);
|
||||
padding-bottom: 10px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* ── Card grid ── */
|
||||
.card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.rosary-card {
|
||||
background: var(--white);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
border: 1px solid var(--border);
|
||||
overflow: hidden;
|
||||
color: var(--text);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.rosary-card-photo {
|
||||
width: 100%;
|
||||
height: 160px;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
background: #e5e7eb;
|
||||
}
|
||||
|
||||
.rosary-card-photo-placeholder {
|
||||
width: 100%;
|
||||
height: 160px;
|
||||
background: linear-gradient(135deg, var(--navy) 0%, #2d5a8e 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 48px;
|
||||
color: rgba(255,255,255,.5);
|
||||
}
|
||||
|
||||
.rosary-card-body {
|
||||
padding: 16px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.rosary-card-title {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
margin: 0 0 4px;
|
||||
color: var(--navy);
|
||||
}
|
||||
|
||||
.rosary-card-meta {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.rosary-card-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-top: 1px solid var(--border);
|
||||
padding-top: 10px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.rosary-card-by {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.rosary-card-link {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--navy);
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rosary-card-link:hover {
|
||||
color: var(--gold);
|
||||
}
|
||||
|
||||
.badge-novena {
|
||||
display: inline-block;
|
||||
background: #ede9fe;
|
||||
color: #5b21b6;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
padding: 2px 8px;
|
||||
border-radius: 99px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .04em;
|
||||
}
|
||||
|
||||
/* ── Profile header ── */
|
||||
.profile-header {
|
||||
background: var(--white);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 32px 24px;
|
||||
}
|
||||
|
||||
.profile-header-inner {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.profile-avatar {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 50%;
|
||||
background: var(--navy);
|
||||
color: #fff;
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.profile-info h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 800;
|
||||
margin: 0 0 2px;
|
||||
color: var(--navy);
|
||||
}
|
||||
|
||||
.profile-info p {
|
||||
font-size: 14px;
|
||||
color: var(--muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── Empty state ── */
|
||||
.pub-empty {
|
||||
text-align: center;
|
||||
padding: 56px 24px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.pub-empty .cross {
|
||||
font-size: 48px;
|
||||
display: block;
|
||||
margin-bottom: 12px;
|
||||
opacity: .3;
|
||||
}
|
||||
|
||||
.pub-empty p {
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── Donate strip ── */
|
||||
.donate-strip {
|
||||
text-align: center;
|
||||
padding: 28px 24px 32px;
|
||||
background: var(--white);
|
||||
border-top: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.donate-strip-text {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
letter-spacing: .03em;
|
||||
color: var(--muted);
|
||||
text-transform: uppercase;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.donate-strip-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
letter-spacing: .01em;
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
background: linear-gradient(135deg, var(--navy) 0%, #2d5a8e 100%);
|
||||
padding: 11px 28px;
|
||||
border-radius: 99px;
|
||||
box-shadow: 0 2px 10px rgba(30,58,95,.25);
|
||||
transition: transform .15s, box-shadow .15s, background .15s;
|
||||
}
|
||||
|
||||
.donate-strip-link:hover {
|
||||
background: linear-gradient(135deg, #c9973d 0%, #b8872e 100%);
|
||||
box-shadow: 0 4px 16px rgba(201,151,61,.35);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* ── Footer ── */
|
||||
.pub-footer {
|
||||
text-align: center;
|
||||
padding: 24px;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
border-top: 1px solid var(--border);
|
||||
margin-top: 48px;
|
||||
}
|
||||
|
||||
/* ── Novena hero (day-picker page) ── */
|
||||
.novena-hero {
|
||||
background: linear-gradient(135deg, var(--navy) 0%, #2d5a8e 100%);
|
||||
color: #fff;
|
||||
padding: 48px 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 40px;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.novena-hero-photo-wrap {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.novena-hero-photo {
|
||||
width: 160px;
|
||||
height: 160px;
|
||||
object-fit: cover;
|
||||
border-radius: 50%;
|
||||
border: 3px solid rgba(255,255,255,.25);
|
||||
display: block;
|
||||
mask-image: radial-gradient(ellipse 90% 90% at center, black 60%, transparent 100%);
|
||||
-webkit-mask-image: radial-gradient(ellipse 90% 90% at center, black 60%, transparent 100%);
|
||||
}
|
||||
|
||||
.novena-hero-cross {
|
||||
font-size: 72px;
|
||||
color: rgba(255,255,255,.3);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.novena-hero-text {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.novena-hero-label {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .12em;
|
||||
color: var(--gold);
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.novena-hero-title {
|
||||
font-size: clamp(24px, 4vw, 40px);
|
||||
font-weight: 800;
|
||||
margin: 0 0 8px;
|
||||
letter-spacing: -.01em;
|
||||
}
|
||||
|
||||
.novena-hero-subject {
|
||||
font-size: 18px;
|
||||
margin: 0 0 4px;
|
||||
color: rgba(255,255,255,.9);
|
||||
}
|
||||
|
||||
.novena-hero-dates {
|
||||
font-size: 14px;
|
||||
color: rgba(255,255,255,.6);
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.novena-hero-by {
|
||||
font-size: 13px;
|
||||
color: rgba(255,255,255,.5);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── Novena day grid ── */
|
||||
.novena-days-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.novena-day-card {
|
||||
background: var(--white);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 20px 18px;
|
||||
box-shadow: var(--shadow);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.novena-day-missing {
|
||||
background: #f9fafb;
|
||||
opacity: .55;
|
||||
}
|
||||
|
||||
.novena-day-number {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .08em;
|
||||
color: var(--navy);
|
||||
}
|
||||
|
||||
.novena-day-mysteries {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.novena-day-link {
|
||||
display: inline-block;
|
||||
margin-top: 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--navy);
|
||||
text-decoration: none;
|
||||
border-top: 1px solid var(--border);
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.novena-day-link:hover {
|
||||
color: var(--gold);
|
||||
}
|
||||
|
||||
/* ── Search bar ── */
|
||||
.search-wrap {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 28px 24px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.home-search-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
background: var(--white);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 40px;
|
||||
padding: 0 18px;
|
||||
box-shadow: var(--shadow);
|
||||
transition: border-color .15s, box-shadow .15s;
|
||||
}
|
||||
|
||||
.home-search-row:focus-within {
|
||||
border-color: var(--navy);
|
||||
box-shadow: 0 0 0 3px rgba(30,58,95,.1);
|
||||
}
|
||||
|
||||
.home-search-icon {
|
||||
color: var(--muted);
|
||||
font-size: 15px;
|
||||
flex-shrink: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.home-search-input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
font-size: 15px;
|
||||
font-family: inherit;
|
||||
color: var(--text);
|
||||
padding: 13px 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.home-search-input::placeholder { color: var(--muted); }
|
||||
|
||||
.home-search-clear {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--muted);
|
||||
font-size: 16px;
|
||||
padding: 4px 6px;
|
||||
border-radius: 4px;
|
||||
display: none;
|
||||
line-height: 1;
|
||||
transition: color .15s;
|
||||
}
|
||||
.home-search-clear:hover { color: var(--text); }
|
||||
.home-search-clear.visible { display: block; }
|
||||
|
||||
/* User profile links that appear when search matches a user */
|
||||
.search-user-results {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.search-user-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: var(--white);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 99px;
|
||||
padding: 5px 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--navy);
|
||||
text-decoration: none;
|
||||
box-shadow: var(--shadow);
|
||||
transition: background .15s, border-color .15s;
|
||||
}
|
||||
.search-user-pill:hover {
|
||||
background: var(--navy);
|
||||
color: #fff;
|
||||
border-color: var(--navy);
|
||||
}
|
||||
|
||||
/* No-results message */
|
||||
.search-no-results {
|
||||
display: none;
|
||||
text-align: center;
|
||||
padding: 40px 24px;
|
||||
color: var(--muted);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
/* ── Pinned / Featured section ── */
|
||||
.pinned-section {
|
||||
background: linear-gradient(180deg, #fefdf6 0%, var(--bg) 100%);
|
||||
border-bottom: 1px solid #e8dfc0;
|
||||
}
|
||||
|
||||
.pinned-section .pub-section {
|
||||
padding-bottom: 32px;
|
||||
}
|
||||
|
||||
.pinned-section .pub-section h2 {
|
||||
color: #8a6820;
|
||||
border-bottom-color: var(--gold);
|
||||
}
|
||||
|
||||
/* Gold top-border on pinned cards */
|
||||
.rosary-card.is-pinned {
|
||||
border-top: 3px solid var(--gold);
|
||||
}
|
||||
|
||||
/* Pin/unpin toggle button — floats top-right of card, admin-only */
|
||||
.rosary-card {
|
||||
position: relative; /* anchor for .pin-btn */
|
||||
}
|
||||
|
||||
.pin-btn {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
background: rgba(255,255,255,.88);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 3px 7px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
color: var(--muted);
|
||||
transition: background .15s, color .15s, border-color .15s;
|
||||
z-index: 2;
|
||||
}
|
||||
.pin-btn:hover {
|
||||
background: var(--gold);
|
||||
border-color: var(--gold);
|
||||
color: #fff;
|
||||
}
|
||||
.pin-btn.is-pinned {
|
||||
color: #8a6820;
|
||||
border-color: var(--gold);
|
||||
background: #fff9e6;
|
||||
}
|
||||
|
||||
/* Badge for Divine Mercy Novena */
|
||||
.badge-divine-mercy {
|
||||
display: inline-block;
|
||||
background: #fce7e7;
|
||||
color: #9b1c1c;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
padding: 2px 8px;
|
||||
border-radius: 99px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .04em;
|
||||
}
|
||||
|
||||
/* ── Responsive ── */
|
||||
@media (max-width: 600px) {
|
||||
.pub-nav { padding: 0 16px; }
|
||||
.pub-hero { padding: 40px 16px; }
|
||||
.pub-section { padding: 32px 16px; }
|
||||
.card-grid { grid-template-columns: 1fr; }
|
||||
.search-wrap { padding: 20px 16px 0; }
|
||||
}
|
||||
@@ -0,0 +1,576 @@
|
||||
/* =========================================================
|
||||
setup.css — Admin / setup page styles
|
||||
========================================================= */
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
--bg: #f8f9fb;
|
||||
--bg-card: #ffffff;
|
||||
--border: #d0d5dd;
|
||||
--text: #1d2027;
|
||||
--muted: #6b7280;
|
||||
--primary: #2563eb;
|
||||
--primary-h: #1d4ed8;
|
||||
--danger: #dc2626;
|
||||
--danger-h: #b91c1c;
|
||||
--gold: #b8860b;
|
||||
--success-bg: #f0fdf4;
|
||||
--success-border: #86efac;
|
||||
--error-bg: #fef2f2;
|
||||
--error-border: #fca5a5;
|
||||
--radius: 8px;
|
||||
--font: system-ui, -apple-system, 'Segoe UI', sans-serif;
|
||||
}
|
||||
|
||||
html, body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: var(--font);
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
Login page
|
||||
---------------------------------------------------------------- */
|
||||
.login-page {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.login-box {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 40px 48px;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,0.06);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login-box h1 {
|
||||
font-size: 1.6rem;
|
||||
color: var(--gold);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.login-box h2 {
|
||||
font-size: 1rem;
|
||||
font-weight: 400;
|
||||
color: var(--muted);
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
Admin container & header
|
||||
---------------------------------------------------------------- */
|
||||
.admin-container {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 0 24px 60px;
|
||||
}
|
||||
|
||||
.admin-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 20px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.admin-header h1 {
|
||||
font-size: 1.4rem;
|
||||
color: var(--gold);
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
main h2 {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
Buttons
|
||||
---------------------------------------------------------------- */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 8px 18px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
border: 1px solid transparent;
|
||||
transition: background 0.15s, border-color 0.15s, opacity 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn:disabled { opacity: 0.55; cursor: not-allowed; }
|
||||
|
||||
.btn-primary {
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
}
|
||||
.btn-primary:hover:not(:disabled) { background: var(--primary-h); }
|
||||
|
||||
.btn-secondary {
|
||||
background: #fff;
|
||||
color: var(--text);
|
||||
border-color: var(--border);
|
||||
}
|
||||
.btn-secondary:hover:not(:disabled) { background: #f3f4f6; }
|
||||
|
||||
.btn-danger {
|
||||
background: var(--danger);
|
||||
color: #fff;
|
||||
}
|
||||
.btn-danger:hover:not(:disabled) { background: var(--danger-h); }
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
border-color: transparent;
|
||||
}
|
||||
.btn-ghost:hover:not(:disabled) { background: #f3f4f6; color: var(--text); }
|
||||
|
||||
.btn-full { width: 100%; }
|
||||
.btn-large { padding: 12px 28px; font-size: 1rem; }
|
||||
|
||||
.btn-sm {
|
||||
padding: 5px 12px;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
Forms
|
||||
---------------------------------------------------------------- */
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 6px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="password"],
|
||||
input[type="file"],
|
||||
select,
|
||||
textarea {
|
||||
width: 100%;
|
||||
padding: 9px 13px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-size: 0.95rem;
|
||||
font-family: var(--font);
|
||||
background: #fff;
|
||||
color: var(--text);
|
||||
outline: none;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
max-width: 520px;
|
||||
}
|
||||
input[type="file"] {
|
||||
padding: 6px 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input:focus, select:focus {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px rgba(37,99,235,0.12);
|
||||
}
|
||||
|
||||
.required {
|
||||
color: var(--danger);
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.help-text {
|
||||
margin-top: 5px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
margin-top: 32px;
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
Alerts
|
||||
---------------------------------------------------------------- */
|
||||
.alert {
|
||||
padding: 12px 16px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 20px;
|
||||
border-width: 1px;
|
||||
border-style: solid;
|
||||
}
|
||||
.alert-error {
|
||||
background: var(--error-bg);
|
||||
border-color: var(--error-border);
|
||||
color: #991b1b;
|
||||
}
|
||||
.alert-success {
|
||||
background: var(--success-bg);
|
||||
border-color: var(--success-border);
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
Novena auto-create notice
|
||||
---------------------------------------------------------------- */
|
||||
.novena-auto-notice {
|
||||
padding: 12px 16px;
|
||||
background: #f0f9ff;
|
||||
border: 1px solid #bae6fd;
|
||||
color: #0c4a6e;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Read-only field value display */
|
||||
.form-static {
|
||||
padding: 9px 13px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: #f8f9fb;
|
||||
font-size: 0.95rem;
|
||||
color: var(--muted);
|
||||
max-width: 520px;
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
Upload status
|
||||
---------------------------------------------------------------- */
|
||||
.upload-status {
|
||||
margin-top: 8px;
|
||||
font-size: 0.85rem;
|
||||
padding: 6px 10px;
|
||||
border-radius: 4px;
|
||||
display: inline-block;
|
||||
}
|
||||
.upload-status.uploading { background: #eff6ff; color: #1d4ed8; }
|
||||
.upload-status.success { background: var(--success-bg); color: #166534; }
|
||||
.upload-status.error { background: var(--error-bg); color: #991b1b; }
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
Photo preview in edit mode
|
||||
---------------------------------------------------------------- */
|
||||
.photo-preview {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.photo-preview img {
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
Sessions table
|
||||
---------------------------------------------------------------- */
|
||||
.sessions-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.sessions-table th {
|
||||
text-align: left;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 2px solid var(--border);
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sessions-table td {
|
||||
padding: 13px 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.sessions-table tbody tr:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.session-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
.subject-name {
|
||||
display: block;
|
||||
font-weight: 400;
|
||||
font-size: 0.82rem;
|
||||
color: var(--muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
Novena group rows — index.php
|
||||
---------------------------------------------------------------- */
|
||||
.novena-group-row {
|
||||
background: rgba(37, 99, 235, 0.03);
|
||||
}
|
||||
.novena-group-row td:first-child {
|
||||
font-weight: 600;
|
||||
}
|
||||
.novena-group-icon {
|
||||
color: var(--gold);
|
||||
margin-right: 4px;
|
||||
}
|
||||
.novena-badge {
|
||||
display: inline-block;
|
||||
margin-left: 8px;
|
||||
padding: 1px 8px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
border-radius: 20px;
|
||||
background: rgba(37, 99, 235, 0.1);
|
||||
color: var(--primary);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
Cards — novena_group.php
|
||||
---------------------------------------------------------------- */
|
||||
.card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 28px 32px;
|
||||
}
|
||||
.card-title {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
Breadcrumb — novena_group.php
|
||||
---------------------------------------------------------------- */
|
||||
.breadcrumb {
|
||||
font-size: 0.88rem;
|
||||
color: var(--muted);
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.breadcrumb a {
|
||||
color: var(--primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
.breadcrumb a:hover { text-decoration: underline; }
|
||||
.bc-sep {
|
||||
margin: 0 8px;
|
||||
color: var(--border);
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
Missing day row
|
||||
---------------------------------------------------------------- */
|
||||
.day-missing td {
|
||||
color: var(--muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
Alert banners
|
||||
---------------------------------------------------------------- */
|
||||
.alert {
|
||||
border-radius: var(--radius);
|
||||
padding: 14px 18px;
|
||||
margin-bottom: 24px;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
.alert-success {
|
||||
background: var(--success-bg);
|
||||
border: 1px solid var(--success-border);
|
||||
color: #166534;
|
||||
}
|
||||
.alert-error {
|
||||
background: var(--error-bg);
|
||||
border: 1px solid var(--error-border);
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
Form grid (two columns on wider screens)
|
||||
---------------------------------------------------------------- */
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0 32px;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.form-grid { grid-template-columns: 1fr; }
|
||||
.card { padding: 20px 16px; }
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
Photo preview
|
||||
---------------------------------------------------------------- */
|
||||
.photo-preview-wrap {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.photo-preview {
|
||||
max-height: 120px;
|
||||
max-width: 200px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
Form label helpers
|
||||
---------------------------------------------------------------- */
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 6px;
|
||||
color: var(--text);
|
||||
}
|
||||
.form-help {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
margin-top: 4px;
|
||||
}
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: 9px 13px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-size: 0.95rem;
|
||||
font-family: var(--font);
|
||||
background: var(--bg-card);
|
||||
color: var(--text);
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12);
|
||||
}
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.radio-row {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
padding: 10px 0;
|
||||
}
|
||||
.radio-row label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-weight: 400;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
Empty state
|
||||
---------------------------------------------------------------- */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 60px 24px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.empty-state p {
|
||||
margin-bottom: 16px;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
Mobile — tablets and phones (≤ 768 px)
|
||||
---------------------------------------------------------------- */
|
||||
@media (max-width: 768px) {
|
||||
.admin-container {
|
||||
padding: 0 16px 40px;
|
||||
}
|
||||
|
||||
.admin-header {
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
padding: 14px 0;
|
||||
}
|
||||
|
||||
.admin-header h1 {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
/* Make the sessions table horizontally scrollable */
|
||||
.sessions-table-wrap {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
margin: 0 -16px;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.sessions-table {
|
||||
min-width: 560px; /* keep columns readable while allowing scroll */
|
||||
}
|
||||
|
||||
/* Stack action buttons vertically */
|
||||
.actions {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Forms: full-width inputs on small screens */
|
||||
input[type="text"],
|
||||
input[type="password"],
|
||||
input[type="file"],
|
||||
select,
|
||||
textarea {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.form-actions .btn {
|
||||
flex: 1 1 auto;
|
||||
text-align: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
@@ -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 = '🔈'; // 🔊
|
||||
btnAudio.title = 'Audio on — click to mute';
|
||||
} else {
|
||||
btnAudio.innerHTML = '🔇'; // 🔇
|
||||
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]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
})();
|
||||
@@ -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)
|
||||
* 2–4 = small (3 Hail Marys, stem)
|
||||
* 5 = large (Our Father, Decade 1)
|
||||
* 6–15 = small (10 HMs, Decade 1)
|
||||
* 16 = large (Our Father, Decade 2)
|
||||
* 17–26 = small (Decade 2)
|
||||
* 27 = large (Our Father, Decade 3)
|
||||
* 28–37 = small (Decade 3)
|
||||
* 38 = large (Our Father, Decade 4)
|
||||
* 39–48 = small (Decade 4)
|
||||
* 49 = large (Our Father, Decade 5)
|
||||
* 50–59 = 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 0–59 (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 };
|
||||
})();
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
})();
|
||||
Reference in New Issue
Block a user