From 663fde390953aec7644b528909c5f4959c43b9c1 Mon Sep 17 00:00:00 2001 From: Philip Guzman III Date: Wed, 13 May 2026 18:44:08 -0700 Subject: [PATCH] =?UTF-8?q?Initial=20commit=20=E2=80=94=20Rosary=20Present?= =?UTF-8?q?er=20App?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitignore | 20 + .htaccess | 27 + README.md | 117 ++++ admin/audio.php | 386 ++++++++++++ admin/index.php | 247 ++++++++ admin/novena_group.php | 338 +++++++++++ admin/profile.php | 253 ++++++++ admin/settings.php | 260 ++++++++ admin/setup.php | 250 ++++++++ admin/users.php | 425 +++++++++++++ api/delete_audio.php | 45 ++ api/save_session.php | 220 +++++++ api/toggle_pin.php | 64 ++ api/upload_audio.php | 100 +++ api/upload_photo.php | 78 +++ assets/css/present.css | 532 ++++++++++++++++ assets/css/public.css | 617 +++++++++++++++++++ assets/css/setup.css | 576 ++++++++++++++++++ assets/js/presenter.js | 668 ++++++++++++++++++++ assets/js/rosary.js | 364 +++++++++++ assets/js/setup.js | 186 ++++++ composer.json | 24 + config/db.example.php | 95 +++ confirm.php | 24 + data/prayers.php | 1213 +++++++++++++++++++++++++++++++++++++ favicon.svg | 7 + forgot_password.php | 85 +++ includes/auth.php | 68 +++ includes/build_slides.php | 261 ++++++++ includes/donate.php | 48 ++ includes/mailer.php | 207 +++++++ index.php | 430 +++++++++++++ install.php | 218 +++++++ login.php | 96 +++ logout.php | 9 + migrate_v2.php | 117 ++++ migrate_v3.php | 277 +++++++++ novena_group.php | 6 + novena_public.php | 145 +++++ present.php | 231 +++++++ profile.php | 160 +++++ recording_script.md | 1132 ++++++++++++++++++++++++++++++++++ register.php | 177 ++++++ reset_password.php | 93 +++ setup.php | 6 + uploads/.gitkeep | 0 46 files changed, 10902 insertions(+) create mode 100644 .gitignore create mode 100644 .htaccess create mode 100644 README.md create mode 100644 admin/audio.php create mode 100644 admin/index.php create mode 100644 admin/novena_group.php create mode 100644 admin/profile.php create mode 100644 admin/settings.php create mode 100644 admin/setup.php create mode 100644 admin/users.php create mode 100644 api/delete_audio.php create mode 100644 api/save_session.php create mode 100644 api/toggle_pin.php create mode 100644 api/upload_audio.php create mode 100644 api/upload_photo.php create mode 100644 assets/css/present.css create mode 100644 assets/css/public.css create mode 100644 assets/css/setup.css create mode 100644 assets/js/presenter.js create mode 100644 assets/js/rosary.js create mode 100644 assets/js/setup.js create mode 100644 composer.json create mode 100644 config/db.example.php create mode 100644 confirm.php create mode 100644 data/prayers.php create mode 100644 favicon.svg create mode 100644 forgot_password.php create mode 100644 includes/auth.php create mode 100644 includes/build_slides.php create mode 100644 includes/donate.php create mode 100644 includes/mailer.php create mode 100644 index.php create mode 100644 install.php create mode 100644 login.php create mode 100644 logout.php create mode 100644 migrate_v2.php create mode 100644 migrate_v3.php create mode 100644 novena_group.php create mode 100644 novena_public.php create mode 100644 present.php create mode 100644 profile.php create mode 100644 recording_script.md create mode 100644 register.php create mode 100644 reset_password.php create mode 100644 setup.php create mode 100644 uploads/.gitkeep diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3b04a84 --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +# Database credentials — copy db.example.php → db.php and fill in your values +config/db.php + +# User-uploaded audio and photo files +uploads/* +!uploads/.gitkeep + +# Claude Code project files +.claude/ + +# OS / editor noise +.DS_Store +Thumbs.db +*.swp +*.swo + +# Non-code assets +*.pptx +*.docx +*.pdf diff --git a/.htaccess b/.htaccess new file mode 100644 index 0000000..15bbba5 --- /dev/null +++ b/.htaccess @@ -0,0 +1,27 @@ +Options -Indexes +RewriteEngine On + +# Set to /subdir if deployed in a subdirectory (e.g. RewriteBase /rosary) +RewriteBase / + +# Let real files and directories pass through +RewriteCond %{REQUEST_FILENAME} -f [OR] +RewriteCond %{REQUEST_FILENAME} -d +RewriteRule ^ - [L] + +# Auth pages +RewriteRule ^login$ login.php [L,QSA] +RewriteRule ^logout$ logout.php [L,QSA] +RewriteRule ^register$ register.php [L,QSA] +RewriteRule ^confirm$ confirm.php [L,QSA] +RewriteRule ^forgot-password$ forgot_password.php [L,QSA] +RewriteRule ^reset-password$ reset_password.php [L,QSA] + +# Admin panel — let admin/ directory handle it +RewriteRule ^admin/?$ admin/index.php [L,QSA] + +# Public user rosary: /username/slug → present.php +RewriteRule ^([a-zA-Z0-9_-]+)/([a-zA-Z0-9_-]+)/?$ present.php?username=$1&slug=$2 [L,QSA] + +# Public user profile: /username → profile.php +RewriteRule ^([a-zA-Z0-9_-]+)/?$ profile.php?username=$1 [L,QSA] diff --git a/README.md b/README.md new file mode 100644 index 0000000..dfaeb3b --- /dev/null +++ b/README.md @@ -0,0 +1,117 @@ +# Rosary Presenter + +A multi-user web app for leading the Rosary, novenas, and the Divine Mercy Chaplet — built for live presentation at prayer services. Live at **[loveandrosary.com](https://loveandrosary.com)**. + +## What It Does + +- **Slide-based presentation** — navigate prayer-by-prayer with leader/congregation text split on screen +- **Rosary bead ring** — SVG visualization tracks which bead is active in real time +- **Session types** — General Rosary, Memorial Rosary, Deceased Novena, Divine Mercy Chaplet +- **Novena groups** — link 9 daily sessions into one group with a public day-picker page +- **Audio uploads** — attach MP3/audio per session (up to 50 MB) +- **Multi-user** — role hierarchy: `superadmin` → `admin` → `superuser` → `user` +- **Public profiles** — each user gets a `/username` page with their public sessions +- **Donate strip** — optional PayPal / Venmo / Buy Me a Coffee link on public pages + +## Stack + +- PHP 8 + PDO (no framework, no Composer dependencies) +- MySQL 8 / MariaDB +- Vanilla JS (no build step) +- Apache/Nginx with `.htaccess` rewrite rules + +## Setup + +### 1. Configure database + +```bash +cp config/db.example.php config/db.php +# Edit config/db.php — fill in DB_HOST, DB_NAME, DB_USER, DB_PASS +# Set BASE_URL if deploying to a subdirectory (e.g. '/rosary') +``` + +### 2. Create the database schema + +Visit `install.php` in your browser once to create all tables and seed the superadmin account. **Delete `install.php` immediately after.** + +Default superadmin credentials: `supadmin` / `supadmin` — **change these immediately**. + +### 3. Configure the web server + +#### Apache — `.htaccess` is included. Enable `mod_rewrite` and set `AllowOverride All`. + +#### Nginx — add to your server block: + +```nginx +location / { + try_files $uri $uri/ @php; +} +location @php { + rewrite ^/([^/]+)/([^/]+)$ /present.php?username=$1&slug=$2 last; + rewrite ^/([^/]+)$ /profile.php?username=$1 last; +} +``` + +### 4. Uploads directory + +```bash +chmod 755 uploads/ +``` + +### 5. SMTP (optional) + +Configure outbound email in **Admin → Settings** for registration confirmation and password reset emails. If left blank, the app will auto-confirm new users instead. + +## Deployment Checklist + +- [ ] `config/db.php` filled in with production credentials +- [ ] `install.php` deleted after first run +- [ ] `uploads/` is writable by the web server +- [ ] `BASE_URL` matches your subdirectory path (leave empty for domain root) +- [ ] Superadmin password and email changed +- [ ] SMTP configured in Admin → Settings + +## Project Structure + +``` +Rosary/ +├── admin/ # Admin dashboard (auth-gated) +│ ├── index.php # Dashboard home +│ ├── setup.php # Create/edit a session +│ ├── novena_group.php +│ ├── users.php +│ ├── settings.php # Site-wide settings (superadmin only) +│ └── audio.php +├── api/ # JSON endpoints (upload, save, delete) +├── assets/ +│ ├── css/ # present.css, public.css, setup.css +│ └── js/ # presenter.js, rosary.js, setup.js +├── config/ +│ ├── db.example.php # Copy → db.php and fill in credentials +│ └── db.php # (gitignored — contains real credentials) +├── data/ +│ └── prayers.php # All prayer text + build_decade_slides() +├── includes/ +│ ├── auth.php # require_auth(), current_user(), has_role() +│ ├── build_slides.php +│ ├── donate.php +│ └── mailer.php +├── uploads/ # User-uploaded audio (gitignored) +├── index.php # Public home — card grid of sessions +├── present.php # Presentation player (public) +├── novena_public.php # Novena day-picker (public) +├── install.php # Run once, then delete +└── .htaccess # URL rewriting +``` + +## URL Routing + +| URL | Resolves to | +|-----|-------------| +| `/username/slug` | `present.php?username=X&slug=Y` | +| `/username` | `profile.php?username=X` | +| `/username/novena-slug` | Redirects to `novena_public.php?group_id=X` | + +## License + +Private project — all rights reserved. diff --git a/admin/audio.php b/admin/audio.php new file mode 100644 index 0000000..d6c0e78 --- /dev/null +++ b/admin/audio.php @@ -0,0 +1,386 @@ + [ + 'sign_of_cross' => 'Sign of the Cross', + 'apostles_creed' => 'Apostles\' Creed', + 'our_father' => 'Our Father (all)', + 'hail_mary' => 'Hail Mary (all)', + 'glory_be' => 'Glory Be (all)', + 'fatima_prayer' => 'Fatima Prayer', + 'hail_holy_queen' => 'Hail Holy Queen', + 'rosary_closing_prayer' => 'Rosary Closing Prayer', + 'closing' => 'Closing Slide', + ], + 'Sorrowful Mysteries' => [ + 'mystery_sorrowful_1' => '1st Mystery — Agony in the Garden', + 'mystery_sorrowful_2' => '2nd Mystery — Scourging at the Pillar', + 'mystery_sorrowful_3' => '3rd Mystery — Crowning with Thorns', + 'mystery_sorrowful_4' => '4th Mystery — Carrying of the Cross', + 'mystery_sorrowful_5' => '5th Mystery — Crucifixion and Death', + ], + 'Joyful Mysteries' => [ + 'mystery_joyful_1' => '1st Mystery — The Annunciation', + 'mystery_joyful_2' => '2nd Mystery — The Visitation', + 'mystery_joyful_3' => '3rd Mystery — The Nativity', + 'mystery_joyful_4' => '4th Mystery — The Presentation', + 'mystery_joyful_5' => '5th Mystery — Finding in the Temple', + ], + 'Glorious Mysteries' => [ + 'mystery_glorious_1' => '1st Mystery — The Resurrection', + 'mystery_glorious_2' => '2nd Mystery — The Ascension', + 'mystery_glorious_3' => '3rd Mystery — Descent of the Holy Spirit', + 'mystery_glorious_4' => '4th Mystery — The Assumption', + 'mystery_glorious_5' => '5th Mystery — Coronation of Mary', + ], + 'Luminous Mysteries' => [ + 'mystery_luminous_1' => '1st Mystery — Baptism of Jesus', + 'mystery_luminous_2' => '2nd Mystery — Wedding at Cana', + 'mystery_luminous_3' => '3rd Mystery — Proclamation of the Kingdom', + 'mystery_luminous_4' => '4th Mystery — The Transfiguration', + 'mystery_luminous_5' => '5th Mystery — Institution of the Eucharist', + ], + 'Novena for Deceased' => [ + 'novena_day_1' => 'Day 1 — Novena Prayer', + 'novena_day_2' => 'Day 2 — Novena Prayer', + 'novena_day_3' => 'Day 3 — Novena Prayer', + 'novena_day_4' => 'Day 4 — Novena Prayer', + 'novena_day_5' => 'Day 5 — Novena Prayer', + 'novena_day_6' => 'Day 6 — Novena Prayer', + 'novena_day_7' => 'Day 7 — Novena Prayer', + 'novena_day_8' => 'Day 8 — Novena Prayer', + 'novena_day_9' => 'Day 9 — Novena Prayer', + 'litany_passion_intro' => 'Litany of Passion — Intro', + 'litany_passion_2' => 'Litany of Passion — Entry 2', + 'litany_passion_3' => 'Litany of Passion — Entry 3', + 'litany_passion_4' => 'Litany of Passion — Entry 4', + 'litany_passion_5' => 'Litany of Passion — Entry 5', + 'litany_passion_6' => 'Litany of Passion — Entry 6', + 'litany_passion_7' => 'Litany of Passion — Entry 7', + 'litany_passion_8' => 'Litany of Passion — Entry 8', + 'litany_passion_9' => 'Litany of Passion — Entry 9', + 'litany_passion_10' => 'Litany of Passion — Entry 10', + 'litany_passion_11' => 'Litany of Passion — Entry 11', + 'litany_departed_kyrie' => 'Litany for Departed — Kyrie', + 'litany_departed_christe' => 'Litany for Departed — Christe', + 'litany_departed_lord' => 'Litany for Departed — Lord', + 'litany_departed_mary' => 'Litany for Departed — Mary', + 'litany_departed_michael' => 'Litany for Departed — Michael', + 'litany_departed_angels' => 'Litany for Departed — Angels', + 'litany_departed_john' => 'Litany for Departed — John the Baptist', + 'litany_departed_joseph' => 'Litany for Departed — Joseph', + 'litany_departed_peter_paul' => 'Litany for Departed — Peter & Paul', + 'litany_departed_all_saints' => 'Litany for Departed — All Saints', + 'litany_departed_deliver_death' => 'Litany for Departed — Deliver from Death', + 'litany_departed_deliver_sin' => 'Litany for Departed — Deliver from Sin', + 'litany_departed_deliver_judgment' => 'Litany for Departed — Deliver from Judgment', + 'litany_departed_agnus_1' => 'Litany for Departed — Agnus Dei 1', + 'litany_departed_agnus_2' => 'Litany for Departed — Agnus Dei 2', + 'litany_departed_agnus_3' => 'Litany for Departed — Agnus Dei 3', + 'litany_departed_eternal_rest_1' => 'Eternal Rest (part 1)', + 'litany_departed_eternal_rest_2' => 'Eternal Rest (part 2)', + 'litany_departed_concluding' => 'Concluding Prayer', + ], + 'Divine Mercy Novena' => [ + 'dm_opening' => 'Opening Prayer', + 'dm_blood_water' => 'O Blood and Water (×3)', + 'dm_eternal_father' => 'Eternal Father (chaplet)', + 'dm_for_sake' => 'For the Sake of His Sorrowful Passion (×10)', + 'dm_holy_god' => 'Holy God (×3)', + 'dm_intention_day_1' => 'Day 1 — Jesus\' Intention', + 'dm_prayer_day_1' => 'Day 1 — Day Prayer', + 'dm_intention_day_2' => 'Day 2 — Jesus\' Intention', + 'dm_prayer_day_2' => 'Day 2 — Day Prayer', + 'dm_intention_day_3' => 'Day 3 — Jesus\' Intention', + 'dm_prayer_day_3' => 'Day 3 — Day Prayer', + 'dm_intention_day_4' => 'Day 4 — Jesus\' Intention', + 'dm_prayer_day_4' => 'Day 4 — Day Prayer', + 'dm_intention_day_5' => 'Day 5 — Jesus\' Intention', + 'dm_prayer_day_5' => 'Day 5 — Day Prayer', + 'dm_intention_day_6' => 'Day 6 — Jesus\' Intention', + 'dm_prayer_day_6' => 'Day 6 — Day Prayer', + 'dm_intention_day_7' => 'Day 7 — Jesus\' Intention', + 'dm_prayer_day_7' => 'Day 7 — Day Prayer', + 'dm_intention_day_8' => 'Day 8 — Jesus\' Intention', + 'dm_prayer_day_8' => 'Day 8 — Day Prayer', + 'dm_intention_day_9' => 'Day 9 — Jesus\' Intention', + 'dm_prayer_day_9' => 'Day 9 — Day Prayer', + ], +]; + +$total_keys = array_sum(array_map('count', $AUDIO_KEYS)); +$uploaded_count = 0; +foreach ($AUDIO_KEYS as $keys) { + foreach (array_keys($keys) as $k) { + if (isset($uploaded_files[$k])) $uploaded_count++; + } +} +?> + + + + + + + Audio — <?= htmlspecialchars($site_name) ?> + + + + + +
+ +
+

+ +
+ +
+

Prayer Audio

+

+ Upload pre-recorded audio for each prayer. A 🔊 toggle appears in the presenter when audio is available. +

+ +
+ How it works + Accepted formats: MP3, M4A, OGG, WAV  •  Max file size: 50 MB per file.
+ Prayers that repeat (e.g. Our Father, Hail Mary, Glory Be) share a single recording — upload once and it plays on every occurrence.
+ Uploading a new file for a key automatically replaces the old one. +
+ +
+
+
+
of prayers recorded
+
+
+ + + + $keys): ?> +
+

+
+ + + + + + + + + + + $label): + $has = isset($uploaded_files[$key]); + $ext = $has ? $uploaded_files[$key] : null; + ?> + + + + + + + + +
PrayerKeyStatusActions
+ + ✓ Uploaded + + — Not uploaded + + + + + + +
+
+
+ +
+
+ + + + diff --git a/admin/index.php b/admin/index.php new file mode 100644 index 0000000..7ed8dc9 --- /dev/null +++ b/admin/index.php @@ -0,0 +1,247 @@ +prepare('DELETE FROM sessions WHERE novena_group_id = ?')->execute([$gid]); + $pdo->prepare('DELETE FROM novena_groups WHERE id = ?')->execute([$gid]); + } else { + $pdo->prepare('DELETE FROM sessions WHERE novena_group_id = ? AND (SELECT user_id FROM novena_groups WHERE id = ?) = ?') + ->execute([$gid, $gid, $uid]); + $pdo->prepare('DELETE FROM novena_groups WHERE id = ? AND user_id = ?')->execute([$gid, $uid]); + } + header('Location: ' . BASE_URL . '/admin/'); + exit; + } + + if (isset($_POST['delete_id'])) { + $id = (int)$_POST['delete_id']; + if ($is_admin) { + $pdo->prepare('DELETE FROM sessions WHERE id = ?')->execute([$id]); + } else { + $pdo->prepare('DELETE FROM sessions WHERE id = ? AND user_id = ?')->execute([$id, $uid]); + } + header('Location: ' . BASE_URL . '/admin/'); + exit; + } +} + +// Load sessions +if ($is_admin) { + $regular = $pdo->query(" + SELECT s.*, 'session' AS row_type, u.username, u.display_name + FROM sessions s + LEFT JOIN users u ON u.id = s.user_id + WHERE s.novena_group_id IS NULL + ORDER BY s.created_at DESC + ")->fetchAll(); + + $novena_groups = $pdo->query(" + SELECT ng.*, 'novena_group' AS row_type, COUNT(s.id) AS day_count, + u.username, u.display_name + FROM novena_groups ng + LEFT JOIN sessions s ON s.novena_group_id = ng.id + LEFT JOIN users u ON u.id = ng.user_id + GROUP BY ng.id + ORDER BY ng.created_at DESC + ")->fetchAll(); +} else { + $regular = $pdo->prepare(" + SELECT s.*, 'session' AS row_type, u.username, u.display_name + FROM sessions s + LEFT JOIN users u ON u.id = s.user_id + WHERE s.novena_group_id IS NULL AND s.user_id = ? + ORDER BY s.created_at DESC + "); + $regular->execute([$uid]); + $regular = $regular->fetchAll(); + + $ng_stmt = $pdo->prepare(" + SELECT ng.*, 'novena_group' AS row_type, COUNT(s.id) AS day_count, + u.username, u.display_name + FROM novena_groups ng + LEFT JOIN sessions s ON s.novena_group_id = ng.id + LEFT JOIN users u ON u.id = ng.user_id + WHERE ng.user_id = ? + GROUP BY ng.id + ORDER BY ng.created_at DESC + "); + $ng_stmt->execute([$uid]); + $novena_groups = $ng_stmt->fetchAll(); +} + +$all_rows = array_merge($regular, $novena_groups); +usort($all_rows, fn($a, $b) => strcmp($b['created_at'], $a['created_at'])); + +$occasion_labels = [ + 'novena_deceased' => 'Novena for Deceased', + 'divine_mercy_novena' => 'Divine Mercy Novena', + 'general_rosary' => 'General Rosary', + 'memorial' => 'Memorial', +]; +$mystery_labels = [ + 'sorrowful' => 'Sorrowful', + 'joyful' => 'Joyful', + 'glorious' => 'Glorious', + 'luminous' => 'Luminous', + 'by_day_of_week' => 'By Day', +]; + +$novena_created = isset($_GET['novena_created']) ? (int)$_GET['novena_created'] : 0; +?> + + + + + + + Dashboard — <?= htmlspecialchars($site_name) ?> + + + +
+ +
+

+
+ ← View Site + + Users + Audio + + + Settings + + + Logout +
+
+ +
+ 0): ?> +
+ ✓ novena day sessions created. +
+ + +
+

+ + New Session +
+ + +
+

No sessions yet.

+ Create Your First Session +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameOccasionMysteriesOwnerPublicCreatedActions
+ + + + + + 9-Day Novena + ✓ Yes' : 'No' ?> + View Days → + +
+ + +
+ +
+ + + + + ✓ Yes' : 'No' ?> + + Present + + Edit +
+ + +
+ +
+
+ +
+
+ + diff --git a/admin/novena_group.php b/admin/novena_group.php new file mode 100644 index 0000000..240ca0c --- /dev/null +++ b/admin/novena_group.php @@ -0,0 +1,338 @@ +prepare('SELECT * FROM novena_groups WHERE id = ?'); +$group->execute([$gid]); +$group = $group->fetch(); + +if (!$group) { + header('Location: ' . BASE_URL . '/admin/'); + exit; +} + +// Ownership check +if (!has_role('admin') && (int)$group['user_id'] !== $uid) { + header('Location: ' . BASE_URL . '/admin/'); + exit; +} + +$is_dm = ($group['mystery_set'] === 'chaplet'); + +// Handle delete of a single day session +if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['delete_day_id'])) { + $did = (int)$_POST['delete_day_id']; + $pdo->prepare('DELETE FROM sessions WHERE id = ? AND novena_group_id = ?')->execute([$did, $gid]); + + $remaining = (int)$pdo->query("SELECT COUNT(*) FROM sessions WHERE novena_group_id = {$gid}")->fetchColumn(); + if ($remaining < 1) { + $pdo->prepare('DELETE FROM novena_groups WHERE id = ?')->execute([$gid]); + header('Location: ' . BASE_URL . '/admin/'); + exit; + } + header('Location: ' . BASE_URL . '/admin/novena_group.php?id=' . $gid . '&deleted=1'); + exit; +} + +// Handle group-level metadata edit +$save_error = ''; +$save_success = false; + +if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save_group'])) { + $g_name = trim($_POST['g_name'] ?? ''); + $g_photo = trim($_POST['g_photo'] ?? '') ?: null; + $g_public = isset($_POST['is_public']) ? 1 : 0; + + if ($is_dm) { + // Divine Mercy: keep mystery_set as 'chaplet', no subject fields + $g_mystery = 'chaplet'; + $g_subject = null; + $g_pronoun = null; + $g_dates = null; + } else { + $g_mystery = trim($_POST['g_mystery'] ?? ''); + $g_subject = trim($_POST['g_subject'] ?? '') ?: null; + $g_pronoun = trim($_POST['g_pronoun'] ?? '') ?: null; + $g_dates = trim($_POST['g_dates'] ?? '') ?: null; + } + + $valid_mysteries = ['sorrowful', 'joyful', 'glorious', 'luminous', 'by_day_of_week', 'chaplet']; + + if ($g_name === '') { + $save_error = 'Name is required.'; + } elseif (!in_array($g_mystery, $valid_mysteries, true)) { + $save_error = 'Invalid mystery set.'; + } else { + $pdo->prepare(' + UPDATE novena_groups + SET name = ?, mystery_set = ?, subject_name = ?, + subject_pronoun = ?, subject_dates = ?, + photo_path = COALESCE(?, photo_path), + is_public = ? + WHERE id = ? + ')->execute([$g_name, $g_mystery, $g_subject, $g_pronoun, $g_dates, $g_photo, $g_public, $gid]); + + $pdo->prepare(' + UPDATE sessions + SET mystery_set = ?, subject_name = ?, + subject_pronoun = ?, subject_dates = ?, + photo_path = COALESCE(?, photo_path), + name = CONCAT(?, CONCAT(\' — Day \', novena_day)), + is_public = ? + WHERE novena_group_id = ? + ')->execute([$g_mystery, $g_subject, $g_pronoun, $g_dates, $g_photo, $g_name, $g_public, $gid]); + + $save_success = true; + // Reload group + $grp_stmt = $pdo->prepare('SELECT * FROM novena_groups WHERE id = ?'); + $grp_stmt->execute([$gid]); + $group = $grp_stmt->fetch(); + } +} + +$days = $pdo->prepare('SELECT * FROM sessions WHERE novena_group_id = ? ORDER BY novena_day'); +$days->execute([$gid]); +$days = $days->fetchAll(); + +$mystery_labels = [ + 'sorrowful' => 'Sorrowful Mysteries', + 'joyful' => 'Joyful Mysteries', + 'glorious' => 'Glorious Mysteries', + 'luminous' => 'Luminous Mysteries', + 'by_day_of_week' => 'By Day of Week', + 'chaplet' => 'Chaplet of Divine Mercy', +]; +?> + + + + + + + <?= htmlspecialchars($group['name']) ?> — <?= htmlspecialchars($site_name) ?> + + + + +
+ +
+

+ +
+ +
+ + + +
Day deleted successfully.
+ + +
✓ Novena updated. Changes applied to all 9 day sessions.
+ +
+ + +
+

Novena Details

+
+ + +
+
+ + +

Used as the base name for all 9 days.

+
+ + +
+ + +
+ +
+ + +
+ +
+ +
+ + +
+
+ +
+ + +
+ + +
+ + +
+ Current photo +
+ + + + + + +
+ +
+ +
+
+ +
+ +
+
+
+ +
+

Days ( of 9)

+
+ + + + + + + + + + + > + + + + + + +
DayMysteriesActions
Day missing' ?> + + Present +
+ + +
+ + + +
+
+
+
+
+ + + + diff --git a/admin/profile.php b/admin/profile.php new file mode 100644 index 0000000..2e01fbe --- /dev/null +++ b/admin/profile.php @@ -0,0 +1,253 @@ +prepare('SELECT * FROM users WHERE id = ?'); +$stmt->execute([$target_id]); +$profile = $stmt->fetch(); +if (!$profile) { + header('Location: ' . BASE_URL . '/admin/'); + exit; +} + +// ── Handle form submissions ─────────────────────────────────────────────────── +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $action = $_POST['action'] ?? ''; + + // ── Update profile ─────────────────────────────────────────────────────── + if ($action === 'update_profile') { + $new_display = trim($_POST['display_name'] ?? ''); + $pdo->prepare('UPDATE users SET display_name=? WHERE id=?')->execute([$new_display, $target_id]); + + // Refresh session if editing own profile + if ($target_id === $uid) { + $_SESSION['display_name'] = $new_display; + } + $messages[] = 'Profile updated.'; + $profile['display_name'] = $new_display; + } + + // ── Update email ───────────────────────────────────────────────────────── + if ($action === 'update_email') { + $new_email = trim($_POST['new_email'] ?? ''); + $cur_pass = $_POST['current_password'] ?? ''; + + if (!filter_var($new_email, FILTER_VALIDATE_EMAIL)) { + $errors[] = 'Invalid email address.'; + } elseif (!password_verify($cur_pass, $profile['password_hash'])) { + $errors[] = 'Current password is incorrect.'; + } else { + $chk = $pdo->prepare('SELECT id FROM users WHERE email=? AND id!=?'); + $chk->execute([$new_email, $target_id]); + if ($chk->fetch()) { + $errors[] = 'That email is already in use.'; + } else { + $pdo->prepare('UPDATE users SET email=? WHERE id=?')->execute([$new_email, $target_id]); + if ($target_id === $uid) $_SESSION['email'] = $new_email; + $messages[] = 'Email updated.'; + $profile['email'] = $new_email; + } + } + } + + // ── Change password ─────────────────────────────────────────────────────── + if ($action === 'change_password') { + $cur_pass = $_POST['current_password'] ?? ''; + $new_pass = $_POST['new_password'] ?? ''; + $conf_pass = $_POST['confirm_password'] ?? ''; + + if (!password_verify($cur_pass, $profile['password_hash'])) { + $errors[] = 'Current password is incorrect.'; + } elseif (strlen($new_pass) < 8) { + $errors[] = 'New password must be at least 8 characters.'; + } elseif ($new_pass !== $conf_pass) { + $errors[] = 'New passwords do not match.'; + } else { + $hash = password_hash($new_pass, PASSWORD_BCRYPT); + $pdo->prepare('UPDATE users SET password_hash=? WHERE id=?')->execute([$hash, $target_id]); + $messages[] = 'Password changed successfully.'; + $profile['password_hash'] = $hash; + } + } + + // ── Superadmin: change rosary limit ────────────────────────────────────── + if ($action === 'update_limit' && has_role('superadmin')) { + $new_limit = (int)($_POST['rosary_limit'] ?? 1); + $pdo->prepare('UPDATE users SET rosary_limit=? WHERE id=?')->execute([$new_limit, $target_id]); + if ($target_id === $uid) $_SESSION['rosary_limit'] = $new_limit; + $messages[] = 'Rosary limit updated.'; + $profile['rosary_limit'] = $new_limit; + } +} + +$is_own = ($target_id === $uid); +$is_super = has_role('superadmin'); +$role_labels = ['superadmin'=>'Superadmin','admin'=>'Admin','superuser'=>'Superuser','user'=>'User']; +?> + + + + + + + <?= $is_own ? 'My Account' : 'Edit User' ?> — <?= htmlspecialchars($site_name) ?> + + + + +
+ +
+

+ +
+ +
+ +
+ + +
+ + +

+ + +
+

Account Info

+
+ +
+
+
+ +
+
+
+ +
+ + +  (edit below) + +
+
+
+ +
+
+
+ + +
+

Display Name

+
+ +
+ + +

Shown publicly on your profile and rosary cards.

+
+ +
+
+ + +
+

Email Address

+
+ +
+ +
+
+
+ + +
+
+ + +
+ +
+
+ + +
+

Change Password

+
+ +
+ + +
+
+ + +

At least 8 characters.

+
+
+ + +
+ +
+
+ + + +
+

Rosary Limit

+
+ +
+ + +

How many rosary sessions this user can create.

+
+ +
+
+ + +
+
+ + diff --git a/admin/settings.php b/admin/settings.php new file mode 100644 index 0000000..3b6c80c --- /dev/null +++ b/admin/settings.php @@ -0,0 +1,260 @@ +Test Email +

Hello, " . htmlspecialchars($tname) . "!

+

This is a test email from your {$site_name} installation. If you received this, your SMTP settings are working correctly.

" + ); + $ok = send_email($to, $tname, 'Test Email — ' . $site_name, $body); + if ($ok) { + $message = 'Test email sent to ' . htmlspecialchars($to); + } else { + $error = 'Failed to send test email. Check your SMTP settings and server error log.'; + } + } +} + +$settings = [ + 'site_name' => get_setting('site_name', 'Rosary Presenter'), + 'site_url' => get_setting('site_url'), + 'smtp_host' => get_setting('smtp_host'), + 'smtp_port' => get_setting('smtp_port', '587'), + 'smtp_user' => get_setting('smtp_user'), + 'smtp_pass' => get_setting('smtp_pass'), + 'smtp_from' => get_setting('smtp_from'), + 'smtp_from_name' => get_setting('smtp_from_name', 'Rosary Presenter'), + 'donate_enabled' => get_setting('donate_enabled', '0'), + 'donate_type' => get_setting('donate_type', 'custom'), + 'donate_handle' => get_setting('donate_handle', ''), + 'donate_label' => get_setting('donate_label', ''), +]; +?> + + + + + + + Settings — <?= htmlspecialchars($site_name) ?> + + + + +
+ +
+

+ +
+ +
+ +
+ + +
+ + +

Site Settings

+ +
+ + +
+

General

+
+ + +

Displayed in the browser tab, emails, and the site header.

+
+
+ + +

Used for links in emails. Include protocol, no trailing slash.

+
+
+ +
+

SMTP Email

+

+ Leave SMTP Host blank to use PHP's built-in mail() function. + For Gmail: host=smtp.gmail.com, port=587, use an App Password. +

+
+
+ + +
+
+ + +

587 for STARTTLS, 465 for SSL.

+
+
+ + +
+
+ +
+ + +
+
+
+ + +
+
+ + +
+
+
+ +
+

Donate Button

+

+ Show a small donation strip on the public home page. Choose a service, enter your handle or URL, and enable it when ready. +

+
+ +
+ +
+ +
+ +
+
+ +
+

Test Email

+

Send a test email to to verify your SMTP settings.

+
+ + +
+
+
+
+ + + + diff --git a/admin/setup.php b/admin/setup.php new file mode 100644 index 0000000..f5955ef --- /dev/null +++ b/admin/setup.php @@ -0,0 +1,250 @@ +prepare('SELECT * FROM sessions WHERE id = ?'); + $stmt->execute([(int)$_GET['id']]); + $session = $stmt->fetch(); + if (!$session) { + header('Location: ' . BASE_URL . '/admin/'); + exit; + } + // Ownership check: must own or be admin + if (!has_role('admin') && (int)$session['user_id'] !== $uid) { + header('Location: ' . BASE_URL . '/admin/'); + exit; + } +} + +// Creating new session? Check rosary limit +$limit_error = ''; +if (!$session && !can_create_rosary($uid, $user['rosary_limit'])) { + $limit = $user['rosary_limit']; + $limit_error = "You have reached your rosary limit ({$limit}). Please contact an administrator to increase your limit."; +} + +$page_title = $session ? 'Edit Session' : 'New Session'; +?> + + + + + + + <?= $page_title ?> — <?= htmlspecialchars($site_name) ?> + + + + +
+
+

+
+ ← View Site + + Users + + + Settings + + + ← Back + Logout +
+
+ +
+

+ + +
+ + + + +
+ + + + + +
+ + +

+ For novena: enter just the name (e.g. "Medy") — sessions will be created as + "Medy — Day 1" through "Medy — Day 9". +

+
+ + +
+ + +
+ + +
+ + +
+ + + + + + + + + + + + + + + + + +
+ + +
+ Current photo +

Current photo. Upload a new one to replace it.

+
+ + +

JPEG, PNG, WebP — max 5 MB. Recommended: square or landscape, at least 800 × 600 px. Shown on the home page card and cover slide.

+ + +
+ + +
+ +
+ +
+ + Cancel +
+
+ + +
+
+ + + + diff --git a/admin/users.php b/admin/users.php new file mode 100644 index 0000000..a556db6 --- /dev/null +++ b/admin/users.php @@ -0,0 +1,425 @@ +prepare('SELECT id FROM users WHERE username=? OR email=?'); + $chk->execute([$new_username, $new_email]); + if ($chk->fetch()) { + $errors[] = 'Username or email already in use.'; + } else { + $hash = password_hash($new_password, PASSWORD_BCRYPT); + $pdo->prepare(" + INSERT INTO users (username,email,password_hash,display_name,role,rosary_limit,email_confirmed) + VALUES (?,?,?,?,?,?,1) + ")->execute([$new_username, $new_email, $hash, $new_display_name ?: $new_username, $new_role, $new_limit]); + $messages[] = "User '{$new_username}' created."; + } + } + } + + // ── Update user ────────────────────────────────────────────────────────── + if ($action === 'update_user') { + $target_id = (int)($_POST['target_id'] ?? 0); + $upd_display = trim($_POST['upd_display_name'] ?? ''); + $upd_email = trim($_POST['upd_email'] ?? ''); + $upd_role = $_POST['upd_role'] ?? ''; + $upd_limit = (int)($_POST['upd_rosary_limit'] ?? 1); + + // Fetch target to check if superadmin + $tgt = $pdo->prepare('SELECT * FROM users WHERE id=?'); + $tgt->execute([$target_id]); + $tgt = $tgt->fetch(); + + if (!$tgt) { + $errors[] = 'User not found.'; + } else { + // Role protection + $allowed_roles = $is_super ? ['user','superuser','admin','superadmin'] : ['user','superuser']; + if (!in_array($upd_role, $allowed_roles, true)) $upd_role = $tgt['role']; + // Non-superadmin cannot change superadmin's role + if (!$is_super && $tgt['role'] === 'superadmin') { + $errors[] = 'Cannot modify a superadmin account.'; + } else { + if (!filter_var($upd_email, FILTER_VALIDATE_EMAIL)) { + $errors[] = 'Invalid email.'; + } else { + $chk = $pdo->prepare('SELECT id FROM users WHERE email=? AND id!=?'); + $chk->execute([$upd_email, $target_id]); + if ($chk->fetch()) { + $errors[] = 'Email already in use by another account.'; + } else { + $pdo->prepare('UPDATE users SET display_name=?,email=?,role=?,rosary_limit=? WHERE id=?') + ->execute([$upd_display, $upd_email, $upd_role, $upd_limit, $target_id]); + $messages[] = "User '{$tgt['username']}' updated."; + } + } + } + } + } + + // ── Reset password ─────────────────────────────────────────────────────── + if ($action === 'reset_password') { + $target_id = (int)($_POST['target_id'] ?? 0); + $new_pass = $_POST['new_pass'] ?? ''; + + if (strlen($new_pass) < 8) { + $errors[] = 'New password must be at least 8 characters.'; + } else { + $hash = password_hash($new_pass, PASSWORD_BCRYPT); + $pdo->prepare('UPDATE users SET password_hash=? WHERE id=?')->execute([$hash, $target_id]); + $messages[] = 'Password reset successfully.'; + } + } + + // ── Resend confirmation email ───────────────────────────────────────────── + if ($action === 'resend_confirmation') { + $target_id = (int)($_POST['target_id'] ?? 0); + $tgt = $pdo->prepare('SELECT * FROM users WHERE id = ? AND email_confirmed = 0'); + $tgt->execute([$target_id]); + $tgt = $tgt->fetch(); + + if (!$tgt) { + $errors[] = 'User not found or already confirmed.'; + } else { + $smtp_host = get_setting('smtp_host'); + if ($smtp_host === '') { + // No SMTP — confirm directly + $pdo->prepare('UPDATE users SET email_confirmed = 1, confirm_token = NULL WHERE id = ?') + ->execute([$target_id]); + $messages[] = "No SMTP configured — {$tgt['username']} has been confirmed directly."; + } else { + $token = bin2hex(random_bytes(32)); + $site_url = rtrim(get_setting('site_url'), '/'); + $site_name = get_setting('site_name', APP_NAME); + $link = $site_url . '/confirm?token=' . urlencode($token); + $disp = $tgt['display_name'] ?: $tgt['username']; + + $pdo->prepare('UPDATE users SET confirm_token = ? WHERE id = ?') + ->execute([$token, $target_id]); + + $body_html = " +

Confirm your email

+

Hello, " . htmlspecialchars($disp) . "!

+

An administrator has resent your confirmation email for {$site_name}. Click the button below to confirm your email address and activate your account:

+

+ Confirm Email +

+

Or copy this link: " . htmlspecialchars($link) . "

+

If you did not register, ignore this email.

+ "; + $html = email_template('Confirm your email — ' . $site_name, $body_html); + $ok = send_email($tgt['email'], $disp, 'Confirm your email — ' . $site_name, $html); + + if ($ok) { + $messages[] = "Confirmation email resent to {$tgt['email']}."; + } else { + $errors[] = 'Failed to send email. Check your SMTP settings.'; + } + } + } + } + + // ── Delete user ────────────────────────────────────────────────────────── + if ($action === 'delete_user') { + $target_id = (int)($_POST['target_id'] ?? 0); + if ($target_id === $uid) { + $errors[] = 'You cannot delete your own account.'; + } else { + $tgt = $pdo->prepare('SELECT role,username FROM users WHERE id=?'); + $tgt->execute([$target_id]); + $tgt = $tgt->fetch(); + if ($tgt && $tgt['role'] === 'superadmin' && !$is_super) { + $errors[] = 'Cannot delete a superadmin account.'; + } elseif ($tgt) { + $pdo->prepare('DELETE FROM users WHERE id=?')->execute([$target_id]); + $messages[] = "User '{$tgt['username']}' deleted."; + } + } + } +} + +// ── Load all users with rosary counts ──────────────────────────────────────── +$users = $pdo->query(" + SELECT u.*, + (SELECT COUNT(*) FROM sessions WHERE user_id=u.id AND occasion != 'novena_deceased') + + (SELECT COUNT(*) FROM novena_groups WHERE user_id=u.id) + AS rosary_count + FROM users u + ORDER BY u.created_at DESC +")->fetchAll(); + +$role_labels = ['superadmin'=>'Superadmin','admin'=>'Admin','superuser'=>'Superuser','user'=>'User']; +$role_colors = [ + 'superadmin' => '#dc2626', + 'admin' => '#ea580c', + 'superuser' => '#2563eb', + 'user' => '#6b7280', +]; +?> + + + + + + + Users — <?= htmlspecialchars($site_name) ?> + + + + +
+ +
+

+ +
+ +
+ +
+ + +
+ + +
+

Users ()

+ +
+ + +
+

Create New User

+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
UsernameDisplay NameEmailRoleLimitRosariesJoinedActions
+ + Unconfirmed' : '' ?> + + + + + + + + + + +
+ + + +
+ + +
+ + + +
+ + + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+ +
+ + +
+
+ + +
+
+ +
+
+
+
+
+
+
+
+ + + + diff --git a/api/delete_audio.php b/api/delete_audio.php new file mode 100644 index 0000000..2a2f670 --- /dev/null +++ b/api/delete_audio.php @@ -0,0 +1,45 @@ + 'Permission denied']); + exit; +} + +if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + http_response_code(405); + echo json_encode(['error' => 'Method not allowed']); + exit; +} + +$key = trim($_POST['key'] ?? ''); +if (!preg_match('/^[a-z0-9_]+$/', $key) || strlen($key) > 100) { + http_response_code(400); + echo json_encode(['error' => 'Invalid audio key']); + exit; +} + +$audio_dir = UPLOADS_DIR . 'audio/'; +$deleted = false; + +foreach (glob($audio_dir . $key . '.*') ?: [] as $file) { + unlink($file); + $deleted = true; +} + +echo json_encode(['deleted' => $deleted]); diff --git a/api/save_session.php b/api/save_session.php new file mode 100644 index 0000000..971492f --- /dev/null +++ b/api/save_session.php @@ -0,0 +1,220 @@ + 'Method not allowed']); + exit; +} + +// Collect and sanitize input +$id = isset($_POST['id']) && $_POST['id'] !== '' ? (int)$_POST['id'] : null; +$name = trim($_POST['name'] ?? ''); +$occasion = trim($_POST['occasion'] ?? ''); +$mystery_set = trim($_POST['mystery_set'] ?? ''); +$novena_mystery_mode = trim($_POST['novena_mystery_mode'] ?? ''); +$novena_day = isset($_POST['novena_day']) && $_POST['novena_day'] !== '' ? (int)$_POST['novena_day'] : null; +$subject_name = trim($_POST['subject_name'] ?? '') ?: null; +$subject_pronoun = trim($_POST['subject_pronoun'] ?? '') ?: null; +$subject_dates = trim($_POST['subject_dates'] ?? '') ?: null; +$photo_path = trim($_POST['photo_path'] ?? '') ?: null; +$is_public = isset($_POST['is_public']) ? 1 : 0; + +// For novena sessions, mystery_set is determined by novena_mystery_mode +if ($occasion === 'novena_deceased') { + $mystery_set = ($novena_mystery_mode === 'by_day_of_week') ? 'by_day_of_week' : 'sorrowful'; +} +// Divine Mercy Novena uses the chaplet — no mystery set +if ($occasion === 'divine_mercy_novena') { + $mystery_set = 'chaplet'; +} + +// Validate required fields +$valid_occasions = ['novena_deceased', 'divine_mercy_novena', 'general_rosary', 'memorial']; +$valid_mysteries = ['sorrowful', 'joyful', 'glorious', 'luminous', 'by_day_of_week', 'chaplet']; + +if ($name === '') { + http_response_code(400); + echo json_encode(['error' => 'Session name is required']); + exit; +} +if (!in_array($occasion, $valid_occasions, true)) { + http_response_code(400); + echo json_encode(['error' => 'Invalid occasion']); + exit; +} +if (!in_array($mystery_set, $valid_mysteries, true)) { + http_response_code(400); + echo json_encode(['error' => 'Invalid mystery set']); + exit; +} + +try { + $pdo = get_pdo(); + + // ------------------------------------------------------------------ + // EDIT: update single existing session + // ------------------------------------------------------------------ + if ($id !== null) { + // Verify ownership or admin + $chk = $pdo->prepare('SELECT user_id FROM sessions WHERE id = ?'); + $chk->execute([$id]); + $row = $chk->fetch(); + if (!$row) { + http_response_code(404); + echo json_encode(['error' => 'Session not found']); + exit; + } + if (!has_role('admin') && (int)$row['user_id'] !== $uid) { + http_response_code(403); + echo json_encode(['error' => 'Permission denied']); + exit; + } + + // Update slug if name changed and user owns it + $new_slug = null; + if (!has_role('admin') || (int)$row['user_id'] === $uid) { + $owner_id = (int)$row['user_id']; + $new_slug = unique_slug($name, $owner_id, 'sessions', $id); + } + + $stmt = $pdo->prepare(' + UPDATE sessions + SET name = ?, occasion = ?, mystery_set = ?, + subject_name = ?, subject_pronoun = ?, subject_dates = ?, + photo_path = COALESCE(?, photo_path), + is_public = ?' . + ($new_slug !== null ? ', slug = ?' : '') . ' + WHERE id = ? + '); + + $params = [ + $name, $occasion, $mystery_set, + $subject_name, $subject_pronoun, $subject_dates, + $photo_path, $is_public, + ]; + if ($new_slug !== null) $params[] = $new_slug; + $params[] = $id; + + $stmt->execute($params); + echo json_encode(['id' => $id]); + exit; + } + + // ------------------------------------------------------------------ + // Creating new: check rosary limit + // ------------------------------------------------------------------ + if (!can_create_rosary($uid, $user['rosary_limit'])) { + http_response_code(429); + echo json_encode(['error' => 'Rosary limit reached. Contact an administrator to increase your limit.']); + exit; + } + + // ------------------------------------------------------------------ + // CREATE NEW NOVENA: create a group record, then 9 day sessions + // ------------------------------------------------------------------ + if ($occasion === 'divine_mercy_novena') { + $grp_slug = unique_slug($name, $uid, 'novena_groups'); + + $grp = $pdo->prepare(' + INSERT INTO novena_groups + (name, mystery_set, subject_name, subject_pronoun, subject_dates, photo_path, user_id, is_public, slug) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + '); + $grp->execute([$name, $mystery_set, null, null, null, $photo_path, $uid, $is_public, $grp_slug]); + $group_id = (int)$pdo->lastInsertId(); + + $insert = $pdo->prepare(' + INSERT INTO sessions + (name, occasion, mystery_set, novena_day, + subject_name, subject_pronoun, subject_dates, photo_path, novena_group_id, + user_id, is_public, slug) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + '); + $created_ids = []; + for ($day = 1; $day <= 9; $day++) { + $day_name = $name . ' — Day ' . $day; + $day_slug = unique_slug($day_name, $uid, 'sessions'); + $insert->execute([ + $day_name, $occasion, $mystery_set, $day, + null, null, null, $photo_path, $group_id, + $uid, $is_public, $day_slug, + ]); + $created_ids[] = (int)$pdo->lastInsertId(); + } + echo json_encode(['novena' => true, 'group_id' => $group_id, 'ids' => $created_ids]); + exit; + } + + if ($occasion === 'novena_deceased') { + $grp_slug = unique_slug($name, $uid, 'novena_groups'); + + $grp = $pdo->prepare(' + INSERT INTO novena_groups + (name, mystery_set, subject_name, subject_pronoun, subject_dates, photo_path, user_id, is_public, slug) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + '); + $grp->execute([$name, $mystery_set, $subject_name, $subject_pronoun, $subject_dates, $photo_path, $uid, $is_public, $grp_slug]); + $group_id = (int)$pdo->lastInsertId(); + + $insert = $pdo->prepare(' + INSERT INTO sessions + (name, occasion, mystery_set, novena_day, + subject_name, subject_pronoun, subject_dates, photo_path, novena_group_id, + user_id, is_public, slug) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + '); + $created_ids = []; + for ($day = 1; $day <= 9; $day++) { + $day_name = $name . ' — Day ' . $day; + $day_slug = unique_slug($day_name, $uid, 'sessions'); + $insert->execute([ + $day_name, $occasion, $mystery_set, $day, + $subject_name, $subject_pronoun, $subject_dates, $photo_path, $group_id, + $uid, $is_public, $day_slug, + ]); + $created_ids[] = (int)$pdo->lastInsertId(); + } + echo json_encode(['novena' => true, 'group_id' => $group_id, 'ids' => $created_ids]); + exit; + } + + // ------------------------------------------------------------------ + // CREATE NEW: single session (general_rosary or memorial) + // ------------------------------------------------------------------ + $slug = unique_slug($name, $uid, 'sessions'); + + $stmt = $pdo->prepare(' + INSERT INTO sessions + (name, occasion, mystery_set, novena_day, + subject_name, subject_pronoun, subject_dates, photo_path, + user_id, is_public, slug) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + '); + $stmt->execute([ + $name, $occasion, $mystery_set, $novena_day, + $subject_name, $subject_pronoun, $subject_dates, $photo_path, + $uid, $is_public, $slug, + ]); + echo json_encode(['id' => (int)$pdo->lastInsertId()]); + +} catch (PDOException $e) { + http_response_code(500); + echo json_encode(['error' => 'Database error: ' . $e->getMessage()]); +} diff --git a/api/toggle_pin.php b/api/toggle_pin.php new file mode 100644 index 0000000..9ad4f9c --- /dev/null +++ b/api/toggle_pin.php @@ -0,0 +1,64 @@ + 'Permission denied']); + exit; +} + +if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + http_response_code(405); + echo json_encode(['error' => 'Method not allowed']); + exit; +} + +$type = trim($_POST['type'] ?? ''); +$id = (int)($_POST['id'] ?? 0); + +if (!in_array($type, ['session', 'novena'], true) || $id < 1) { + http_response_code(400); + echo json_encode(['error' => 'Invalid parameters']); + exit; +} + +try { + $pdo = get_pdo(); + $table = ($type === 'session') ? 'sessions' : 'novena_groups'; + + $sel = $pdo->prepare("SELECT is_pinned FROM {$table} WHERE id = ?"); + $sel->execute([$id]); + $row = $sel->fetch(); + + if (!$row) { + http_response_code(404); + echo json_encode(['error' => 'Record not found']); + exit; + } + + $new = $row['is_pinned'] ? 0 : 1; + $upd = $pdo->prepare("UPDATE {$table} SET is_pinned = ? WHERE id = ?"); + $upd->execute([$new, $id]); + + echo json_encode(['pinned' => (bool)$new]); + +} catch (PDOException $e) { + http_response_code(500); + echo json_encode(['error' => 'Database error']); +} diff --git a/api/upload_audio.php b/api/upload_audio.php new file mode 100644 index 0000000..050166e --- /dev/null +++ b/api/upload_audio.php @@ -0,0 +1,100 @@ + 'Permission denied']); + exit; +} + +if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + http_response_code(405); + echo json_encode(['error' => 'Method not allowed']); + exit; +} + +$key = trim($_POST['key'] ?? ''); +if (!preg_match('/^[a-z0-9_]+$/', $key) || strlen($key) > 100) { + http_response_code(400); + echo json_encode(['error' => 'Invalid audio key']); + exit; +} + +if (!isset($_FILES['audio']) || $_FILES['audio']['error'] !== UPLOAD_ERR_OK) { + $codes = [ + UPLOAD_ERR_INI_SIZE => 'File exceeds server limit', + UPLOAD_ERR_FORM_SIZE => 'File exceeds form limit', + UPLOAD_ERR_PARTIAL => 'File only partially uploaded', + UPLOAD_ERR_NO_FILE => 'No file uploaded', + ]; + $code = $_FILES['audio']['error'] ?? UPLOAD_ERR_NO_FILE; + http_response_code(400); + echo json_encode(['error' => $codes[$code] ?? 'Upload error']); + exit; +} + +$file = $_FILES['audio']; +$max_size = 50 * 1024 * 1024; // 50 MB + +if ($file['size'] > $max_size) { + http_response_code(400); + echo json_encode(['error' => 'File too large (max 50 MB)']); + exit; +} + +$finfo = new finfo(FILEINFO_MIME_TYPE); +$mime = $finfo->file($file['tmp_name']); +$allowed = [ + 'audio/mpeg' => 'mp3', + 'audio/mp3' => 'mp3', + 'audio/mp4' => 'm4a', + 'audio/x-m4a' => 'm4a', + 'audio/ogg' => 'ogg', + 'audio/wav' => 'wav', + 'audio/x-wav' => 'wav', + 'audio/wave' => 'wav', + 'audio/webm' => 'webm', +]; + +if (!isset($allowed[$mime])) { + http_response_code(400); + echo json_encode(['error' => 'Invalid format. Allowed: MP3, M4A, OGG, WAV']); + exit; +} + +$ext = $allowed[$mime]; +$audio_dir = UPLOADS_DIR . 'audio/'; + +if (!is_dir($audio_dir)) { + mkdir($audio_dir, 0755, true); +} + +// Delete any existing file for this key (regardless of extension) +foreach (glob($audio_dir . $key . '.*') ?: [] as $old) { + unlink($old); +} + +$dest = $audio_dir . $key . '.' . $ext; + +if (!move_uploaded_file($file['tmp_name'], $dest)) { + http_response_code(500); + echo json_encode(['error' => 'Failed to save file']); + exit; +} + +echo json_encode(['key' => $key, 'ext' => $ext]); diff --git a/api/upload_photo.php b/api/upload_photo.php new file mode 100644 index 0000000..3bc8108 --- /dev/null +++ b/api/upload_photo.php @@ -0,0 +1,78 @@ + 'Method not allowed']); + exit; +} + +if (!isset($_FILES['photo']) || $_FILES['photo']['error'] !== UPLOAD_ERR_OK) { + $upload_errors = [ + UPLOAD_ERR_INI_SIZE => 'File exceeds server upload limit', + UPLOAD_ERR_FORM_SIZE => 'File exceeds form size limit', + UPLOAD_ERR_PARTIAL => 'File was only partially uploaded', + UPLOAD_ERR_NO_FILE => 'No file was uploaded', + UPLOAD_ERR_NO_TMP_DIR => 'Missing temporary folder', + UPLOAD_ERR_CANT_WRITE => 'Failed to write file to disk', + UPLOAD_ERR_EXTENSION => 'A PHP extension stopped the upload', + ]; + $err_code = $_FILES['photo']['error'] ?? UPLOAD_ERR_NO_FILE; + $err_msg = $upload_errors[$err_code] ?? 'Unknown upload error'; + http_response_code(400); + echo json_encode(['error' => $err_msg]); + exit; +} + +$file = $_FILES['photo']; +$max_size = 5 * 1024 * 1024; // 5 MB + +// Validate file size +if ($file['size'] > $max_size) { + http_response_code(400); + echo json_encode(['error' => 'File is too large (max 5 MB)']); + exit; +} + +// Validate MIME type using finfo (not just extension) +$finfo = new finfo(FILEINFO_MIME_TYPE); +$mime = $finfo->file($file['tmp_name']); +$allowed = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']; + +if (!in_array($mime, $allowed, true)) { + http_response_code(400); + echo json_encode(['error' => 'Invalid file type. Allowed: JPEG, PNG, GIF, WebP']); + exit; +} + +$ext_map = [ + 'image/jpeg' => 'jpg', + 'image/png' => 'png', + 'image/gif' => 'gif', + 'image/webp' => 'webp', +]; +$ext = $ext_map[$mime]; +$filename = bin2hex(random_bytes(16)) . '.' . $ext; +$dest = UPLOADS_DIR . $filename; + +if (!is_dir(UPLOADS_DIR)) { + mkdir(UPLOADS_DIR, 0755, true); +} + +if (!move_uploaded_file($file['tmp_name'], $dest)) { + http_response_code(500); + echo json_encode(['error' => 'Failed to save file']); + exit; +} + +echo json_encode(['path' => UPLOADS_URL . $filename]); diff --git a/assets/css/present.css b/assets/css/present.css new file mode 100644 index 0000000..9b7c895 --- /dev/null +++ b/assets/css/present.css @@ -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; } +} diff --git a/assets/css/public.css b/assets/css/public.css new file mode 100644 index 0000000..12b1de8 --- /dev/null +++ b/assets/css/public.css @@ -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; } +} diff --git a/assets/css/setup.css b/assets/css/setup.css new file mode 100644 index 0000000..e934a9e --- /dev/null +++ b/assets/css/setup.css @@ -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; + } +} diff --git a/assets/js/presenter.js b/assets/js/presenter.js new file mode 100644 index 0000000..e23a20b --- /dev/null +++ b/assets/js/presenter.js @@ -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
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]); + } + }); + } + +})(); diff --git a/assets/js/rosary.js b/assets/js/rosary.js new file mode 100644 index 0000000..2e6855a --- /dev/null +++ b/assets/js/rosary.js @@ -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 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 }; +})(); diff --git a/assets/js/setup.js b/assets/js/setup.js new file mode 100644 index 0000000..c8f68cc --- /dev/null +++ b/assets/js/setup.js @@ -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; + } + +})(); diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..a06fe47 --- /dev/null +++ b/composer.json @@ -0,0 +1,24 @@ +{ + "name": "pguzman/rosary-presenter", + "description": "Multi-user Rosary presentation app — slide-based prayer leader for the Rosary, novenas, and Divine Mercy Chaplet", + "type": "project", + "homepage": "https://loveandrosary.com", + "license": "proprietary", + "authors": [ + { + "name": "Philip Guzman III", + "email": "pguzman@theguzmanfamily.com" + } + ], + "require": { + "php": ">=8.0", + "ext-pdo": "*", + "ext-pdo_mysql": "*", + "ext-mbstring": "*" + }, + "config": { + "platform": { + "php": "8.0" + } + } +} diff --git a/config/db.example.php b/config/db.example.php new file mode 100644 index 0000000..2617bd4 --- /dev/null +++ b/config/db.example.php @@ -0,0 +1,95 @@ + PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, + ]; + $pdo = new PDO($dsn, DB_USER, DB_PASS, $options); + } + return $pdo; +} + +/** + * Read a value from site_settings table. + */ +function get_setting(string $key, string $default = ''): string { + static $cache = []; + if (isset($cache[$key])) return $cache[$key]; + try { + $st = get_pdo()->prepare('SELECT val FROM site_settings WHERE key_name = ?'); + $st->execute([$key]); + $row = $st->fetch(); + $cache[$key] = $row ? (string)$row['val'] : $default; + } catch (PDOException $e) { + $cache[$key] = $default; + } + return $cache[$key]; +} + +/** + * Save a value to site_settings table. + */ +function set_setting(string $key, string $value): void { + get_pdo()->prepare('INSERT INTO site_settings (key_name, val) VALUES (?,?) ON DUPLICATE KEY UPDATE val=?') + ->execute([$key, $value, $value]); +} + +/** + * Slugify a string for use in URLs. + */ +function slugify(string $text): string { + $text = mb_strtolower(trim($text)); + $text = preg_replace('/[^a-z0-9\s-]/', '', $text); + $text = preg_replace('/[\s-]+/', '-', $text); + return trim($text, '-') ?: 'session'; +} + +/** + * Generate a slug unique for a given user_id in sessions/novena_groups. + * $table: 'sessions' or 'novena_groups', $exclude_id: ID to exclude (for edits) + */ +function unique_slug(string $base, int $user_id, string $table = 'sessions', ?int $exclude_id = null): string { + $slug = slugify($base); + $pdo = get_pdo(); + $candidate = $slug; + $n = 2; + while (true) { + $sql = "SELECT COUNT(*) FROM {$table} WHERE slug = ? AND user_id = ?"; + $params = [$candidate, $user_id]; + if ($exclude_id !== null) { + $sql .= ' AND id != ?'; + $params[] = $exclude_id; + } + $st = $pdo->prepare($sql); + $st->execute($params); + if ((int)$st->fetchColumn() === 0) break; + $candidate = $slug . '-' . ($n++); + if ($n > 999) { $candidate = $slug . '-' . uniqid(); break; } + } + return $candidate; +} diff --git a/confirm.php b/confirm.php new file mode 100644 index 0000000..9f864e2 --- /dev/null +++ b/confirm.php @@ -0,0 +1,24 @@ +prepare('SELECT id FROM users WHERE confirm_token = ? AND email_confirmed = 0'); +$stmt->execute([$token]); +$user = $stmt->fetch(); + +if ($user) { + $pdo->prepare('UPDATE users SET email_confirmed = 1, confirm_token = NULL WHERE id = ?') + ->execute([$user['id']]); + header('Location: ' . BASE_URL . '/login?confirmed=1'); +} else { + // Token not found or already used + header('Location: ' . BASE_URL . '/login?confirm_error=1'); +} +exit; diff --git a/data/prayers.php b/data/prayers.php new file mode 100644 index 0000000..b132836 --- /dev/null +++ b/data/prayers.php @@ -0,0 +1,1213 @@ + unique string + * 'type' => 'cover'|'prayer'|'mystery'|'litany'|'closing' + * 'section' => logical grouping string + * 'title' => optional heading + * 'leader' => Leader-only text (empty string if none) + * 'all' => All-together text (empty string if none) + * 'bead' => null | 'small' | 'large' | 'crucifix' + * 'bead_index' => 0-based index into 60-bead sequence, or null + */ + +// --------------------------------------------------------------------------- +// OPENING PRAYERS +// --------------------------------------------------------------------------- +$opening = [ + [ + 'id' => 'sign_of_cross', + 'type' => 'prayer', + 'section' => 'opening', + 'title' => 'Sign of the Cross', + 'leader' => 'In the name of the Father, and of the Son, and of the Holy Spirit.', + 'all' => 'Amen.', + 'bead' => 'crucifix', + 'bead_index' => 0, + ], + [ + 'id' => 'apostles_creed', + 'type' => 'prayer', + 'section' => 'opening', + 'title' => 'Apostles\' Creed', + 'leader' => "I believe in God, the Father Almighty, Creator of Heaven and earth;\nand in Jesus Christ, His only Son, Our Lord,\nWho was conceived by the Holy Spirit, born of the Virgin Mary,\nsuffered under Pontius Pilate, was crucified, died, and was buried.\nHe descended into Hell; the third day He rose again from the dead;\nHe ascended into Heaven, and sitteth at the right hand of God, the Father Almighty;\nfrom thence He shall come to judge the living and the dead.", + 'all' => "I believe in the Holy Spirit, the Holy Catholic Church,\nthe communion of saints, the forgiveness of sins,\nthe resurrection of the body and life everlasting. Amen.", + 'bead' => 'crucifix', + 'bead_index' => 0, + ], + [ + 'id' => 'our_father_opening', + 'type' => 'prayer', + 'section' => 'opening', + 'title' => 'Our Father', + 'leader' => "Our Father, Who art in Heaven,\nhallowed be Thy name;\nThy kingdom come,\nThy will be done on earth as it is in Heaven.", + 'all' => "Give us this day our daily bread,\nand forgive us our trespasses,\nas we forgive those who trespass against us;\nand lead us not into temptation,\nbut deliver us from evil. Amen.", + 'bead' => 'large', + 'bead_index' => 1, + ], + [ + 'id' => 'hail_mary_faith', + 'type' => 'prayer', + 'section' => 'opening', + 'title' => 'Hail Mary — for an increase in Faith', + 'leader' => "Hail Mary, full of grace, the Lord is with thee;\nblessed art thou amongst women,\nand blessed is the fruit of thy womb, Jesus.", + 'all' => "Holy Mary, Mother of God,\npray for us sinners,\nnow and at the hour of our death. Amen.", + 'bead' => 'small', + 'bead_index' => 2, + ], + [ + 'id' => 'hail_mary_hope', + 'type' => 'prayer', + 'section' => 'opening', + 'title' => 'Hail Mary — for an increase in Hope', + 'leader' => "Hail Mary, full of grace, the Lord is with thee;\nblessed art thou amongst women,\nand blessed is the fruit of thy womb, Jesus.", + 'all' => "Holy Mary, Mother of God,\npray for us sinners,\nnow and at the hour of our death. Amen.", + 'bead' => 'small', + 'bead_index' => 3, + ], + [ + 'id' => 'hail_mary_charity', + 'type' => 'prayer', + 'section' => 'opening', + 'title' => 'Hail Mary — for an increase in Charity', + 'leader' => "Hail Mary, full of grace, the Lord is with thee;\nblessed art thou amongst women,\nand blessed is the fruit of thy womb, Jesus.", + 'all' => "Holy Mary, Mother of God,\npray for us sinners,\nnow and at the hour of our death. Amen.", + 'bead' => 'small', + 'bead_index' => 4, + ], + [ + 'id' => 'glory_be_opening', + 'type' => 'prayer', + 'section' => 'opening', + 'title' => 'Glory Be', + 'leader' => "Glory be to the Father, and to the Son, and to the Holy Spirit,", + 'all' => "as it was in the beginning, is now, and ever shall be,\nworld without end. Amen.", + 'bead' => null, + 'bead_index' => null, + ], +]; + +// --------------------------------------------------------------------------- +// DECADES — shared prayers used for each of the 5 decades +// --------------------------------------------------------------------------- + +/** + * Returns the array of slides for a single decade. + * $decade_num: 1-5 + * $mystery: the mystery announcement slide (already built) + * $our_father_bead_index: bead index for the Our Father of this decade + * $hail_mary_start_index: bead index for the first Hail Mary of this decade + */ +function build_decade_slides(int $decade_num, array $mystery_slide, int $our_father_bead_index, int $hail_mary_start_index): array { + $slides = []; + + // Mystery announcement + $slides[] = $mystery_slide; + + // Our Father + $slides[] = [ + 'id' => 'our_father_decade_' . $decade_num, + 'type' => 'prayer', + 'section' => 'decade_' . $decade_num, + 'title' => 'Our Father', + 'leader' => "Our Father, Who art in Heaven,\nhallowed be Thy name;\nThy kingdom come,\nThy will be done on earth as it is in Heaven.", + 'all' => "Give us this day our daily bread,\nand forgive us our trespasses,\nas we forgive those who trespass against us;\nand lead us not into temptation,\nbut deliver us from evil. Amen.", + 'bead' => 'large', + 'bead_index' => $our_father_bead_index, + ]; + + // 10 Hail Marys + for ($i = 1; $i <= 10; $i++) { + $slides[] = [ + 'id' => 'hail_mary_d' . $decade_num . '_' . $i, + 'type' => 'prayer', + 'section' => 'decade_' . $decade_num, + 'title' => 'Hail Mary', + 'leader' => "Hail Mary, full of grace, the Lord is with thee;\nblessed art thou amongst women,\nand blessed is the fruit of thy womb, Jesus.", + 'all' => "Holy Mary, Mother of God,\npray for us sinners,\nnow and at the hour of our death. Amen.", + 'bead' => 'small', + 'bead_index' => $hail_mary_start_index + ($i - 1), + ]; + } + + // Glory Be + $slides[] = [ + 'id' => 'glory_be_d' . $decade_num, + 'type' => 'prayer', + 'section' => 'decade_' . $decade_num, + 'title' => 'Glory Be', + 'leader' => "Glory be to the Father, and to the Son, and to the Holy Spirit,", + 'all' => "as it was in the beginning, is now, and ever shall be,\nworld without end. Amen.", + 'bead' => null, + 'bead_index' => null, + ]; + + // Fatima Prayer + $slides[] = [ + 'id' => 'fatima_d' . $decade_num, + 'type' => 'prayer', + 'section' => 'decade_' . $decade_num, + 'title' => 'Fatima Prayer', + 'leader' => "O my Jesus, forgive us our sins,\nsave us from the fires of hell,", + 'all' => "lead all souls to Heaven,\nespecially those who are in most need of Thy mercy.", + 'bead' => null, + 'bead_index' => null, + ]; + + return $slides; +} + +// --------------------------------------------------------------------------- +// MYSTERY SETS +// --------------------------------------------------------------------------- + +$mysteries = [ + 'sorrowful' => [ + [ + 'id' => 'mystery_sorrowful_1', + 'type' => 'mystery', + 'section' => 'mystery_announcement', + 'title' => 'The First Sorrowful Mystery', + 'leader' => 'The Agony in the Garden', + 'all' => 'Lord, help us to pray with greater fervor and devotion.', + 'bead' => null, + 'bead_index' => null, + ], + [ + 'id' => 'mystery_sorrowful_2', + 'type' => 'mystery', + 'section' => 'mystery_announcement', + 'title' => 'The Second Sorrowful Mystery', + 'leader' => 'The Scourging at the Pillar', + 'all' => 'Lord, help us to mortify our senses and overcome sin.', + 'bead' => null, + 'bead_index' => null, + ], + [ + 'id' => 'mystery_sorrowful_3', + 'type' => 'mystery', + 'section' => 'mystery_announcement', + 'title' => 'The Third Sorrowful Mystery', + 'leader' => 'The Crowning with Thorns', + 'all' => 'Lord, help us to have the courage to do what is right.', + 'bead' => null, + 'bead_index' => null, + ], + [ + 'id' => 'mystery_sorrowful_4', + 'type' => 'mystery', + 'section' => 'mystery_announcement', + 'title' => 'The Fourth Sorrowful Mystery', + 'leader' => 'The Carrying of the Cross', + 'all' => 'Lord, help us to bear our daily crosses with patience.', + 'bead' => null, + 'bead_index' => null, + ], + [ + 'id' => 'mystery_sorrowful_5', + 'type' => 'mystery', + 'section' => 'mystery_announcement', + 'title' => 'The Fifth Sorrowful Mystery', + 'leader' => 'The Crucifixion and Death of Our Lord', + 'all' => 'Lord, help us to die to ourselves and to grow in holiness.', + 'bead' => null, + 'bead_index' => null, + ], + ], + 'joyful' => [ + [ + 'id' => 'mystery_joyful_1', + 'type' => 'mystery', + 'section' => 'mystery_announcement', + 'title' => 'The First Joyful Mystery', + 'leader' => 'The Annunciation', + 'all' => 'Lord, help us to grow in humility and openness to God\'s will.', + 'bead' => null, + 'bead_index' => null, + ], + [ + 'id' => 'mystery_joyful_2', + 'type' => 'mystery', + 'section' => 'mystery_announcement', + 'title' => 'The Second Joyful Mystery', + 'leader' => 'The Visitation', + 'all' => 'Lord, help us to love our neighbors and serve those in need.', + 'bead' => null, + 'bead_index' => null, + ], + [ + 'id' => 'mystery_joyful_3', + 'type' => 'mystery', + 'section' => 'mystery_announcement', + 'title' => 'The Third Joyful Mystery', + 'leader' => 'The Nativity', + 'all' => 'Lord, help us to embrace the spirit of poverty and detachment.', + 'bead' => null, + 'bead_index' => null, + ], + [ + 'id' => 'mystery_joyful_4', + 'type' => 'mystery', + 'section' => 'mystery_announcement', + 'title' => 'The Fourth Joyful Mystery', + 'leader' => 'The Presentation in the Temple', + 'all' => 'Lord, help us to offer ourselves completely to God.', + 'bead' => null, + 'bead_index' => null, + ], + [ + 'id' => 'mystery_joyful_5', + 'type' => 'mystery', + 'section' => 'mystery_announcement', + 'title' => 'The Fifth Joyful Mystery', + 'leader' => 'The Finding of Jesus in the Temple', + 'all' => 'Lord, help us to seek God above all things.', + 'bead' => null, + 'bead_index' => null, + ], + ], + 'glorious' => [ + [ + 'id' => 'mystery_glorious_1', + 'type' => 'mystery', + 'section' => 'mystery_announcement', + 'title' => 'The First Glorious Mystery', + 'leader' => 'The Resurrection', + 'all' => 'Lord, help us to believe more deeply in Your Resurrection.', + 'bead' => null, + 'bead_index' => null, + ], + [ + 'id' => 'mystery_glorious_2', + 'type' => 'mystery', + 'section' => 'mystery_announcement', + 'title' => 'The Second Glorious Mystery', + 'leader' => 'The Ascension', + 'all' => 'Lord, help us to lift our hearts and minds to heaven.', + 'bead' => null, + 'bead_index' => null, + ], + [ + 'id' => 'mystery_glorious_3', + 'type' => 'mystery', + 'section' => 'mystery_announcement', + 'title' => 'The Third Glorious Mystery', + 'leader' => 'The Descent of the Holy Spirit', + 'all' => 'Lord, help us to be open to the gifts of the Holy Spirit.', + 'bead' => null, + 'bead_index' => null, + ], + [ + 'id' => 'mystery_glorious_4', + 'type' => 'mystery', + 'section' => 'mystery_announcement', + 'title' => 'The Fourth Glorious Mystery', + 'leader' => 'The Assumption of the Blessed Virgin Mary', + 'all' => 'Lord, help us to trust in Mary\'s powerful intercession.', + 'bead' => null, + 'bead_index' => null, + ], + [ + 'id' => 'mystery_glorious_5', + 'type' => 'mystery', + 'section' => 'mystery_announcement', + 'title' => 'The Fifth Glorious Mystery', + 'leader' => 'The Coronation of the Blessed Virgin Mary', + 'all' => 'Lord, help us to honor Mary as Queen of Heaven and Earth.', + 'bead' => null, + 'bead_index' => null, + ], + ], + 'luminous' => [ + [ + 'id' => 'mystery_luminous_1', + 'type' => 'mystery', + 'section' => 'mystery_announcement', + 'title' => 'The First Luminous Mystery', + 'leader' => 'The Baptism of Our Lord in the Jordan', + 'all' => 'Lord, help us to live our baptismal promises with joy.', + 'bead' => null, + 'bead_index' => null, + ], + [ + 'id' => 'mystery_luminous_2', + 'type' => 'mystery', + 'section' => 'mystery_announcement', + 'title' => 'The Second Luminous Mystery', + 'leader' => 'The Wedding Feast at Cana', + 'all' => 'Lord, help us to do whatever You tell us.', + 'bead' => null, + 'bead_index' => null, + ], + [ + 'id' => 'mystery_luminous_3', + 'type' => 'mystery', + 'section' => 'mystery_announcement', + 'title' => 'The Third Luminous Mystery', + 'leader' => 'The Proclamation of the Kingdom of God', + 'all' => 'Lord, help us to repent and believe in the Gospel.', + 'bead' => null, + 'bead_index' => null, + ], + [ + 'id' => 'mystery_luminous_4', + 'type' => 'mystery', + 'section' => 'mystery_announcement', + 'title' => 'The Fourth Luminous Mystery', + 'leader' => 'The Transfiguration', + 'all' => 'Lord, help us to behold Your glory and be transformed.', + 'bead' => null, + 'bead_index' => null, + ], + [ + 'id' => 'mystery_luminous_5', + 'type' => 'mystery', + 'section' => 'mystery_announcement', + 'title' => 'The Fifth Luminous Mystery', + 'leader' => 'The Institution of the Eucharist', + 'all' => 'Lord, help us to adore You more deeply in the Eucharist.', + 'bead' => null, + 'bead_index' => null, + ], + ], +]; + +// --------------------------------------------------------------------------- +// HAIL HOLY QUEEN (all occasions) +// --------------------------------------------------------------------------- +$hail_holy_queen = [ + [ + 'id' => 'hail_holy_queen', + 'type' => 'prayer', + 'section' => 'closing_prayers', + 'title' => 'Hail Holy Queen', + 'leader' => "Hail, Holy Queen, Mother of Mercy,\nour life, our sweetness and our hope.\nTo thee do we cry,\npoor banished children of Eve.\nTo thee do we send up our sighs,\nmourning and weeping in this valley of tears.\nTurn then, most gracious advocate,\nthine eyes of mercy toward us,\nand after this our exile\nshow unto us the blessed fruit of thy womb, Jesus.\nO clement, O loving,\nO sweet Virgin Mary.", + 'all' => '', + 'bead' => null, + 'bead_index' => null, + ], + [ + 'id' => 'hail_holy_queen_response', + 'type' => 'prayer', + 'section' => 'closing_prayers', + 'title' => 'Hail Holy Queen — Response', + 'leader' => 'Pray for us, O holy Mother of God.', + 'all' => 'That we may be made worthy of the promises of Christ.', + 'bead' => null, + 'bead_index' => null, + ], +]; + +// ROSARY CLOSING PRAYER (general_rosary and memorial only — omitted for novena_deceased) +// --------------------------------------------------------------------------- +$rosary_closing_prayer = [ + 'id' => 'rosary_closing_prayer', + 'type' => 'prayer', + 'section' => 'closing_prayers', + 'title' => 'Closing Prayer', + 'leader' => "Let us pray.\n\nO God, whose only-begotten Son, by His life, death, and resurrection,\nhas purchased for us the rewards of eternal life,\ngrant, we beseech Thee, that meditating upon these mysteries\nof the Most Holy Rosary of the Blessed Virgin Mary,\nwe may imitate what they contain and obtain what they promise,\nthrough the same Christ Our Lord.", + 'all' => 'Amen.', + 'bead' => null, + 'bead_index' => null, +]; + +// --------------------------------------------------------------------------- +// LITANY OF THE PASSION (Novena for Deceased) +// --------------------------------------------------------------------------- +$litany_passion = [ + [ + 'id' => 'litany_passion_intro', + 'type' => 'litany', + 'section' => 'litany_passion', + 'title' => 'Litany of the Passion', + 'leader' => 'My Jesus, through the torment of Your agony in the Garden of Gethsemane,', + 'all' => 'Have mercy on the soul of {name}.', + 'bead' => null, + 'bead_index' => null, + ], + [ + 'id' => 'litany_passion_2', + 'type' => 'litany', + 'section' => 'litany_passion', + 'title' => 'Litany of the Passion', + 'leader' => 'My Jesus, through the grief of Your betrayal and arrest,', + 'all' => 'Have mercy on the soul of {name}.', + 'bead' => null, + 'bead_index' => null, + ], + [ + 'id' => 'litany_passion_3', + 'type' => 'litany', + 'section' => 'litany_passion', + 'title' => 'Litany of the Passion', + 'leader' => 'My Jesus, through the pain of Your scourging at the pillar,', + 'all' => 'Have mercy on the soul of {name}.', + 'bead' => null, + 'bead_index' => null, + ], + [ + 'id' => 'litany_passion_4', + 'type' => 'litany', + 'section' => 'litany_passion', + 'title' => 'Litany of the Passion', + 'leader' => 'My Jesus, through the humiliation of Your crowning with thorns,', + 'all' => 'Have mercy on the soul of {name}.', + 'bead' => null, + 'bead_index' => null, + ], + [ + 'id' => 'litany_passion_5', + 'type' => 'litany', + 'section' => 'litany_passion', + 'title' => 'Litany of the Passion', + 'leader' => 'My Jesus, through the agony of Your carrying of the cross,', + 'all' => 'Have mercy on the soul of {name}.', + 'bead' => null, + 'bead_index' => null, + ], + [ + 'id' => 'litany_passion_6', + 'type' => 'litany', + 'section' => 'litany_passion', + 'title' => 'Litany of the Passion', + 'leader' => 'My Jesus, through the agony of Your crucifixion and death,', + 'all' => 'Have mercy on the soul of {name}.', + 'bead' => null, + 'bead_index' => null, + ], + [ + 'id' => 'litany_passion_7', + 'type' => 'litany', + 'section' => 'litany_passion', + 'title' => 'Litany of the Passion', + 'leader' => 'My Jesus, through Your precious blood shed on the cross,', + 'all' => 'Have mercy on the soul of {name}.', + 'bead' => null, + 'bead_index' => null, + ], + [ + 'id' => 'litany_passion_8', + 'type' => 'litany', + 'section' => 'litany_passion', + 'title' => 'Litany of the Passion', + 'leader' => 'My Jesus, through Your final commendation of Your spirit into the Father\'s hands,', + 'all' => 'Have mercy on the soul of {name}.', + 'bead' => null, + 'bead_index' => null, + ], + [ + 'id' => 'litany_passion_9', + 'type' => 'litany', + 'section' => 'litany_passion', + 'title' => 'Litany of the Passion', + 'leader' => 'My Jesus, through the silence of Your entombment,', + 'all' => 'Have mercy on the soul of {name}.', + 'bead' => null, + 'bead_index' => null, + ], + [ + 'id' => 'litany_passion_10', + 'type' => 'litany', + 'section' => 'litany_passion', + 'title' => 'Litany of the Passion', + 'leader' => 'My Jesus, through the joy of Your Resurrection,', + 'all' => 'Have mercy on the soul of {name}.', + 'bead' => null, + 'bead_index' => null, + ], + [ + 'id' => 'litany_passion_11', + 'type' => 'litany', + 'section' => 'litany_passion', + 'title' => 'Litany of the Passion', + 'leader' => 'My Jesus, through the glory of Your Ascension and promise of return,', + 'all' => 'Have mercy on the soul of {name}.', + 'bead' => null, + 'bead_index' => null, + ], +]; + +// --------------------------------------------------------------------------- +// NOVENA PRAYERS — Days 1–9 +// --------------------------------------------------------------------------- +$novena_prayers = [ + 1 => [ + 'id' => 'novena_day_1', + 'type' => 'prayer', + 'section' => 'novena', + 'title' => 'Novena Prayer — Day 1', + 'leader' => "Let us pray.\n\nO God of all consolation,\nYou do not willingly grieve or afflict the children of men.\nLook with pity on the suffering of this family in their loss.\nGrant that they may not sorrow as those who have no hope,\nbut through their tears may look up to You,\nthe source of all consolation.\n\nLord, we pray for {name},\nwhom You have called from this life to Yourself.\nGrant {pronoun_obj} Your peace and let perpetual light shine upon {pronoun_obj}.\nComfort those who mourn\nand give them the sure hope of eternal life.\nWe ask this through Christ our Lord.", + 'all' => 'Amen.', + 'bead' => null, + 'bead_index' => null, + ], + 2 => [ + 'id' => 'novena_day_2', + 'type' => 'prayer', + 'section' => 'novena', + 'title' => 'Novena Prayer — Day 2', + 'leader' => "Let us pray.\n\nO Lord, You are the resurrection and the life.\nWhoever believes in You, though {pronoun} die, yet shall {pronoun} live.\nWe believe and trust in Your promise.\n\nWe pray for {name},\nwho believed in You and received Your sacraments.\nWelcome {pronoun_obj} now into the fullness of Your eternal kingdom,\nwhere every tear is wiped away\nand sorrow is turned into eternal joy.\nBless this family with Your comfort\nand give them faith that sees beyond the grave.\nWe ask this through Christ our Lord.", + 'all' => 'Amen.', + 'bead' => null, + 'bead_index' => null, + ], + 3 => [ + 'id' => 'novena_day_3', + 'type' => 'prayer', + 'section' => 'novena', + 'title' => 'Novena Prayer — Day 3', + 'leader' => "Let us pray.\n\nLord Jesus Christ, by Your own three days in the tomb,\nYou hallowed the graves of all who believe in You.\nMay {name} sleep here in peace until You awaken {pronoun_obj} to glory,\nfor You are the resurrection and the life.\nThen {pronoun} shall see You face to face\nand in Your light shall see light\nand know the splendor of God.\nFor You live and reign forever and ever.", + 'all' => 'Amen.', + 'bead' => null, + 'bead_index' => null, + ], + 4 => [ + 'id' => 'novena_day_4', + 'type' => 'prayer', + 'section' => 'novena', + 'title' => 'Novena Prayer — Day 4', + 'leader' => "Let us pray.\n\nMerciful Father,\nYou have revealed to us that nothing unclean shall enter Your kingdom,\nand that Your love is a refining fire\nthat purifies all who seek You.\n\nWe believe that the souls of the faithful,\ncleansed of all that is imperfect,\ncome at last to the fullness of Your presence.\nAnd so we pray with confidence for {name},\ntrusting in the great mercy You have shown\nto all who have died in Your friendship.\n\nBy the prayers of Your holy Church,\nby the sacrifice of Your Son offered daily on her altars,\nand by our own humble intercession here,\nmay {name} be swiftly brought\nthrough every shadow of imperfection\ninto the radiance of Your eternal light.\n\nBind us together — the living and the dead —\nin the one Body of Christ,\nuntil that day when all who love You\nshall stand together before Your face.\nWe ask this through Christ our Lord.", + 'all' => 'Amen.', + 'bead' => null, + 'bead_index' => null, + ], + 5 => [ + 'id' => 'novena_day_5', + 'type' => 'prayer', + 'section' => 'novena', + 'title' => 'Novena Prayer — Day 5', + 'leader' => "Let us pray.\n\nO Lord, support us all the day long of this troublous life\nuntil the shadows lengthen and the evening comes,\nand the busy world is hushed,\nand the fever of life is over\nand our work is done.\nThen in Your mercy grant us a safe lodging,\nand a holy rest,\nand peace at the last.\n\nGrant this mercy now to {name},\nwho has finished {pronoun_poss} earthly journey.\nReceive {pronoun_obj} into Your rest\nand into Your peace forever.", + 'all' => 'Amen.', + 'bead' => null, + 'bead_index' => null, + ], + 6 => [ + 'id' => 'novena_day_6', + 'type' => 'prayer', + 'section' => 'novena', + 'title' => 'Novena Prayer — Day 6', + 'leader' => "Let us pray.\n\nInto Your hands, O Lord,\nwe humbly entrust {name}.\nIn this life You embraced {pronoun_obj} with Your tender love;\ndeliver {pronoun_obj} now from every evil\nand bid {pronoun_obj} enter eternal rest.\n\nThe old order has passed away:\nwelcome {pronoun_obj} then into paradise,\nwhere there will be no sorrow, no weeping or pain,\nbut fullness of peace and joy\nwith Your Son and the Holy Spirit\nforever and ever.", + 'all' => 'Amen.', + 'bead' => null, + 'bead_index' => null, + ], + 7 => [ + 'id' => 'novena_day_7', + 'type' => 'prayer', + 'section' => 'novena', + 'title' => 'Novena Prayer — Day 7', + 'leader' => "Let us pray.\n\nGod, our Father,\nYour power brings us to birth,\nYour providence guides our lives,\nand by Your command we return to dust.\n\nLord, those who die still live in Your presence;\ntheir lives change but do not end.\nWe pray in hope for {name},\nfor all the dead known to us,\nand for all the forgotten dead.\n\nAs we struggle with the mystery of death,\nlet Your Spirit comfort and console us.\nMay we also be consoled by the truth\nthat {name} lives forever in Your love.", + 'all' => 'Amen.', + 'bead' => null, + 'bead_index' => null, + ], + 8 => [ + 'id' => 'novena_day_8', + 'type' => 'prayer', + 'section' => 'novena', + 'title' => 'Novena Prayer — Day 8', + 'leader' => "Let us pray.\n\nO God, the glory of the faithful\nand the life of the just,\nby the death and resurrection of whose Son\nwe have been redeemed,\nlook mercifully on Your departed servant {name},\nthat, just as {pronoun} shared in the mystery of our Savior's passion,\nso {pronoun} may be a partaker of His resurrection.\nWho lives and reigns with You\nin the unity of the Holy Spirit,\none God, for ever and ever.", + 'all' => 'Amen.', + 'bead' => null, + 'bead_index' => null, + ], + 9 => [ + 'id' => 'novena_day_9', + 'type' => 'prayer', + 'section' => 'novena', + 'title' => 'Novena Prayer — Day 9', + 'leader' => "Let us pray.\n\nLord God,\nYou are the glory of believers and the life of the just.\nYour Son redeemed us by dying and rising to life again.\nOur departed {name} believed in the mystery of our resurrection.\nLet {pronoun_obj} share the joys and bliss of the risen life\nthat Christ gives to His loved ones.\n\nWe thank You for the gift of {name}'s life among us.\nWe thank You for the love {pronoun} gave,\nthe faith {pronoun} lived,\nand the hope with which {pronoun} departed.\nBring us all one day to that eternal reunion in Your kingdom.", + 'all' => 'Amen.', + 'bead' => null, + 'bead_index' => null, + ], +]; + +// --------------------------------------------------------------------------- +// LITANY FOR THE DEPARTED +// --------------------------------------------------------------------------- +$litany_departed = [ + [ + 'id' => 'litany_departed_kyrie', + 'type' => 'litany', + 'section' => 'litany_departed', + 'title' => 'Litany for the Departed', + 'leader' => 'Lord, have mercy.', + 'all' => 'Lord, have mercy.', + 'bead' => null, + 'bead_index' => null, + ], + [ + 'id' => 'litany_departed_christe', + 'type' => 'litany', + 'section' => 'litany_departed', + 'title' => 'Litany for the Departed', + 'leader' => 'Christ, have mercy.', + 'all' => 'Christ, have mercy.', + 'bead' => null, + 'bead_index' => null, + ], + [ + 'id' => 'litany_departed_lord', + 'type' => 'litany', + 'section' => 'litany_departed', + 'title' => 'Litany for the Departed', + 'leader' => 'Lord, have mercy.', + 'all' => 'Lord, have mercy.', + 'bead' => null, + 'bead_index' => null, + ], + [ + 'id' => 'litany_departed_mary', + 'type' => 'litany', + 'section' => 'litany_departed', + 'title' => 'Litany for the Departed', + 'leader' => 'Holy Mary, Mother of God,', + 'all' => 'Pray for {pronoun_obj}.', + 'bead' => null, + 'bead_index' => null, + ], + [ + 'id' => 'litany_departed_michael', + 'type' => 'litany', + 'section' => 'litany_departed', + 'title' => 'Litany for the Departed', + 'leader' => 'Saint Michael,', + 'all' => 'Pray for {pronoun_obj}.', + 'bead' => null, + 'bead_index' => null, + ], + [ + 'id' => 'litany_departed_angels', + 'type' => 'litany', + 'section' => 'litany_departed', + 'title' => 'Litany for the Departed', + 'leader' => 'Holy angels of God,', + 'all' => 'Pray for {pronoun_obj}.', + 'bead' => null, + 'bead_index' => null, + ], + [ + 'id' => 'litany_departed_john', + 'type' => 'litany', + 'section' => 'litany_departed', + 'title' => 'Litany for the Departed', + 'leader' => 'Saint John the Baptist,', + 'all' => 'Pray for {pronoun_obj}.', + 'bead' => null, + 'bead_index' => null, + ], + [ + 'id' => 'litany_departed_joseph', + 'type' => 'litany', + 'section' => 'litany_departed', + 'title' => 'Litany for the Departed', + 'leader' => 'Saint Joseph,', + 'all' => 'Pray for {pronoun_obj}.', + 'bead' => null, + 'bead_index' => null, + ], + [ + 'id' => 'litany_departed_peter_paul', + 'type' => 'litany', + 'section' => 'litany_departed', + 'title' => 'Litany for the Departed', + 'leader' => 'Saints Peter and Paul,', + 'all' => 'Pray for {pronoun_obj}.', + 'bead' => null, + 'bead_index' => null, + ], + [ + 'id' => 'litany_departed_all_saints', + 'type' => 'litany', + 'section' => 'litany_departed', + 'title' => 'Litany for the Departed', + 'leader' => 'All you saints of God,', + 'all' => 'Pray for {pronoun_obj}.', + 'bead' => null, + 'bead_index' => null, + ], + [ + 'id' => 'litany_departed_deliver_death', + 'type' => 'litany', + 'section' => 'litany_departed', + 'title' => 'Litany for the Departed', + 'leader' => 'From all evil,', + 'all' => 'Deliver {pronoun_obj}, O Lord.', + 'bead' => null, + 'bead_index' => null, + ], + [ + 'id' => 'litany_departed_deliver_sin', + 'type' => 'litany', + 'section' => 'litany_departed', + 'title' => 'Litany for the Departed', + 'leader' => 'From the power of darkness,', + 'all' => 'Deliver {pronoun_obj}, O Lord.', + 'bead' => null, + 'bead_index' => null, + ], + [ + 'id' => 'litany_departed_deliver_judgment', + 'type' => 'litany', + 'section' => 'litany_departed', + 'title' => 'Litany for the Departed', + 'leader' => 'On the day of judgment,', + 'all' => 'Deliver {pronoun_obj}, O Lord.', + 'bead' => null, + 'bead_index' => null, + ], + [ + 'id' => 'litany_departed_agnus_1', + 'type' => 'litany', + 'section' => 'litany_departed', + 'title' => 'Litany for the Departed', + 'leader' => 'Lamb of God, who takes away the sins of the world,', + 'all' => 'Grant {pronoun_obj} rest.', + 'bead' => null, + 'bead_index' => null, + ], + [ + 'id' => 'litany_departed_agnus_2', + 'type' => 'litany', + 'section' => 'litany_departed', + 'title' => 'Litany for the Departed', + 'leader' => 'Lamb of God, who takes away the sins of the world,', + 'all' => 'Grant {pronoun_obj} rest.', + 'bead' => null, + 'bead_index' => null, + ], + [ + 'id' => 'litany_departed_agnus_3', + 'type' => 'litany', + 'section' => 'litany_departed', + 'title' => 'Litany for the Departed', + 'leader' => 'Lamb of God, who takes away the sins of the world,', + 'all' => 'Grant {pronoun_obj} eternal rest.', + 'bead' => null, + 'bead_index' => null, + ], + [ + 'id' => 'litany_departed_eternal_rest_1', + 'type' => 'litany', + 'section' => 'litany_departed', + 'title' => 'Eternal Rest', + 'leader' => "Eternal rest grant unto {pronoun_obj}, O Lord,", + 'all' => "and let perpetual light shine upon {pronoun_obj}.", + 'bead' => null, + 'bead_index' => null, + ], + [ + 'id' => 'litany_departed_eternal_rest_2', + 'type' => 'litany', + 'section' => 'litany_departed', + 'title' => 'Eternal Rest', + 'leader' => "May {pronoun_poss} soul and the souls of all the faithful departed,\nthrough the mercy of God, rest in peace.", + 'all' => 'Amen.', + 'bead' => null, + 'bead_index' => null, + ], + [ + 'id' => 'litany_departed_concluding', + 'type' => 'litany', + 'section' => 'litany_departed', + 'title' => 'Concluding Prayer', + 'leader' => "Let us pray.\n\nO God, whose nature it is always to have mercy and to spare,\nwe humbly entreat You on behalf of Your servant {name},\nwhom You have this day called out of this world.\nDo not deliver {pronoun_obj} into the hands of the enemy,\nnor forget {pronoun_obj} forever,\nbut command Your holy angels to receive {pronoun_obj}\nand take {pronoun_obj} to paradise,\nthat, having believed and hoped in You,\n{pronoun} may not undergo the pains of hell,\nbut may possess everlasting joys.\nThrough Christ our Lord.", + 'all' => 'Amen.', + 'bead' => null, + 'bead_index' => null, + ], +]; + +// --------------------------------------------------------------------------- +// DIVINE MERCY NOVENA — Pre-Chaplet Opening Prayer (said before beads) +// --------------------------------------------------------------------------- +$divine_mercy_opening = [ + [ + 'id' => 'dm_opening', + 'type' => 'prayer', + 'section' => 'divine_mercy_opening', + 'title' => 'Opening Prayer', + 'leader' => "You expired, Jesus, but the source of life gushed forth for souls,\nand the ocean of mercy opened up for the whole world.\nO Fount of Life, unfathomable Divine Mercy,\nenvelop the whole world and empty Yourself out upon us.", + 'all' => '', + 'bead' => null, + 'bead_index' => null, + ], + // "O Blood and Water" said 3 times — repeat badge auto-detected by matching leader+all + [ + 'id' => 'dm_blood_water_1', + 'type' => 'prayer', + 'section' => 'divine_mercy_opening', + 'title' => 'O Blood and Water', + 'leader' => "O Blood and Water, which gushed forth from the Heart of Jesus\nas a fountain of Mercy for us,", + 'all' => 'I trust in You!', + 'bead' => null, + 'bead_index' => null, + ], + [ + 'id' => 'dm_blood_water_2', + 'type' => 'prayer', + 'section' => 'divine_mercy_opening', + 'title' => 'O Blood and Water', + 'leader' => "O Blood and Water, which gushed forth from the Heart of Jesus\nas a fountain of Mercy for us,", + 'all' => 'I trust in You!', + 'bead' => null, + 'bead_index' => null, + ], + [ + 'id' => 'dm_blood_water_3', + 'type' => 'prayer', + 'section' => 'divine_mercy_opening', + 'title' => 'O Blood and Water', + 'leader' => "O Blood and Water, which gushed forth from the Heart of Jesus\nas a fountain of Mercy for us,", + 'all' => 'I trust in You!', + 'bead' => null, + 'bead_index' => null, + ], +]; + +// --------------------------------------------------------------------------- +// DIVINE MERCY NOVENA — Day Intentions & Prayers (Days 1–9) +// From St. Faustina's Diary — each day: [intention slide, prayer slide] +// --------------------------------------------------------------------------- +$divine_mercy_novena_prayers = [ + 1 => [ + [ + 'id' => 'dm_intention_day_1', + 'type' => 'prayer', + 'section' => 'divine_mercy_novena', + 'title' => 'Day 1 — All Mankind', + 'leader' => "Today bring to Me all mankind, especially all sinners,\nand immerse them in the ocean of My mercy.\nIn this way you will console Me in the bitter grief\ninto which the loss of souls plunges Me.", + 'all' => '', + 'bead' => null, + 'bead_index' => null, + ], + [ + 'id' => 'dm_prayer_day_1', + 'type' => 'prayer', + 'section' => 'divine_mercy_novena', + 'title' => 'Day 1 Prayer', + 'leader' => "Most Merciful Jesus, whose very nature it is to have compassion on us\nand to forgive us, do not look upon our sins\nbut upon our trust which we place in Your infinite goodness.\nReceive us all into the abode of Your Most Compassionate Heart,\nand never let us escape from It.\nWe beg this of You by Your love which unites You\nto the Father and the Holy Spirit.", + 'all' => 'Amen.', + 'bead' => null, + 'bead_index' => null, + ], + ], + 2 => [ + [ + 'id' => 'dm_intention_day_2', + 'type' => 'prayer', + 'section' => 'divine_mercy_novena', + 'title' => 'Day 2 — Priests and Religious', + 'leader' => "Today bring to Me the Souls of Priests and Religious,\nand immerse them in My unfathomable mercy.\nIt was they who gave Me strength to endure My bitter Passion.\nThrough them as through channels My mercy flows out\nupon mankind.", + 'all' => '', + 'bead' => null, + 'bead_index' => null, + ], + [ + 'id' => 'dm_prayer_day_2', + 'type' => 'prayer', + 'section' => 'divine_mercy_novena', + 'title' => 'Day 2 Prayer', + 'leader' => "Most Merciful Jesus, from whom comes all that is good,\nincrease Your grace in men and women consecrated to Your service,\nthat they may perform worthy works of mercy;\nand that all who see them may glorify the Father of Mercy\nwho is in heaven.", + 'all' => 'Amen.', + 'bead' => null, + 'bead_index' => null, + ], + ], + 3 => [ + [ + 'id' => 'dm_intention_day_3', + 'type' => 'prayer', + 'section' => 'divine_mercy_novena', + 'title' => 'Day 3 — All Devout and Faithful Souls', + 'leader' => "Today bring to Me all Devout and Faithful Souls,\nand immerse them in the ocean of My mercy.\nThese souls brought Me consolation on the Way of the Cross.\nThey were that drop of consolation in the midst of an ocean of bitterness.", + 'all' => '', + 'bead' => null, + 'bead_index' => null, + ], + [ + 'id' => 'dm_prayer_day_3', + 'type' => 'prayer', + 'section' => 'divine_mercy_novena', + 'title' => 'Day 3 Prayer', + 'leader' => "Most Merciful Jesus, from the treasury of Your mercy,\nYou impart Your graces in great abundance to each and all.\nReceive us into the abode of Your Most Compassionate Heart\nand never let us escape from It.\nWe beg this grace of You by that most wondrous love\nfor the heavenly Father with which Your Heart burns so fiercely.", + 'all' => 'Amen.', + 'bead' => null, + 'bead_index' => null, + ], + ], + 4 => [ + [ + 'id' => 'dm_intention_day_4', + 'type' => 'prayer', + 'section' => 'divine_mercy_novena', + 'title' => 'Day 4 — Those Who Do Not Believe', + 'leader' => "Today bring to Me those who do not believe in God\nand those who do not yet know Me.\nI was thinking also of them during My bitter Passion,\nand their future zeal comforted My Heart.\nImmerse them in the ocean of My mercy.", + 'all' => '', + 'bead' => null, + 'bead_index' => null, + ], + [ + 'id' => 'dm_prayer_day_4', + 'type' => 'prayer', + 'section' => 'divine_mercy_novena', + 'title' => 'Day 4 Prayer', + 'leader' => "Most Compassionate Jesus, You are the Light of the whole world.\nReceive into the abode of Your Most Compassionate Heart\nthe souls of those who do not believe in God\nand of those who as yet do not know You.\nLet the rays of Your grace enlighten them\nthat they, too, together with us,\nmay extol Your wonderful mercy;\nand do not let them escape from the abode\nwhich is Your Most Compassionate Heart.", + 'all' => 'Amen.', + 'bead' => null, + 'bead_index' => null, + ], + ], + 5 => [ + [ + 'id' => 'dm_intention_day_5', + 'type' => 'prayer', + 'section' => 'divine_mercy_novena', + 'title' => 'Day 5 — Separated Brethren', + 'leader' => "Today bring to Me the Souls of Heretics and Schismatics,\nand immerse them in the ocean of My mercy.\nDuring My bitter Passion they tore at My Body and Heart,\nthat is, My Church.\nAs they return to unity with the Church\nMy wounds heal and in this way they alleviate My Passion.", + 'all' => '', + 'bead' => null, + 'bead_index' => null, + ], + [ + 'id' => 'dm_prayer_day_5', + 'type' => 'prayer', + 'section' => 'divine_mercy_novena', + 'title' => 'Day 5 Prayer', + 'leader' => "Most Merciful Jesus, Goodness Itself,\nYou do not refuse light to those who seek it of You.\nReceive into the abode of Your Most Compassionate Heart\nthe souls of heretics and schismatics\nand draw them by Your light into the unity of the Church,\nand do not let them escape from the abode\nof Your Most Compassionate Heart;\nbut bring it about that they, too,\ncome to glorify the generosity of Your mercy.", + 'all' => 'Amen.', + 'bead' => null, + 'bead_index' => null, + ], + ], + 6 => [ + [ + 'id' => 'dm_intention_day_6', + 'type' => 'prayer', + 'section' => 'divine_mercy_novena', + 'title' => 'Day 6 — Meek and Humble Souls', + 'leader' => "Today bring to Me the Meek and Humble Souls\nand the Souls of Little Children,\nand immerse them in My mercy.\nThese souls most closely resemble My Heart.\nThey strengthened Me during My bitter agony.\nI saw them as earthly Angels,\nwho will keep vigil at My altars.\nI pour out upon them whole torrents of grace.\nOnly the humble soul is capable of receiving My grace.\nI favor humble souls with My confidence.", + 'all' => '', + 'bead' => null, + 'bead_index' => null, + ], + [ + 'id' => 'dm_prayer_day_6', + 'type' => 'prayer', + 'section' => 'divine_mercy_novena', + 'title' => 'Day 6 Prayer', + 'leader' => "Most Merciful Jesus, You yourself have said,\n'Learn from Me for I am meek and humble of heart.'\nReceive into the abode of Your Most Compassionate Heart\nall meek and humble souls and the souls of little children.\nThese souls send all heaven into ecstasy\nand they are the heavenly Father's favorites.\nThey are a sweet-smelling bouquet before the throne of God;\nGod himself takes delight in their fragrance.\nThese souls have a permanent abode\nin Your Most Compassionate Heart, O Jesus,\nand they unceasingly sing out a hymn of love and mercy.", + 'all' => 'Amen.', + 'bead' => null, + 'bead_index' => null, + ], + ], + 7 => [ + [ + 'id' => 'dm_intention_day_7', + 'type' => 'prayer', + 'section' => 'divine_mercy_novena', + 'title' => 'Day 7 — Souls Who Venerate God\'s Mercy', + 'leader' => "Today bring to Me the Souls\nwho especially venerate and glorify My Mercy,\nand immerse them in My mercy.\nThese souls sorrowed most over My Passion\nand entered most deeply into My spirit.\nThey are living reflections of My compassionate Heart.\nThese souls will shine with a special brightness\nin the next life.\nNot one of them will go into the fire of hell.\nI shall particularly defend each one of them\nat the hour of death.", + 'all' => '', + 'bead' => null, + 'bead_index' => null, + ], + [ + 'id' => 'dm_prayer_day_7', + 'type' => 'prayer', + 'section' => 'divine_mercy_novena', + 'title' => 'Day 7 Prayer', + 'leader' => "Most Merciful Jesus, whose Heart is Love Itself,\nreceive into the abode of Your Most Compassionate Heart\nthe souls of those who particularly extol\nand venerate the greatness of Your mercy.\nThese souls are mighty with the very power of God Himself.\nIn the midst of all afflictions and adversities they go forward,\nconfident of Your mercy;\nand united to You, O Jesus,\nthey carry all mankind on their shoulders.\nThese souls will not be judged severely,\nbut Your mercy will embrace them as they depart from this life.", + 'all' => 'Amen.', + 'bead' => null, + 'bead_index' => null, + ], + ], + 8 => [ + [ + 'id' => 'dm_intention_day_8', + 'type' => 'prayer', + 'section' => 'divine_mercy_novena', + 'title' => 'Day 8 — Souls in Purgatory', + 'leader' => "Today bring to Me the Souls\nthat are detained in Purgatory,\nand immerse them in the abyss of My mercy.\nLet the torrents of My Blood cool down their scorching flames.\nAll these souls are greatly loved by Me.\nThey are making retribution to My justice.\nIt is in your power to bring them relief.\nDraw all the indulgences from the treasury of My Church\nand offer them on their behalf.", + 'all' => '', + 'bead' => null, + 'bead_index' => null, + ], + [ + 'id' => 'dm_prayer_day_8', + 'type' => 'prayer', + 'section' => 'divine_mercy_novena', + 'title' => 'Day 8 Prayer', + 'leader' => "Most Merciful Jesus, You Yourself have said\nthat You desire mercy;\nso I bring into the abode of Your Most Compassionate Heart\nthe souls in Purgatory,\nsouls who are very dear to You,\nand yet, who must make retribution to Your justice.\nMay the streams of Blood and Water\nwhich gushed forth from Your Heart\nput out the flames of Purgatory,\nthat there, too, the power of Your mercy may be celebrated.", + 'all' => 'Amen.', + 'bead' => null, + 'bead_index' => null, + ], + ], + 9 => [ + [ + 'id' => 'dm_intention_day_9', + 'type' => 'prayer', + 'section' => 'divine_mercy_novena', + 'title' => 'Day 9 — Lukewarm Souls', + 'leader' => "Today bring to Me the Souls\nwho have become Lukewarm,\nand immerse them in the abyss of My mercy.\nThese souls wound My Heart most painfully.\nMy soul suffered the most dreadful loathing in the Garden of Olives\nbecause of lukewarm souls.\nThey were the reason I cried out:\n'Father, take this cup away from Me, if it be Your will.'\nFor them, the last hope of salvation\nis to run to My mercy.", + 'all' => '', + 'bead' => null, + 'bead_index' => null, + ], + [ + 'id' => 'dm_prayer_day_9', + 'type' => 'prayer', + 'section' => 'divine_mercy_novena', + 'title' => 'Day 9 Prayer', + 'leader' => "Most Compassionate Jesus, You are Compassion Itself.\nI bring lukewarm souls into the abode\nof Your Most Compassionate Heart.\nIn this fire of Your pure love,\nlet these tepid souls, who, like corpses,\nfilled You with such deep loathing,\nbe once again set aflame.\nO Most Compassionate Jesus,\nexercise the omnipotence of Your mercy\nand draw them into the very ardor of Your love,\nand bestow upon them the gift of holy love,\nfor nothing is beyond Your power.", + 'all' => 'Amen.', + 'bead' => null, + 'bead_index' => null, + ], + ], +]; + +// --------------------------------------------------------------------------- +// DIVINE MERCY CHAPLET — Opening prayers (on stem beads before the 5 decades) +// --------------------------------------------------------------------------- +$divine_mercy_chaplet_opening = [ + [ + 'id' => 'dm_sign_of_cross', + 'type' => 'prayer', + 'section' => 'dm_chaplet_opening', + 'title' => 'Sign of the Cross', + 'leader' => 'In the name of the Father, and of the Son, and of the Holy Spirit.', + 'all' => 'Amen.', + 'bead' => 'crucifix', + 'bead_index' => 0, + ], + [ + 'id' => 'dm_our_father_opening', + 'type' => 'prayer', + 'section' => 'dm_chaplet_opening', + 'title' => 'Our Father', + 'leader' => "Our Father, Who art in Heaven,\nhallowed be Thy name;\nThy kingdom come,\nThy will be done on earth as it is in Heaven.", + 'all' => "Give us this day our daily bread,\nand forgive us our trespasses,\nas we forgive those who trespass against us;\nand lead us not into temptation,\nbut deliver us from evil. Amen.", + 'bead' => 'large', + 'bead_index' => 1, + ], + [ + 'id' => 'dm_hail_mary_opening', + 'type' => 'prayer', + 'section' => 'dm_chaplet_opening', + 'title' => 'Hail Mary', + 'leader' => "Hail Mary, full of grace, the Lord is with thee;\nblessed art thou amongst women,\nand blessed is the fruit of thy womb, Jesus.", + 'all' => "Holy Mary, Mother of God,\npray for us sinners,\nnow and at the hour of our death. Amen.", + 'bead' => 'small', + 'bead_index' => 2, + ], + [ + 'id' => 'dm_apostles_creed', + 'type' => 'prayer', + 'section' => 'dm_chaplet_opening', + 'title' => "Apostles' Creed", + 'leader' => "I believe in God, the Father Almighty, Creator of Heaven and earth;\nand in Jesus Christ, His only Son, Our Lord,\nWho was conceived by the Holy Spirit, born of the Virgin Mary,\nsuffered under Pontius Pilate, was crucified, died, and was buried.\nHe descended into Hell; the third day He rose again from the dead;\nHe ascended into Heaven, and sitteth at the right hand of God, the Father Almighty;\nfrom thence He shall come to judge the living and the dead.", + 'all' => "I believe in the Holy Spirit, the Holy Catholic Church,\nthe communion of saints, the forgiveness of sins,\nthe resurrection of the body and life everlasting. Amen.", + 'bead' => 'small', + 'bead_index' => 3, + ], +]; + +// --------------------------------------------------------------------------- +// DIVINE MERCY CHAPLET — Closing (Holy God × 3; repeat badge auto-detected) +// --------------------------------------------------------------------------- +$divine_mercy_chaplet_close = [ + [ + 'id' => 'dm_holy_god_1', + 'type' => 'prayer', + 'section' => 'dm_chaplet_close', + 'title' => 'Holy God', + 'leader' => 'Holy God, Holy Mighty One, Holy Immortal One,', + 'all' => 'have mercy on us and on the whole world.', + 'bead' => null, + 'bead_index' => null, + ], + [ + 'id' => 'dm_holy_god_2', + 'type' => 'prayer', + 'section' => 'dm_chaplet_close', + 'title' => 'Holy God', + 'leader' => 'Holy God, Holy Mighty One, Holy Immortal One,', + 'all' => 'have mercy on us and on the whole world.', + 'bead' => null, + 'bead_index' => null, + ], + [ + 'id' => 'dm_holy_god_3', + 'type' => 'prayer', + 'section' => 'dm_chaplet_close', + 'title' => 'Holy God', + 'leader' => 'Holy God, Holy Mighty One, Holy Immortal One,', + 'all' => 'have mercy on us and on the whole world.', + 'bead' => null, + 'bead_index' => null, + ], +]; + +// --------------------------------------------------------------------------- +// CLOSING SLIDES — keyed by occasion +// --------------------------------------------------------------------------- +$closing = [ + 'novena_deceased' => [ + 'id' => 'closing_novena', + 'type' => 'closing', + 'section' => 'closing', + 'title' => '{name}', + 'subtitle' => '{subject_dates}', + 'leader' => '', + 'all' => "This concludes the Rosary for {name}.\nThank you for praying with us.\n\nMay {pronoun_poss} soul rest in peace. Amen.", + 'bead' => null, + 'bead_index' => null, + 'photo_path' => null, // filled in by build_slides() + ], + 'general_rosary' => [ + 'id' => 'closing_general', + 'type' => 'closing', + 'section' => 'closing', + 'title' => 'End of the Rosary', + 'subtitle' => '', + 'leader' => '', + 'all' => "Most Sacred Heart of Jesus,\nhave mercy on us.\n\nImmaculate Heart of Mary,\npray for us.", + 'bead' => null, + 'bead_index' => null, + 'photo_path' => null, + ], + 'memorial' => [ + 'id' => 'closing_memorial', + 'type' => 'closing', + 'section' => 'closing', + 'title' => '{name}', + 'subtitle' => '{subject_dates}', + 'leader' => '', + 'all' => "This concludes the Rosary for {name}.\nThank you for praying with us.\n\nMay {pronoun_poss} soul rest in peace. Amen.", + 'bead' => null, + 'bead_index' => null, + 'photo_path' => null, // filled in by build_slides() + ], + 'divine_mercy_novena' => [ + 'id' => 'closing_divine_mercy', + 'type' => 'closing', + 'section' => 'closing', + 'title' => 'Divine Mercy Novena', + 'subtitle' => '', + 'leader' => '', + 'all' => "Jesus, I trust in You.\n\nThank you for praying with us.", + 'bead' => null, + 'bead_index' => null, + 'photo_path' => null, + ], +]; diff --git a/favicon.svg b/favicon.svg new file mode 100644 index 0000000..f6ccab2 --- /dev/null +++ b/favicon.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/forgot_password.php b/forgot_password.php new file mode 100644 index 0000000..956ad9a --- /dev/null +++ b/forgot_password.php @@ -0,0 +1,85 @@ +prepare('SELECT id, display_name, username FROM users WHERE email = ? LIMIT 1'); + $stmt->execute([$email]); + $user = $stmt->fetch(); + + if ($user) { + $token = bin2hex(random_bytes(32)); + $expires = date('Y-m-d H:i:s', strtotime('+1 hour')); + + $pdo->prepare('UPDATE users SET reset_token = ?, reset_expires = ? WHERE id = ?') + ->execute([$token, $expires, $user['id']]); + + $site_url = rtrim(get_setting('site_url'), '/'); + $link = $site_url . '/reset-password?token=' . urlencode($token); + $name = $user['display_name'] ?: $user['username']; + + $body_html = " +

Reset your password

+

Hello, " . htmlspecialchars($name) . "!

+

We received a request to reset your password for your {$site_name} account.

+

+ Reset Password +

+

This link expires in 1 hour.

+

Or copy this link: " . htmlspecialchars($link) . "

+

If you did not request a password reset, ignore this email.

+ "; + $html = email_template('Reset your password — ' . $site_name, $body_html); + send_email($email, $name, 'Reset your password — ' . $site_name, $html); + } + // Always show success to prevent email enumeration + } + $sent = true; +} +?> + + + + + + + Forgot Password — <?= htmlspecialchars($site_name) ?> + + + + + + diff --git a/includes/auth.php b/includes/auth.php new file mode 100644 index 0000000..c5ab5d5 --- /dev/null +++ b/includes/auth.php @@ -0,0 +1,68 @@ +' + . '

Access Denied

' + . '

You do not have permission to view this page.

' + . '← Dashboard'; + exit; + } +} + +/** True if current session user has at least $min_role. */ +function has_role(string $min): bool { + _auth_start(); + $levels = ['user' => 1, 'superuser' => 2, 'admin' => 3, 'superadmin' => 4]; + return ($levels[$_SESSION['role'] ?? ''] ?? 0) >= ($levels[$min] ?? 999); +} + +/** Return current user data from session (or empty defaults). */ +function current_user(): array { + _auth_start(); + return [ + 'id' => $_SESSION['user_id'] ?? null, + 'username' => $_SESSION['username'] ?? '', + 'email' => $_SESSION['email'] ?? '', + 'role' => $_SESSION['role'] ?? '', + 'display_name' => $_SESSION['display_name'] ?? '', + 'rosary_limit' => $_SESSION['rosary_limit'] ?? 1, + ]; +} + +/** + * Check if user can create another rosary. + * Novenas count as 1 regardless of number of days. + * Returns true if under limit (or limit is -1 = unlimited). + */ +function can_create_rosary(int $user_id, int $limit): bool { + if ($limit < 0) return true; // unlimited + $pdo = get_pdo(); + $st = $pdo->prepare(" + SELECT + (SELECT COUNT(*) FROM sessions WHERE user_id = ? AND occasion != 'novena_deceased') + + (SELECT COUNT(*) FROM novena_groups WHERE user_id = ?) + AS total + "); + $st->execute([$user_id, $user_id]); + return (int)$st->fetchColumn() < $limit; +} diff --git a/includes/build_slides.php b/includes/build_slides.php new file mode 100644 index 0000000..b2f5619 --- /dev/null +++ b/includes/build_slides.php @@ -0,0 +1,261 @@ + 'dm_eternal_father_d' . $decade_num, + 'type' => 'prayer', + 'section' => 'dm_chaplet_decade_' . $decade_num, + 'title' => 'Eternal Father', + 'leader' => "Eternal Father, I offer You the Body and Blood,\nSoul and Divinity of Your dearly beloved Son,\nOur Lord Jesus Christ,", + 'all' => "in atonement for our sins and those of the whole world.", + 'bead' => 'large', + 'bead_index' => $of_bead_index, + ]; + + for ($i = 0; $i < 10; $i++) { + $slides[] = [ + 'id' => 'dm_for_sake_d' . $decade_num . '_' . ($i + 1), + 'type' => 'prayer', + 'section' => 'dm_chaplet_decade_' . $decade_num, + 'title' => 'For the Sake of His Sorrowful Passion', + 'leader' => 'For the sake of His sorrowful Passion,', + 'all' => 'have mercy on us and on the whole world.', + 'bead' => 'small', + 'bead_index' => $hm_bead_start + $i, + ]; + } + + return $slides; +} + +/** + * Build the complete slide array for a session. + * + * @param array $session Row from the sessions table (keys match column names) + * @return array Flat array of slide arrays + */ +function build_slides(array $session): array { + global $opening, $mysteries, $hail_holy_queen, $rosary_closing_prayer, + $litany_passion, $novena_prayers, $litany_departed, $closing, + $divine_mercy_opening, $divine_mercy_novena_prayers, + $divine_mercy_chaplet_opening, $divine_mercy_chaplet_close; + + $slides = []; + + // ----------------------------------------------------------------------- + // 1. Cover slide + // ----------------------------------------------------------------------- + $cover_title = ''; + $cover_all = ''; + + switch ($session['occasion']) { + case 'novena_deceased': + $day = (int)($session['novena_day'] ?? 1); + $cover_title = 'Nine-Day Novena Rosary'; + $cover_all = "In Loving Memory of\n{name}\n{subject_dates}\n\nDay {$day} of 9"; + break; + case 'memorial': + $cover_title = 'Memorial Rosary'; + $cover_all = "In Loving Memory of\n{name}\n{subject_dates}"; + break; + case 'divine_mercy_novena': + $day = (int)($session['novena_day'] ?? 1); + $cover_title = 'Divine Mercy Novena'; + $cover_all = "Chaplet of Divine Mercy\n\nDay {$day} of 9"; + break; + case 'general_rosary': + default: + $cover_title = 'The Holy Rosary'; + $cover_all = ucfirst($session['mystery_set']) . ' Mysteries'; + break; + } + + $slides[] = [ + 'id' => 'cover', + 'type' => 'cover', + 'section' => 'cover', + 'title' => $cover_title, + 'leader' => '', + 'all' => $cover_all, + 'bead' => null, + 'bead_index' => null, + 'photo_path' => $session['photo_path'] ?? null, + ]; + + // ----------------------------------------------------------------------- + // 2. Opening prayers + // ----------------------------------------------------------------------- + if ($session['occasion'] === 'divine_mercy_novena') { + // Pre-chaplet devotion: opening prayer + O Blood and Water × 3 + foreach ($divine_mercy_opening as $slide) { + $slides[] = $slide; + } + // Day-specific intention and prayer + $day = (int)($session['novena_day'] ?? 1); + if (isset($divine_mercy_novena_prayers[$day])) { + foreach ($divine_mercy_novena_prayers[$day] as $slide) { + $slides[] = $slide; + } + } + // Chaplet opening on stem beads (Sign of Cross, Our Father, Hail Mary, Creed) + foreach ($divine_mercy_chaplet_opening as $slide) { + $slides[] = $slide; + } + } else { + foreach ($opening as $slide) { + $slides[] = $slide; + } + } + + // ----------------------------------------------------------------------- + // 3. Five decades + // + // Bead layout: + // Bead 0: Crucifix (Sign of Cross / Creed) + // Bead 1: large (Our Father, stem) + // Beads 2–4: small (3 Hail Marys / Creed for chaplet, stem) + // Bead 5: large (Our Father, Decade 1) + // Beads 6–15: small (Decade 1, 10 HMs / For the sake…) + // Bead 16: large (Our Father, Decade 2) + // Beads 17–26: small (Decade 2) + // Bead 27: large (Our Father, Decade 3) + // Beads 28–37: small (Decade 3) + // Bead 38: large (Our Father, Decade 4) + // Beads 39–48: small (Decade 4) + // Bead 49: large (Our Father, Decade 5) + // Beads 50–59: small (Decade 5) + // ----------------------------------------------------------------------- + + $decade_bead_map = [ + // decade => [our_father_index, first_hm_index] + 1 => [5, 6], + 2 => [16, 17], + 3 => [27, 28], + 4 => [38, 39], + 5 => [49, 50], + ]; + + if ($session['occasion'] === 'divine_mercy_novena') { + for ($d = 1; $d <= 5; $d++) { + [$of_idx, $hm_idx] = $decade_bead_map[$d]; + foreach (build_chaplet_decade_slides($d, $of_idx, $hm_idx) as $slide) { + $slides[] = $slide; + } + } + } else { + $mystery_set = $mysteries[$session['mystery_set']] ?? $mysteries['sorrowful']; + + for ($d = 1; $d <= 5; $d++) { + [$of_idx, $hm_idx] = $decade_bead_map[$d]; + $mystery_slide = $mystery_set[$d - 1]; + + $decade_slides = build_decade_slides($d, $mystery_slide, $of_idx, $hm_idx); + foreach ($decade_slides as $slide) { + $slides[] = $slide; + } + } + } + + // ----------------------------------------------------------------------- + // 4. Post-decade prayers by occasion + // ----------------------------------------------------------------------- + switch ($session['occasion']) { + case 'novena_deceased': + // Hail Holy Queen + foreach ($hail_holy_queen as $slide) { + $slides[] = $slide; + } + // Day-specific novena prayer + $day = (int)($session['novena_day'] ?? 1); + if (isset($novena_prayers[$day])) { + $slides[] = $novena_prayers[$day]; + } + // Litany of the Passion + foreach ($litany_passion as $slide) { + $slides[] = $slide; + } + // Litany for the Departed + foreach ($litany_departed as $slide) { + $slides[] = $slide; + } + break; + + case 'divine_mercy_novena': + // Holy God, Holy Mighty One, Holy Immortal One × 3 + foreach ($divine_mercy_chaplet_close as $slide) { + $slides[] = $slide; + } + break; + + case 'memorial': + case 'general_rosary': + default: + foreach ($hail_holy_queen as $slide) { + $slides[] = $slide; + } + $slides[] = $rosary_closing_prayer; + break; + } + + // ----------------------------------------------------------------------- + // 5. Closing slide — inject photo for personal occasions + // ----------------------------------------------------------------------- + $occasion_key = $session['occasion']; + if (!isset($closing[$occasion_key])) { + $occasion_key = 'general_rosary'; + } + $closing_slide = $closing[$occasion_key]; + if (in_array($occasion_key, ['novena_deceased', 'memorial'])) { + $closing_slide['photo_path'] = $session['photo_path'] ?? null; + } + $slides[] = $closing_slide; + + // ----------------------------------------------------------------------- + // 6. Variable substitution + // ----------------------------------------------------------------------- + $name = $session['subject_name'] ?? ''; + $pronoun = $session['subject_pronoun'] ?? 'he'; // 'he' or 'she' + $dates = $session['subject_dates'] ?? ''; + + $pronoun_obj = ($pronoun === 'she') ? 'her' : 'him'; + $pronoun_poss = ($pronoun === 'she') ? 'her' : 'his'; + $pronoun_cap = ucfirst($pronoun); + + $find = ['{name}', '{pronoun}', '{pronoun_obj}', '{pronoun_poss}', '{subject_dates}']; + $replace = [$name, $pronoun, $pronoun_obj, $pronoun_poss, $dates]; + + foreach ($slides as &$slide) { + $slide['title'] = str_replace($find, $replace, $slide['title'] ?? ''); + $slide['leader'] = str_replace($find, $replace, $slide['leader'] ?? ''); + $slide['all'] = str_replace($find, $replace, $slide['all'] ?? ''); + if (isset($slide['subtitle'])) { + $slide['subtitle'] = str_replace($find, $replace, $slide['subtitle']); + } + } + unset($slide); + + return $slides; +} diff --git a/includes/donate.php b/includes/donate.php new file mode 100644 index 0000000..dfc7aa5 --- /dev/null +++ b/includes/donate.php @@ -0,0 +1,48 @@ + + + /i', "\n", $html)); + } + + // Fall back to PHP mail() if SMTP is not configured + if ($smtp_host === '') { + $headers = "MIME-Version: 1.0\r\n"; + $headers .= "Content-Type: text/html; charset=UTF-8\r\n"; + if ($smtp_from !== '') { + $headers .= 'From: ' . _mail_encode_name($smtp_from_name) . ' <' . $smtp_from . ">\r\n"; + } + return @mail($to_email, $subject, $html, $headers); + } + + try { + return _smtp_send($smtp_host, $smtp_port, $smtp_user, $smtp_pass, $smtp_from, $smtp_from_name, $to_email, $to_name, $subject, $html, $text); + } catch (Throwable $e) { + error_log('Mailer error: ' . $e->getMessage()); + return false; + } +} + +/** + * Return a simple styled HTML email wrapper. + */ +function email_template(string $title, string $body_html): string { + $site_name = get_setting('site_name', 'Rosary Presenter'); + return << + + + + +{$title} + + + + +
+ + + + + + + + + + +
+

✝ {$site_name}

+
+ {$body_html} +
+ © {$site_name}. This email was sent automatically. +
+
+ + +HTML; +} + +// --------------------------------------------------------------------------- +// Internal SMTP helpers +// --------------------------------------------------------------------------- + +function _smtp_send( + string $host, int $port, + string $user, string $pass, + string $from_addr, string $from_name, + string $to_addr, string $to_name, + string $subject, string $html, string $text +): bool { + $use_ssl = ($port === 465); + $socket_addr = ($use_ssl ? 'ssl://' : 'tcp://') . $host . ':' . $port; + + $errno = 0; + $errstr = ''; + $sock = stream_socket_client($socket_addr, $errno, $errstr, 15); + if (!$sock) { + throw new RuntimeException("SMTP connect failed ({$errno}): {$errstr}"); + } + stream_set_timeout($sock, 15); + + _smtp_expect($sock, 220); + + _smtp_cmd($sock, 'EHLO ' . gethostname()); + $ehlo = _smtp_read_all($sock); + + // STARTTLS for port 587 + if (!$use_ssl && strpos($ehlo, 'STARTTLS') !== false) { + _smtp_cmd($sock, 'STARTTLS'); + _smtp_expect($sock, 220); + if (!stream_socket_enable_crypto($sock, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) { + throw new RuntimeException('STARTTLS negotiation failed'); + } + _smtp_cmd($sock, 'EHLO ' . gethostname()); + _smtp_read_all($sock); + } + + // AUTH LOGIN + if ($user !== '') { + _smtp_cmd($sock, 'AUTH LOGIN'); + _smtp_expect($sock, 334); + _smtp_cmd($sock, base64_encode($user)); + _smtp_expect($sock, 334); + _smtp_cmd($sock, base64_encode($pass)); + _smtp_expect($sock, 235); + } + + _smtp_cmd($sock, 'MAIL FROM:<' . $from_addr . '>'); + _smtp_expect($sock, 250); + _smtp_cmd($sock, 'RCPT TO:<' . $to_addr . '>'); + _smtp_expect($sock, [250, 251]); + _smtp_cmd($sock, 'DATA'); + _smtp_expect($sock, 354); + + $boundary = 'b_' . bin2hex(random_bytes(8)); + $date = date('r'); + $msg_id = bin2hex(random_bytes(12)) . '@' . gethostname(); + + $headers = "Date: {$date}\r\n"; + $headers .= 'From: ' . _mail_encode_name($from_name) . ' <' . $from_addr . ">\r\n"; + $headers .= 'To: ' . _mail_encode_name($to_name) . ' <' . $to_addr . ">\r\n"; + $headers .= 'Subject: ' . _mail_encode_name($subject) . "\r\n"; + $headers .= "Message-ID: <{$msg_id}>\r\n"; + $headers .= "MIME-Version: 1.0\r\n"; + $headers .= "Content-Type: multipart/alternative; boundary=\"{$boundary}\"\r\n"; + + $body = "--{$boundary}\r\n"; + $body .= "Content-Type: text/plain; charset=UTF-8\r\n"; + $body .= "Content-Transfer-Encoding: quoted-printable\r\n\r\n"; + $body .= quoted_printable_encode($text) . "\r\n"; + $body .= "--{$boundary}\r\n"; + $body .= "Content-Type: text/html; charset=UTF-8\r\n"; + $body .= "Content-Transfer-Encoding: quoted-printable\r\n\r\n"; + $body .= quoted_printable_encode($html) . "\r\n"; + $body .= "--{$boundary}--\r\n"; + + // Dot-stuff the body + $body = preg_replace('/^\.$/m', '..', $body); + + fwrite($sock, $headers . "\r\n" . $body . "\r\n.\r\n"); + _smtp_expect($sock, 250); + + _smtp_cmd($sock, 'QUIT'); + fclose($sock); + return true; +} + +function _smtp_cmd($sock, string $cmd): void { + fwrite($sock, $cmd . "\r\n"); +} + +function _smtp_read_all($sock): string { + $data = ''; + while (!feof($sock)) { + $line = fgets($sock, 512); + if ($line === false) break; + $data .= $line; + // Last line of multi-line response has a space after the code + if (strlen($line) >= 4 && $line[3] === ' ') break; + } + return $data; +} + +function _smtp_expect($sock, $codes): void { + $response = _smtp_read_all($sock); + $code = (int)substr(trim($response), 0, 3); + $expected = (array)$codes; + if (!in_array($code, $expected, true)) { + throw new RuntimeException("SMTP unexpected response {$code}: " . trim($response)); + } +} + +function _mail_encode_name(string $name): string { + if ($name === '') return ''; + // RFC 2047 encode if needed + if (preg_match('/[^\x20-\x7E]/', $name) || strpbrk($name, '"\\,;<>@') !== false) { + return '=?UTF-8?B?' . base64_encode($name) . '?='; + } + return $name; +} diff --git a/index.php b/index.php new file mode 100644 index 0000000..5538b8c --- /dev/null +++ b/index.php @@ -0,0 +1,430 @@ +query(" + SELECT s.id, s.name, s.occasion, s.mystery_set, s.subject_name, s.photo_path, s.slug, + s.is_pinned, s.created_at, u.username, u.display_name + FROM sessions s + JOIN users u ON u.id = s.user_id + WHERE s.is_public = 1 + AND s.occasion NOT IN ('novena_deceased', 'divine_mercy_novena') + AND s.is_pinned = 1 + ORDER BY s.created_at DESC + LIMIT 20 +")->fetchAll(); + +// Pinned novena groups +$pinned_novenas = $pdo->query(" + SELECT ng.id, ng.name, ng.mystery_set, ng.subject_name, ng.photo_path, ng.slug, + ng.is_pinned, ng.created_at, u.username, u.display_name, + COUNT(s.id) AS day_count + FROM novena_groups ng + JOIN users u ON u.id = ng.user_id + LEFT JOIN sessions s ON s.novena_group_id = ng.id + WHERE ng.is_public = 1 + AND ng.is_pinned = 1 + GROUP BY ng.id + ORDER BY ng.created_at DESC + LIMIT 20 +")->fetchAll(); + +// Regular (unpinned) sessions +$sessions = $pdo->query(" + SELECT s.id, s.name, s.occasion, s.mystery_set, s.subject_name, s.photo_path, s.slug, + s.is_pinned, s.created_at, u.username, u.display_name + FROM sessions s + JOIN users u ON u.id = s.user_id + WHERE s.is_public = 1 + AND s.occasion NOT IN ('novena_deceased', 'divine_mercy_novena') + AND s.is_pinned = 0 + ORDER BY s.created_at DESC + LIMIT 60 +")->fetchAll(); + +// Regular (unpinned) novena groups +$novenas = $pdo->query(" + SELECT ng.id, ng.name, ng.mystery_set, ng.subject_name, ng.photo_path, ng.slug, + ng.is_pinned, ng.created_at, u.username, u.display_name, + COUNT(s.id) AS day_count + FROM novena_groups ng + JOIN users u ON u.id = ng.user_id + LEFT JOIN sessions s ON s.novena_group_id = ng.id + WHERE ng.is_public = 1 + AND ng.is_pinned = 0 + GROUP BY ng.id + ORDER BY ng.created_at DESC + LIMIT 30 +")->fetchAll(); + +// Public users list (for search user-pill links) +$public_users_rows = $pdo->query(" + SELECT DISTINCT u.username, + COALESCE(NULLIF(u.display_name,''), u.username) AS display_name + FROM users u + WHERE u.id IN ( + SELECT user_id FROM sessions WHERE is_public = 1 + UNION + SELECT user_id FROM novena_groups WHERE is_public = 1 + ) + ORDER BY u.username + LIMIT 300 +")->fetchAll(PDO::FETCH_ASSOC); + +// Merge and sort each group newest-first +function merge_and_sort(array $sessions, array $novenas): array { + $all = []; + foreach ($sessions as $r) { $r['_type'] = 'session'; $all[] = $r; } + foreach ($novenas as $r) { $r['_type'] = 'novena'; $all[] = $r; } + usort($all, fn($a, $b) => strcmp($b['created_at'], $a['created_at'])); + return $all; +} + +$pinned = merge_and_sort($pinned_sessions, $pinned_novenas); +$regular = merge_and_sort($sessions, $novenas); + +// --------------------------------------------------------------------------- +// Label maps +// --------------------------------------------------------------------------- +$mystery_labels = [ + 'sorrowful' => 'Sorrowful Mysteries', + 'joyful' => 'Joyful Mysteries', + 'glorious' => 'Glorious Mysteries', + 'luminous' => 'Luminous Mysteries', + 'by_day_of_week' => 'By Day of Week', + 'chaplet' => 'Chaplet of Divine Mercy', +]; +$occasion_labels = [ + 'general_rosary' => 'General Rosary', + 'memorial' => 'Memorial', + 'novena_deceased' => 'Novena for Deceased', + 'divine_mercy_novena'=> 'Divine Mercy Novena', +]; + +// --------------------------------------------------------------------------- +// Card renderer (shared by pinned + regular sections) +// --------------------------------------------------------------------------- +function render_card(array $row, bool $is_admin, array $mystery_labels, array $occasion_labels): void { + $disp_name = $row['display_name'] ?: $row['username']; + $slug = $row['slug'] ?? ''; + $is_novena = ($row['_type'] === 'novena'); + $is_pinned = !empty($row['is_pinned']); + $url = $slug + ? (BASE_URL . '/' . rawurlencode($row['username']) . '/' . rawurlencode($slug)) + : '#'; + $link_text = $is_novena ? 'Select a Day →' : 'Pray →'; + + // For the Divine Mercy Novena badge (mystery_set = 'chaplet') + $is_dm = ($is_novena && ($row['mystery_set'] ?? '') === 'chaplet'); + + $card_class = 'rosary-card' . ($is_pinned ? ' is-pinned' : ''); + + // Data attributes for client-side search + $d_name = htmlspecialchars($row['name'], ENT_QUOTES); + $d_subject = htmlspecialchars($row['subject_name'] ?? '', ENT_QUOTES); + $d_uname = htmlspecialchars($row['username'], ENT_QUOTES); + $d_display = htmlspecialchars($disp_name, ENT_QUOTES); + $type_str = $is_novena ? 'novena' : 'session'; + $row_id = (int)$row['id']; +?> +
+ + + + + + + + +
+ + +
+
+
+ + + Divine Mercy + + 9-Day Novena + +   + + + + • + + +
+ +
+
+ + + + + + + + <?= htmlspecialchars($site_name) ?> + + + + + + +
+

✝ Pray the Rosary Together

+

A shared presentation for families and communities praying the Holy Rosary.

+
+ + +
+
+ 🔍 + + +
+
+
+ + + +
+
+

📌 Featured

+
+ +
+

No featured rosaries match your search.

+
+
+ + + +
+

Public Rosaries

+ + +
+ +

No public rosary sessions yet. Create an account to get started.

+
+ +

+ All rosaries are currently featured above. +

+ +
+ +
+

No rosaries match your search.

+ +
+ + + + + + + + + diff --git a/install.php b/install.php new file mode 100644 index 0000000..d052b83 --- /dev/null +++ b/install.php @@ -0,0 +1,218 @@ +exec($sql); + $log[] = ['ok', $label]; + } catch (PDOException $e) { + if (in_array($e->errorInfo[1], [1060, 1061, 1050], true)) { + $log[] = ['skip', $label . ' (already exists, skipped)']; + } else { + $errors[] = $label . ': ' . $e->getMessage(); + $log[] = ['err', $label . ': ' . $e->getMessage()]; + } + } +} + +// ── 1. sessions ────────────────────────────────────────────────────────────── +inst_sql($pdo, 'Create sessions table', " + CREATE TABLE IF NOT EXISTS sessions ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NULL, + is_public TINYINT(1) NOT NULL DEFAULT 1, + slug VARCHAR(255) NULL, + name VARCHAR(255) NOT NULL, + occasion VARCHAR(50) NOT NULL, + mystery_set VARCHAR(50) NOT NULL, + novena_day TINYINT NULL, + novena_group_id INT NULL, + subject_name VARCHAR(255) NULL, + subject_pronoun VARCHAR(10) NULL, + subject_dates VARCHAR(150) NULL, + photo_path VARCHAR(500) NULL, + is_pinned TINYINT(1) NOT NULL DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +", $log, $errors); + +// ── 2. novena_groups ───────────────────────────────────────────────────────── +inst_sql($pdo, 'Create novena_groups table', " + CREATE TABLE IF NOT EXISTS novena_groups ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NULL, + is_public TINYINT(1) NOT NULL DEFAULT 1, + slug VARCHAR(255) NULL, + name VARCHAR(255) NOT NULL, + mystery_set VARCHAR(50) NOT NULL DEFAULT 'sorrowful', + subject_name VARCHAR(255) NULL, + subject_pronoun VARCHAR(10) NULL, + subject_dates VARCHAR(150) NULL, + photo_path VARCHAR(500) NULL, + is_pinned TINYINT(1) NOT NULL DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +", $log, $errors); + +// ── 3. users ───────────────────────────────────────────────────────────────── +inst_sql($pdo, 'Create users table', " + CREATE TABLE IF NOT EXISTS users ( + id INT AUTO_INCREMENT PRIMARY KEY, + username VARCHAR(50) NOT NULL UNIQUE, + email VARCHAR(255) NOT NULL UNIQUE, + password_hash VARCHAR(255) NOT NULL, + display_name VARCHAR(100) NULL, + role ENUM('superadmin','admin','superuser','user') NOT NULL DEFAULT 'user', + rosary_limit INT NOT NULL DEFAULT 1, + email_confirmed TINYINT(1) NOT NULL DEFAULT 0, + confirm_token VARCHAR(64) NULL, + reset_token VARCHAR(64) NULL, + reset_expires DATETIME NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +", $log, $errors); + +// ── 4. site_settings ───────────────────────────────────────────────────────── +inst_sql($pdo, 'Create site_settings table', " + CREATE TABLE IF NOT EXISTS site_settings ( + key_name VARCHAR(100) PRIMARY KEY, + val TEXT NULL, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +", $log, $errors); + +// ── 5. Seed site_settings ──────────────────────────────────────────────────── +$defaults = [ + 'smtp_host' => '', + 'smtp_port' => '587', + 'smtp_user' => '', + 'smtp_pass' => '', + 'smtp_from' => '', + 'smtp_from_name' => 'Rosary Presenter', + 'site_name' => 'Rosary Presenter', + 'site_url' => '', +]; +$ins_setting = $pdo->prepare('INSERT IGNORE INTO site_settings (key_name, val) VALUES (?, ?)'); +foreach ($defaults as $k => $v) { + try { + $ins_setting->execute([$k, $v]); + $log[] = ['ok', "Seeded site_settings: {$k}"]; + } catch (PDOException $e) { + $errors[] = "site_settings {$k}: " . $e->getMessage(); + } +} + +// ── 6. Seed superadmin ─────────────────────────────────────────────────────── +$hash = password_hash('supadmin', PASSWORD_BCRYPT); +try { + $pdo->prepare(" + INSERT IGNORE INTO users + (username, email, password_hash, display_name, role, rosary_limit, email_confirmed) + VALUES ('supadmin', 'admin@example.com', ?, 'Super Admin', 'superadmin', -1, 1) + ")->execute([$hash]); + $log[] = ['ok', 'Seeded superadmin account (username: supadmin)']; +} catch (PDOException $e) { + $errors[] = 'Seed superadmin: ' . $e->getMessage(); +} + +$overall_ok = empty($errors); +?> + + + + + +Install — <?= APP_NAME ?> + + + +
+

— Installer

+ + + + + + + +
+ ⚠ DELETE install.php from your server immediately after reviewing this page. +
+ +
+

Superadmin Credentials

+
+ Username: supadmin
+ Password: supadmin
+ Role:    superadmin (unlimited rosaries) +
+

+ Change the password immediately — go to + /admin/profile after logging in.
+ Also update the email from admin@example.com to your real address. +

+
+ +
+

Installation Log

+ + + + + + + + + + +
StatusStep
+ +
+
+ + +
+

Next Steps

+
    +
  1. Delete install.php from your server.
  2. +
  3. Go to /login — sign in with supadmin / supadmin.
  4. +
  5. Go to /admin/profile — change your password and email.
  6. +
  7. Go to /admin/settings — set your site URL and configure SMTP.
  8. +
+
+ +
+ + diff --git a/login.php b/login.php new file mode 100644 index 0000000..3fe051b --- /dev/null +++ b/login.php @@ -0,0 +1,96 @@ +prepare('SELECT * FROM users WHERE username = ? OR email = ? LIMIT 1'); + $stmt->execute([$username, $username]); + $user = $stmt->fetch(); + + if (!$user || !password_verify($password, $user['password_hash'])) { + $error = 'Invalid username or password.'; + } elseif (!$user['email_confirmed']) { + $error = 'Please confirm your email address before logging in. Check your inbox for the confirmation link.'; + } else { + session_regenerate_id(true); + $_SESSION['user_id'] = $user['id']; + $_SESSION['username'] = $user['username']; + $_SESSION['email'] = $user['email']; + $_SESSION['role'] = $user['role']; + $_SESSION['display_name'] = $user['display_name'] ?? $user['username']; + $_SESSION['rosary_limit'] = (int)$user['rosary_limit']; + header('Location: ' . BASE_URL . '/admin/'); + exit; + } + } +} + +$confirmed_msg = isset($_GET['confirmed']) ? 'Email confirmed! You can now log in.' : ''; +$reset_msg = isset($_GET['reset']) ? 'Password reset successfully. Please log in.' : ''; +?> + + + + + + + Login — <?= htmlspecialchars(get_setting('site_name', APP_NAME)) ?> + + + + + + diff --git a/logout.php b/logout.php new file mode 100644 index 0000000..93fbf60 --- /dev/null +++ b/logout.php @@ -0,0 +1,9 @@ +setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + +$log = []; + +// ----------------------------------------------------------------------- +// 1. Create novena_groups table +// ----------------------------------------------------------------------- +$pdo->exec(" + CREATE TABLE IF NOT EXISTS novena_groups ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL, + mystery_set VARCHAR(50) NOT NULL DEFAULT 'sorrowful', + subject_name VARCHAR(255) NULL, + subject_pronoun VARCHAR(10) NULL, + subject_dates VARCHAR(150) NULL, + photo_path VARCHAR(500) NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + ) +"); +$log[] = 'novena_groups table ready.'; + +// ----------------------------------------------------------------------- +// 2. Add novena_group_id to sessions (silent if already present) +// ----------------------------------------------------------------------- +try { + $pdo->exec('ALTER TABLE sessions ADD COLUMN novena_group_id INT NULL'); + $log[] = 'Added novena_group_id column to sessions.'; +} catch (PDOException $e) { + $log[] = 'novena_group_id column already exists — skipped.'; +} + +// ----------------------------------------------------------------------- +// 3. Migrate existing novena sessions that have no group yet +// ----------------------------------------------------------------------- +$novenas = $pdo->query(" + SELECT * FROM sessions + WHERE occasion = 'novena_deceased' + AND (novena_group_id IS NULL OR novena_group_id = 0) + ORDER BY name, novena_day +")->fetchAll(); + +if (empty($novenas)) { + $log[] = 'No ungrouped novena sessions found — nothing to migrate.'; +} else { + // Bucket sessions by the base name (strip trailing " — Day N") + $buckets = []; + foreach ($novenas as $n) { + $base = preg_replace('/ — Day \d+$/', '', $n['name']); + $buckets[$base][] = $n; + } + + $ins_grp = $pdo->prepare(' + INSERT INTO novena_groups + (name, mystery_set, subject_name, subject_pronoun, subject_dates, photo_path) + VALUES (?, ?, ?, ?, ?, ?) + '); + $upd_ses = $pdo->prepare('UPDATE sessions SET novena_group_id = ? WHERE id = ?'); + + foreach ($buckets as $base_name => $days) { + $first = $days[0]; + $ins_grp->execute([ + $base_name, + $first['mystery_set'], + $first['subject_name'], + $first['subject_pronoun'], + $first['subject_dates'], + $first['photo_path'], + ]); + $gid = (int)$pdo->lastInsertId(); + foreach ($days as $day) { + $upd_ses->execute([$gid, $day['id']]); + } + $log[] = 'Created group #' . $gid . ' "' . $base_name . '" — ' . count($days) . ' day(s) linked.'; + } +} + +?> + + + + Migrate v2 + + + +

Migration v2 Complete

+
    + +
  • + +
+
+ ⚠ Delete migrate_v2.php from your server now. + It is no longer needed and should not be left publicly accessible. +
+ + diff --git a/migrate_v3.php b/migrate_v3.php new file mode 100644 index 0000000..9ee1ce0 --- /dev/null +++ b/migrate_v3.php @@ -0,0 +1,277 @@ +exec($sql); + $log[] = ['ok', $label]; + } catch (PDOException $e) { + // Ignore "already exists" / "duplicate column" errors (1060, 1061, 1050) + if (in_array($e->errorInfo[1], [1060, 1061, 1050], true)) { + $log[] = ['skip', $label . ' (already exists, skipped)']; + } else { + $errors[] = $label . ': ' . $e->getMessage(); + $log[] = ['err', $label . ': ' . $e->getMessage()]; + } + } +} + +// ── 1. Create users table ──────────────────────────────────────────────────── +run_sql($pdo, 'Create users table', " + CREATE TABLE IF NOT EXISTS users ( + id INT AUTO_INCREMENT PRIMARY KEY, + username VARCHAR(50) NOT NULL UNIQUE, + email VARCHAR(255) NOT NULL UNIQUE, + password_hash VARCHAR(255) NOT NULL, + display_name VARCHAR(100) NULL, + role ENUM('superadmin','admin','superuser','user') NOT NULL DEFAULT 'user', + rosary_limit INT NOT NULL DEFAULT 1, + email_confirmed TINYINT(1) NOT NULL DEFAULT 0, + confirm_token VARCHAR(64) NULL, + reset_token VARCHAR(64) NULL, + reset_expires DATETIME NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +", $log, $errors); + +// ── 2. Create site_settings table ─────────────────────────────────────────── +run_sql($pdo, 'Create site_settings table', " + CREATE TABLE IF NOT EXISTS site_settings ( + key_name VARCHAR(100) PRIMARY KEY, + val TEXT NULL, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +", $log, $errors); + +// ── 3. Create sessions table (fresh install) ──────────────────────────────── +run_sql($pdo, 'Create sessions table', " + CREATE TABLE IF NOT EXISTS sessions ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL, + occasion VARCHAR(50) NOT NULL, + mystery_set VARCHAR(50) NOT NULL, + novena_day TINYINT NULL, + subject_name VARCHAR(255) NULL, + subject_pronoun VARCHAR(10) NULL, + subject_dates VARCHAR(150) NULL, + photo_path VARCHAR(500) NULL, + novena_group_id INT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +", $log, $errors); + +// ── 4. Create novena_groups table (fresh install) ──────────────────────────── +run_sql($pdo, 'Create novena_groups table', " + CREATE TABLE IF NOT EXISTS novena_groups ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL, + mystery_set VARCHAR(50) NOT NULL DEFAULT 'sorrowful', + subject_name VARCHAR(255) NULL, + subject_pronoun VARCHAR(10) NULL, + subject_dates VARCHAR(150) NULL, + photo_path VARCHAR(500) NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +", $log, $errors); + +// ── 5. Add columns to sessions ─────────────────────────────────────────────── +foreach ([ + ['Add sessions.user_id', "ALTER TABLE sessions ADD COLUMN user_id INT NULL AFTER id"], + ['Add sessions.is_public', "ALTER TABLE sessions ADD COLUMN is_public TINYINT(1) NOT NULL DEFAULT 1 AFTER user_id"], + ['Add sessions.slug', "ALTER TABLE sessions ADD COLUMN slug VARCHAR(255) NULL AFTER is_public"], +] as [$label, $sql]) { + run_sql($pdo, $label, $sql, $log, $errors); +} + +// ── 6. Add columns to novena_groups ───────────────────────────────────────── +foreach ([ + ['Add novena_groups.user_id', "ALTER TABLE novena_groups ADD COLUMN user_id INT NULL AFTER id"], + ['Add novena_groups.is_public', "ALTER TABLE novena_groups ADD COLUMN is_public TINYINT(1) NOT NULL DEFAULT 1 AFTER user_id"], + ['Add novena_groups.slug', "ALTER TABLE novena_groups ADD COLUMN slug VARCHAR(255) NULL AFTER is_public"], +] as [$label, $sql]) { + run_sql($pdo, $label, $sql, $log, $errors); +} + +// ── 7. Seed site_settings ──────────────────────────────────────────────────── +$settings = [ + 'smtp_host' => '', + 'smtp_port' => '587', + 'smtp_user' => '', + 'smtp_pass' => '', + 'smtp_from' => '', + 'smtp_from_name' => 'Rosary Presenter', + 'site_name' => 'Rosary Presenter', + 'site_url' => '', +]; +$ins_setting = $pdo->prepare('INSERT IGNORE INTO site_settings (key_name, val) VALUES (?, ?)'); +foreach ($settings as $k => $v) { + try { + $ins_setting->execute([$k, $v]); + $log[] = ['ok', "Seeded site_settings: {$k}"]; + } catch (PDOException $e) { + $errors[] = "site_settings {$k}: " . $e->getMessage(); + } +} + +// ── 8. Seed superadmin user ────────────────────────────────────────────────── +$supadmin_hash = password_hash('supadmin', PASSWORD_BCRYPT); +try { + $pdo->prepare(" + INSERT IGNORE INTO users (username, email, password_hash, display_name, role, rosary_limit, email_confirmed) + VALUES ('supadmin', 'admin@example.com', ?, 'Super Admin', 'superadmin', -1, 1) + ")->execute([$supadmin_hash]); + $log[] = ['ok', 'Seeded superadmin user']; +} catch (PDOException $e) { + $errors[] = 'Seed superadmin: ' . $e->getMessage(); +} + +// Get superadmin ID +$supadmin_row = $pdo->query("SELECT id FROM users WHERE username = 'supadmin'")->fetch(); +$supadmin_id = $supadmin_row ? (int)$supadmin_row['id'] : null; + +if ($supadmin_id) { + // ── 9. Assign unowned sessions to superadmin ───────────────────────────── + try { + $affected = $pdo->prepare("UPDATE sessions SET user_id = ? WHERE user_id IS NULL") + ->execute([$supadmin_id]); + $log[] = ['ok', 'Assigned orphan sessions to superadmin']; + } catch (PDOException $e) { + $errors[] = 'Assign sessions: ' . $e->getMessage(); + } + + // ── 10. Assign unowned novena_groups to superadmin ──────────────────────── + try { + $pdo->prepare("UPDATE novena_groups SET user_id = ? WHERE user_id IS NULL") + ->execute([$supadmin_id]); + $log[] = ['ok', 'Assigned orphan novena_groups to superadmin']; + } catch (PDOException $e) { + $errors[] = 'Assign novena_groups: ' . $e->getMessage(); + } + + // ── 11. Generate slugs for sessions without one ─────────────────────────── + try { + $sessions_no_slug = $pdo->query("SELECT id, name, user_id FROM sessions WHERE slug IS NULL OR slug = ''")->fetchAll(); + $upd_slug = $pdo->prepare("UPDATE sessions SET slug = ? WHERE id = ?"); + foreach ($sessions_no_slug as $row) { + $uid = (int)($row['user_id'] ?? $supadmin_id); + $base = slugify($row['name']); + $slug = unique_slug($row['name'], $uid, 'sessions', (int)$row['id']); + $upd_slug->execute([$slug, $row['id']]); + } + $log[] = ['ok', 'Generated slugs for ' . count($sessions_no_slug) . ' sessions']; + } catch (PDOException $e) { + $errors[] = 'Generate session slugs: ' . $e->getMessage(); + } + + // ── 12. Generate slugs for novena_groups without one ───────────────────── + try { + $groups_no_slug = $pdo->query("SELECT id, name, user_id FROM novena_groups WHERE slug IS NULL OR slug = ''")->fetchAll(); + $upd_gslug = $pdo->prepare("UPDATE novena_groups SET slug = ? WHERE id = ?"); + foreach ($groups_no_slug as $row) { + $uid = (int)($row['user_id'] ?? $supadmin_id); + $slug = unique_slug($row['name'], $uid, 'novena_groups', (int)$row['id']); + $upd_gslug->execute([$slug, $row['id']]); + } + $log[] = ['ok', 'Generated slugs for ' . count($groups_no_slug) . ' novena groups']; + } catch (PDOException $e) { + $errors[] = 'Generate novena_group slugs: ' . $e->getMessage(); + } +} + +// ── Render result page ─────────────────────────────────────────────────────── +$overall_ok = empty($errors); +?> + + + + + +Migration v3 — <?= APP_NAME ?> + + + +
+

— Migration v3

+ + + + + + + +
+ ⚠ DELETE this file (migrate_v3.php) from your server immediately after reviewing this page. +
+ +
+

Superadmin Credentials

+
+ Username: supadmin
+ Password: supadmin
+ Role: superadmin +
+

+ CHANGE THE PASSWORD IMMEDIATELY — go to /admin/profile after logging in.
+ Also update the email from admin@example.com to your real email. +

+
+ +
+

Migration Log

+ + + + + + + + + + +
StatusStep
+ +
+
+ +
+

Next Steps

+
    +
  1. Delete migrate_v3.php from your server.
  2. +
  3. Go to /login and sign in with supadmin / supadmin.
  4. +
  5. Go to /admin/profile and change your password and email.
  6. +
  7. Go to /admin/settings to configure SMTP and your site URL.
  8. +
+
+
+ + diff --git a/novena_group.php b/novena_group.php new file mode 100644 index 0000000..fd35208 --- /dev/null +++ b/novena_group.php @@ -0,0 +1,6 @@ +prepare(' + SELECT ng.*, u.username, u.display_name + FROM novena_groups ng + JOIN users u ON u.id = ng.user_id + WHERE ng.id = ? AND ng.is_public = 1 + '); + $stmt->execute([(int)$_GET['group_id']]); + $group = $stmt->fetch(); +} elseif (isset($_GET['username'], $_GET['slug'])) { + $stmt = $pdo->prepare(' + SELECT ng.*, u.username, u.display_name + FROM novena_groups ng + JOIN users u ON u.id = ng.user_id + WHERE u.username = ? AND ng.slug = ? AND ng.is_public = 1 + '); + $stmt->execute([$_GET['username'], $_GET['slug']]); + $group = $stmt->fetch(); +} + +if (!$group) { + http_response_code(404); + die('Novena not found.'); +} + +// Load all available days +$days_stmt = $pdo->prepare(' + SELECT id, novena_day, mystery_set, slug + FROM sessions + WHERE novena_group_id = ? + ORDER BY novena_day +'); +$days_stmt->execute([$group['id']]); +$days_rows = $days_stmt->fetchAll(); + +$days_by_num = []; +foreach ($days_rows as $d) { + $days_by_num[(int)$d['novena_day']] = $d; +} + +$mystery_labels = [ + 'sorrowful' => 'Sorrowful Mysteries', + 'joyful' => 'Joyful Mysteries', + 'glorious' => 'Glorious Mysteries', + 'luminous' => 'Luminous Mysteries', + 'by_day_of_week' => 'By Day of Week', + 'chaplet' => 'Chaplet of Divine Mercy', +]; + +$disp_name = $group['display_name'] ?: $group['username']; +$photo_src = $group['photo_path'] ? ('/' . ltrim($group['photo_path'], '/')) : ''; +?> + + + + + + + <?= htmlspecialchars($group['name']) ?> — <?= htmlspecialchars($site_name) ?> + + + + + + +
+ +
+ +
+ +
+ + +
+

+

+ +

For

+ + +

+ +

By

+
+
+ +
+

Select a Day

+
+ +
+
Day
+ +
+ Pray → + +
Not yet added
+ +
+ +
+
+ + + + + + + diff --git a/present.php b/present.php new file mode 100644 index 0000000..a2cb23c --- /dev/null +++ b/present.php @@ -0,0 +1,231 @@ +prepare('SELECT id FROM users WHERE username = ?'); + $user_stmt->execute([$_GET['username']]); + $user_row = $user_stmt->fetch(); + if (!$user_row) { + http_response_code(404); + die('User not found.'); + } + + // Try sessions first + $ses_stmt = $pdo->prepare('SELECT id FROM sessions WHERE user_id = ? AND slug = ? AND is_public = 1'); + $ses_stmt->execute([$user_row['id'], $_GET['slug']]); + $ses_row = $ses_stmt->fetch(); + + if ($ses_row) { + $_GET['id'] = $ses_row['id']; + } else { + // Try novena group — find first day session + $grp_stmt = $pdo->prepare('SELECT id FROM novena_groups WHERE user_id = ? AND slug = ? AND is_public = 1'); + $grp_stmt->execute([$user_row['id'], $_GET['slug']]); + $grp_row = $grp_stmt->fetch(); + + if ($grp_row) { + // Show day-picker instead of jumping straight to Day 1 + header('Location: ' . BASE_URL . '/novena_public.php?group_id=' . (int)$grp_row['id']); + exit; + } else { + http_response_code(404); + die('Rosary not found.'); + } + } +} + +// Load session +$id = isset($_GET['id']) ? (int)$_GET['id'] : 0; +if ($id < 1) { + die('Missing session ID. Please use a valid presentation link.'); +} + +$stmt = get_pdo()->prepare('SELECT * FROM sessions WHERE id = ?'); +$stmt->execute([$id]); +$session = $stmt->fetch(); + +if (!$session) { + die('Session not found.'); +} + +// Resolve "by_day_of_week" mystery set to the actual set for today +if ($session['mystery_set'] === 'by_day_of_week') { + $dow_map = [ + 0 => 'glorious', // Sunday + 1 => 'joyful', // Monday + 2 => 'sorrowful', // Tuesday + 3 => 'glorious', // Wednesday + 4 => 'luminous', // Thursday + 5 => 'sorrowful', // Friday + 6 => 'joyful', // Saturday + ]; + $session['mystery_set'] = $dow_map[(int)date('w')]; +} +// "all_sorrowful" is stored as-is but maps to sorrowful +if ($session['mystery_set'] === 'all_sorrowful') { + $session['mystery_set'] = 'sorrowful'; +} + +// Build slide array +$slides = build_slides($session); + +// Prepare JSON for JavaScript (HTML-safe) +$slides_json = json_encode($slides, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT); + +// Session display info +$mystery_labels = [ + 'sorrowful' => 'Sorrowful Mysteries', + 'joyful' => 'Joyful Mysteries', + 'glorious' => 'Glorious Mysteries', + 'luminous' => 'Luminous Mysteries', +]; +$mystery_label = $mystery_labels[$session['mystery_set']] ?? ucfirst($session['mystery_set']); +$site_name = get_setting('site_name', APP_NAME); + +// Back URL — novena sessions return to day-picker; others return home +$back_url = BASE_URL . '/'; +if (!empty($session['novena_group_id'])) { + $back_url = BASE_URL . '/novena_public.php?group_id=' . (int)$session['novena_group_id']; +} + +// Build audio manifest: scan uploads/audio/ for available files +$audio_manifest = []; +$audio_dir = UPLOADS_DIR . 'audio/'; +if (is_dir($audio_dir)) { + foreach (glob($audio_dir . '*.*') ?: [] as $f) { + $base = basename($f); + $dot = strrpos($base, '.'); + if ($dot !== false) { + $k = substr($base, 0, $dot); + $e = strtolower(substr($base, $dot + 1)); + if (preg_match('/^[a-z0-9_]+$/', $k)) { + $audio_manifest[$k] = $e; + } + } + } +} +$has_audio = !empty($audio_manifest); +?> + + + + + + + <?= htmlspecialchars($session['name']) ?> — <?= htmlspecialchars($site_name) ?> + + + + + +
+ + +
+ + +
+ + + + + + + + + + + + + + +
+ + + + + +
← → arrows to navigate  |  F = fullscreen
+ + +
+
+ + + + + + + + diff --git a/profile.php b/profile.php new file mode 100644 index 0000000..c49e39c --- /dev/null +++ b/profile.php @@ -0,0 +1,160 @@ +prepare('SELECT id, username, display_name FROM users WHERE username = ? LIMIT 1'); +$user_stmt->execute([$req_username]); +$profile_user = $user_stmt->fetch(); + +if (!$profile_user) { + http_response_code(404); + echo '

404 — User not found

Home'; + exit; +} + +$uid = (int)$profile_user['id']; +$disp_name = $profile_user['display_name'] ?: $profile_user['username']; +$initials = strtoupper(mb_substr($disp_name, 0, 1)); + +// Load public sessions +$sessions = $pdo->prepare(" + SELECT id, name, occasion, mystery_set, subject_name, photo_path, slug, created_at + FROM sessions + WHERE user_id = ? AND is_public = 1 AND occasion != 'novena_deceased' + ORDER BY created_at DESC +"); +$sessions->execute([$uid]); +$sessions = $sessions->fetchAll(); + +// Load public novena groups +$novenas = $pdo->prepare(" + SELECT ng.id, ng.name, ng.mystery_set, ng.subject_name, ng.photo_path, ng.slug, ng.created_at, + COUNT(s.id) AS day_count + FROM novena_groups ng + LEFT JOIN sessions s ON s.novena_group_id = ng.id + WHERE ng.user_id = ? AND ng.is_public = 1 + GROUP BY ng.id + ORDER BY ng.created_at DESC +"); +$novenas->execute([$uid]); +$novenas = $novenas->fetchAll(); + +// Merge +$all = []; +foreach ($sessions as $r) { $r['_type'] = 'session'; $all[] = $r; } +foreach ($novenas as $r) { $r['_type'] = 'novena'; $all[] = $r; } +usort($all, fn($a, $b) => strcmp($b['created_at'], $a['created_at'])); + +$mystery_labels = [ + 'sorrowful' => 'Sorrowful Mysteries', + 'joyful' => 'Joyful Mysteries', + 'glorious' => 'Glorious Mysteries', + 'luminous' => 'Luminous Mysteries', + 'by_day_of_week' => 'By Day of Week', +]; +$occasion_labels = [ + 'general_rosary' => 'General Rosary', + 'memorial' => 'Memorial', + 'novena_deceased' => 'Novena for Deceased', +]; +?> + + + + + + + <?= htmlspecialchars($disp_name) ?> — <?= htmlspecialchars($site_name) ?> + + + + + + +
+
+
+
+

+

@ public rosary

+
+
+
+ +
+

Public Rosaries

+ + +
+ +

has no public rosary sessions yet.

+
+ + + +
+ + + + + diff --git a/recording_script.md b/recording_script.md new file mode 100644 index 0000000..7e844ac --- /dev/null +++ b/recording_script.md @@ -0,0 +1,1132 @@ +# Recording Script — Rosary Presenter App Audio + +## How to Use This Script + +Each section corresponds to one audio file uploaded in **Admin → Audio**. +The filename to use is shown in `code` under each heading (e.g., `sign_of_cross.mp3`). + +**What to record:** Record the full prayer — both Leader and All portions — as one continuous audio clip. The presenter plays the clip when the slide appears, so it should cover the complete exchange. + +**Novena for Deceased prayers** contain the deceased's name and pronouns (marked as `[Name]`, `[he/she]`, `[him/her]`, `[his/her]`). You have two options: +- Record a **generic version** by pausing briefly where the name goes, or reading "the departed soul" +- **Skip** those audio files — the presenter still works; text displays silently + +**Format:** MP3, M4A, OGG, WAV, or WebM · Max 50 MB per file + +--- + +## Common Prayers + +--- + +### `sign_of_cross` +**Sign of the Cross** + +**Leader:** In the name of the Father, and of the Son, and of the Holy Spirit. +**All:** Amen. + +--- + +### `apostles_creed` +**Apostles' Creed** + +**Leader:** I believe in God, the Father Almighty, Creator of Heaven and earth; +and in Jesus Christ, His only Son, Our Lord, +Who was conceived by the Holy Spirit, born of the Virgin Mary, +suffered under Pontius Pilate, was crucified, died, and was buried. +He descended into Hell; the third day He rose again from the dead; +He ascended into Heaven, and sitteth at the right hand of God, the Father Almighty; +from thence He shall come to judge the living and the dead. + +**All:** I believe in the Holy Spirit, the Holy Catholic Church, +the communion of saints, the forgiveness of sins, +the resurrection of the body and life everlasting. Amen. + +--- + +### `our_father` +**Our Father** *(one file covers all Our Fathers in the rosary)* + +**Leader:** Our Father, Who art in Heaven, +hallowed be Thy name; +Thy kingdom come, +Thy will be done on earth as it is in Heaven. + +**All:** Give us this day our daily bread, +and forgive us our trespasses, +as we forgive those who trespass against us; +and lead us not into temptation, +but deliver us from evil. Amen. + +--- + +### `hail_mary` +**Hail Mary** *(one file covers all 58 Hail Marys in the rosary)* + +**Leader:** Hail Mary, full of grace, the Lord is with thee; +blessed art thou amongst women, +and blessed is the fruit of thy womb, Jesus. + +**All:** Holy Mary, Mother of God, +pray for us sinners, +now and at the hour of our death. Amen. + +--- + +### `glory_be` +**Glory Be** *(one file covers all five Glory Be prayers)* + +**Leader:** Glory be to the Father, and to the Son, and to the Holy Spirit, + +**All:** as it was in the beginning, is now, and ever shall be, +world without end. Amen. + +--- + +### `fatima_prayer` +**Fatima Prayer** *(one file covers all five Fatima Prayers)* + +**Leader:** O my Jesus, forgive us our sins, +save us from the fires of hell, + +**All:** lead all souls to Heaven, +especially those who are in most need of Thy mercy. + +--- + +### `hail_holy_queen` +**Hail Holy Queen** *(two-slide prayer — record both slides together)* + +**Leader:** Hail, Holy Queen, Mother of Mercy, +our life, our sweetness and our hope. +To thee do we cry, +poor banished children of Eve. +To thee do we send up our sighs, +mourning and weeping in this valley of tears. +Turn then, most gracious advocate, +thine eyes of mercy toward us, +and after this our exile +show unto us the blessed fruit of thy womb, Jesus. +O clement, O loving, +O sweet Virgin Mary. + +*(slide turns)* + +**Leader:** Pray for us, O holy Mother of God. +**All:** That we may be made worthy of the promises of Christ. + +--- + +### `rosary_closing_prayer` +**Rosary Closing Prayer** *(general rosary and memorial occasions only)* + +**Leader:** Let us pray. + +O God, whose only-begotten Son, by His life, death, and resurrection, +has purchased for us the rewards of eternal life, +grant, we beseech Thee, that meditating upon these mysteries +of the Most Holy Rosary of the Blessed Virgin Mary, +we may imitate what they contain and obtain what they promise, +through the same Christ Our Lord. + +**All:** Amen. + +--- + +### `closing` +**Closing Slide** *(optional — ambient music or silence works well here too)* + +*This slide displays the session name and a closing message. No spoken prayer. Consider soft instrumental music or leave unrecorded.* + +--- + +## Sorrowful Mysteries + +Each mystery file covers the announcement and fruit response for that mystery. +The Our Father, Hail Marys, Glory Be, and Fatima Prayer use the common files above. + +--- + +### `mystery_sorrowful_1` +**The First Sorrowful Mystery** + +**Leader:** The First Sorrowful Mystery — The Agony in the Garden. +**All:** Lord, help us to pray with greater fervor and devotion. + +--- + +### `mystery_sorrowful_2` +**The Second Sorrowful Mystery** + +**Leader:** The Second Sorrowful Mystery — The Scourging at the Pillar. +**All:** Lord, help us to mortify our senses and overcome sin. + +--- + +### `mystery_sorrowful_3` +**The Third Sorrowful Mystery** + +**Leader:** The Third Sorrowful Mystery — The Crowning with Thorns. +**All:** Lord, help us to have the courage to do what is right. + +--- + +### `mystery_sorrowful_4` +**The Fourth Sorrowful Mystery** + +**Leader:** The Fourth Sorrowful Mystery — The Carrying of the Cross. +**All:** Lord, help us to bear our daily crosses with patience. + +--- + +### `mystery_sorrowful_5` +**The Fifth Sorrowful Mystery** + +**Leader:** The Fifth Sorrowful Mystery — The Crucifixion and Death of Our Lord. +**All:** Lord, help us to die to ourselves and to grow in holiness. + +--- + +## Joyful Mysteries + +--- + +### `mystery_joyful_1` +**The First Joyful Mystery** + +**Leader:** The First Joyful Mystery — The Annunciation. +**All:** Lord, help us to grow in humility and openness to God's will. + +--- + +### `mystery_joyful_2` +**The Second Joyful Mystery** + +**Leader:** The Second Joyful Mystery — The Visitation. +**All:** Lord, help us to love our neighbors and serve those in need. + +--- + +### `mystery_joyful_3` +**The Third Joyful Mystery** + +**Leader:** The Third Joyful Mystery — The Nativity. +**All:** Lord, help us to embrace the spirit of poverty and detachment. + +--- + +### `mystery_joyful_4` +**The Fourth Joyful Mystery** + +**Leader:** The Fourth Joyful Mystery — The Presentation in the Temple. +**All:** Lord, help us to offer ourselves completely to God. + +--- + +### `mystery_joyful_5` +**The Fifth Joyful Mystery** + +**Leader:** The Fifth Joyful Mystery — The Finding of Jesus in the Temple. +**All:** Lord, help us to seek God above all things. + +--- + +## Glorious Mysteries + +--- + +### `mystery_glorious_1` +**The First Glorious Mystery** + +**Leader:** The First Glorious Mystery — The Resurrection. +**All:** Lord, help us to believe more deeply in Your Resurrection. + +--- + +### `mystery_glorious_2` +**The Second Glorious Mystery** + +**Leader:** The Second Glorious Mystery — The Ascension. +**All:** Lord, help us to lift our hearts and minds to heaven. + +--- + +### `mystery_glorious_3` +**The Third Glorious Mystery** + +**Leader:** The Third Glorious Mystery — The Descent of the Holy Spirit. +**All:** Lord, help us to be open to the gifts of the Holy Spirit. + +--- + +### `mystery_glorious_4` +**The Fourth Glorious Mystery** + +**Leader:** The Fourth Glorious Mystery — The Assumption of the Blessed Virgin Mary. +**All:** Lord, help us to trust in Mary's powerful intercession. + +--- + +### `mystery_glorious_5` +**The Fifth Glorious Mystery** + +**Leader:** The Fifth Glorious Mystery — The Coronation of the Blessed Virgin Mary. +**All:** Lord, help us to honor Mary as Queen of Heaven and Earth. + +--- + +## Luminous Mysteries + +--- + +### `mystery_luminous_1` +**The First Luminous Mystery** + +**Leader:** The First Luminous Mystery — The Baptism of Our Lord in the Jordan. +**All:** Lord, help us to live our baptismal promises with joy. + +--- + +### `mystery_luminous_2` +**The Second Luminous Mystery** + +**Leader:** The Second Luminous Mystery — The Wedding Feast at Cana. +**All:** Lord, help us to do whatever You tell us. + +--- + +### `mystery_luminous_3` +**The Third Luminous Mystery** + +**Leader:** The Third Luminous Mystery — The Proclamation of the Kingdom of God. +**All:** Lord, help us to repent and believe in the Gospel. + +--- + +### `mystery_luminous_4` +**The Fourth Luminous Mystery** + +**Leader:** The Fourth Luminous Mystery — The Transfiguration. +**All:** Lord, help us to behold Your glory and be transformed. + +--- + +### `mystery_luminous_5` +**The Fifth Luminous Mystery** + +**Leader:** The Fifth Luminous Mystery — The Institution of the Eucharist. +**All:** Lord, help us to adore You more deeply in the Eucharist. + +--- + +## Novena for Deceased + +> **Note on substitutions:** Where `[Name]` appears, the app inserts the deceased's name at runtime. +> For recording, you may say "the departed" or pause briefly. Where `[he/she]`, `[him/her]`, or `[his/her]` appear, the app substitutes based on the pronoun chosen during setup. +> Consider recording two versions (one with "he/him/his" and one with "she/her/her") and uploading whichever fits your most common use, or leave these unrecorded so they display as text only. + +--- + +### `novena_day_1` +**Day 1 — Novena Prayer** + +**Leader:** Let us pray. + +O God of all consolation, +You do not willingly grieve or afflict the children of men. +Look with pity on the suffering of this family in their loss. +Grant that they may not sorrow as those who have no hope, +but through their tears may look up to You, +the source of all consolation. + +Lord, we pray for the departed, +whom You have called from this life to Yourself. +Grant [him/her] Your peace and let perpetual light shine upon [him/her]. +Comfort those who mourn +and give them the sure hope of eternal life. +We ask this through Christ our Lord. + +**All:** Amen. + +--- + +### `novena_day_2` +**Day 2 — Novena Prayer** + +**Leader:** Let us pray. + +O Lord, You are the resurrection and the life. +Whoever believes in You, though [he/she] die, yet shall [he/she] live. +We believe and trust in Your promise. + +We pray for [Name], +who believed in You and received Your sacraments. +Welcome [him/her] now into the fullness of Your eternal kingdom, +where every tear is wiped away +and sorrow is turned into eternal joy. +Bless this family with Your comfort +and give them faith that sees beyond the grave. +We ask this through Christ our Lord. + +**All:** Amen. + +--- + +### `novena_day_3` +**Day 3 — Novena Prayer** + +**Leader:** Let us pray. + +Lord Jesus Christ, by Your own three days in the tomb, +You hallowed the graves of all who believe in You. +May [Name] sleep here in peace until You awaken [him/her] to glory, +for You are the resurrection and the life. +Then [he/she] shall see You face to face +and in Your light shall see light +and know the splendor of God. +For You live and reign forever and ever. + +**All:** Amen. + +--- + +### `novena_day_4` +**Day 4 — Novena Prayer** + +**Leader:** Let us pray. + +Merciful Father, +You have revealed to us that nothing unclean shall enter Your kingdom, +and that Your love is a refining fire +that purifies all who seek You. + +We believe that the souls of the faithful, +cleansed of all that is imperfect, +come at last to the fullness of Your presence. +And so we pray with confidence for [Name], +trusting in the great mercy You have shown +to all who have died in Your friendship. + +By the prayers of Your holy Church, +by the sacrifice of Your Son offered daily on her altars, +and by our own humble intercession here, +may [Name] be swiftly brought +through every shadow of imperfection +into the radiance of Your eternal light. + +Bind us together — the living and the dead — +in the one Body of Christ, +until that day when all who love You +shall stand together before Your face. +We ask this through Christ our Lord. + +**All:** Amen. + +--- + +### `novena_day_5` +**Day 5 — Novena Prayer** + +**Leader:** Let us pray. + +O Lord, support us all the day long of this troublous life +until the shadows lengthen and the evening comes, +and the busy world is hushed, +and the fever of life is over +and our work is done. +Then in Your mercy grant us a safe lodging, +and a holy rest, +and peace at the last. + +Grant this mercy now to [Name], +who has finished [his/her] earthly journey. +Receive [him/her] into Your rest +and into Your peace forever. + +**All:** Amen. + +--- + +### `novena_day_6` +**Day 6 — Novena Prayer** + +**Leader:** Let us pray. + +Into Your hands, O Lord, +we humbly entrust [Name]. +In this life You embraced [him/her] with Your tender love; +deliver [him/her] now from every evil +and bid [him/her] enter eternal rest. + +The old order has passed away: +welcome [him/her] then into paradise, +where there will be no sorrow, no weeping or pain, +but fullness of peace and joy +with Your Son and the Holy Spirit +forever and ever. + +**All:** Amen. + +--- + +### `novena_day_7` +**Day 7 — Novena Prayer** + +**Leader:** Let us pray. + +God, our Father, +Your power brings us to birth, +Your providence guides our lives, +and by Your command we return to dust. + +Lord, those who die still live in Your presence; +their lives change but do not end. +We pray in hope for [Name], +for all the dead known to us, +and for all the forgotten dead. + +As we struggle with the mystery of death, +let Your Spirit comfort and console us. +May we also be consoled by the truth +that [Name] lives forever in Your love. + +**All:** Amen. + +--- + +### `novena_day_8` +**Day 8 — Novena Prayer** + +**Leader:** Let us pray. + +O God, the glory of the faithful +and the life of the just, +by the death and resurrection of whose Son +we have been redeemed, +look mercifully on Your departed servant [Name], +that, just as [he/she] shared in the mystery of our Savior's passion, +so [he/she] may be a partaker of His resurrection. +Who lives and reigns with You +in the unity of the Holy Spirit, +one God, for ever and ever. + +**All:** Amen. + +--- + +### `novena_day_9` +**Day 9 — Novena Prayer** + +**Leader:** Let us pray. + +Lord God, +You are the glory of believers and the life of the just. +Your Son redeemed us by dying and rising to life again. +Our departed [Name] believed in the mystery of our resurrection. +Let [him/her] share the joys and bliss of the risen life +that Christ gives to His loved ones. + +We thank You for the gift of [Name]'s life among us. +We thank You for the love [he/she] gave, +the faith [he/she] lived, +and the hope with which [he/she] departed. +Bring us all one day to that eternal reunion in Your kingdom. + +**All:** Amen. + +--- + +### `litany_passion_intro` +**Litany of the Passion — Entry 1** + +**Leader:** My Jesus, through the torment of Your agony in the Garden of Gethsemane, +**All:** Have mercy on the soul of [Name]. + +--- + +### `litany_passion_2` +**Litany of the Passion — Entry 2** + +**Leader:** My Jesus, through the grief of Your betrayal and arrest, +**All:** Have mercy on the soul of [Name]. + +--- + +### `litany_passion_3` +**Litany of the Passion — Entry 3** + +**Leader:** My Jesus, through the pain of Your scourging at the pillar, +**All:** Have mercy on the soul of [Name]. + +--- + +### `litany_passion_4` +**Litany of the Passion — Entry 4** + +**Leader:** My Jesus, through the humiliation of Your crowning with thorns, +**All:** Have mercy on the soul of [Name]. + +--- + +### `litany_passion_5` +**Litany of the Passion — Entry 5** + +**Leader:** My Jesus, through the agony of Your carrying of the cross, +**All:** Have mercy on the soul of [Name]. + +--- + +### `litany_passion_6` +**Litany of the Passion — Entry 6** + +**Leader:** My Jesus, through the agony of Your crucifixion and death, +**All:** Have mercy on the soul of [Name]. + +--- + +### `litany_passion_7` +**Litany of the Passion — Entry 7** + +**Leader:** My Jesus, through Your precious blood shed on the cross, +**All:** Have mercy on the soul of [Name]. + +--- + +### `litany_passion_8` +**Litany of the Passion — Entry 8** + +**Leader:** My Jesus, through Your final commendation of Your spirit into the Father's hands, +**All:** Have mercy on the soul of [Name]. + +--- + +### `litany_passion_9` +**Litany of the Passion — Entry 9** + +**Leader:** My Jesus, through the silence of Your entombment, +**All:** Have mercy on the soul of [Name]. + +--- + +### `litany_passion_10` +**Litany of the Passion — Entry 10** + +**Leader:** My Jesus, through the joy of Your Resurrection, +**All:** Have mercy on the soul of [Name]. + +--- + +### `litany_passion_11` +**Litany of the Passion — Entry 11** + +**Leader:** My Jesus, through the glory of Your Ascension and promise of return, +**All:** Have mercy on the soul of [Name]. + +--- + +### `litany_departed_kyrie` +**Litany for the Departed — Kyrie** + +**Leader:** Lord, have mercy. +**All:** Lord, have mercy. + +--- + +### `litany_departed_christe` +**Litany for the Departed — Christe** + +**Leader:** Christ, have mercy. +**All:** Christ, have mercy. + +--- + +### `litany_departed_lord` +**Litany for the Departed — Lord** + +**Leader:** Lord, have mercy. +**All:** Lord, have mercy. + +--- + +### `litany_departed_mary` +**Litany for the Departed — Holy Mary** + +**Leader:** Holy Mary, Mother of God, +**All:** Pray for [him/her]. + +--- + +### `litany_departed_michael` +**Litany for the Departed — Saint Michael** + +**Leader:** Saint Michael, +**All:** Pray for [him/her]. + +--- + +### `litany_departed_angels` +**Litany for the Departed — Holy Angels** + +**Leader:** Holy angels of God, +**All:** Pray for [him/her]. + +--- + +### `litany_departed_john` +**Litany for the Departed — Saint John the Baptist** + +**Leader:** Saint John the Baptist, +**All:** Pray for [him/her]. + +--- + +### `litany_departed_joseph` +**Litany for the Departed — Saint Joseph** + +**Leader:** Saint Joseph, +**All:** Pray for [him/her]. + +--- + +### `litany_departed_peter_paul` +**Litany for the Departed — Saints Peter and Paul** + +**Leader:** Saints Peter and Paul, +**All:** Pray for [him/her]. + +--- + +### `litany_departed_all_saints` +**Litany for the Departed — All Saints** + +**Leader:** All you saints of God, +**All:** Pray for [him/her]. + +--- + +### `litany_departed_deliver_death` +**Litany for the Departed — Deliver from Evil** + +**Leader:** From all evil, +**All:** Deliver [him/her], O Lord. + +--- + +### `litany_departed_deliver_sin` +**Litany for the Departed — Deliver from Darkness** + +**Leader:** From the power of darkness, +**All:** Deliver [him/her], O Lord. + +--- + +### `litany_departed_deliver_judgment` +**Litany for the Departed — On the Day of Judgment** + +**Leader:** On the day of judgment, +**All:** Deliver [him/her], O Lord. + +--- + +### `litany_departed_agnus_1` +**Agnus Dei — First** + +**Leader:** Lamb of God, who takes away the sins of the world, +**All:** Grant [him/her] rest. + +--- + +### `litany_departed_agnus_2` +**Agnus Dei — Second** + +**Leader:** Lamb of God, who takes away the sins of the world, +**All:** Grant [him/her] rest. + +--- + +### `litany_departed_agnus_3` +**Agnus Dei — Third** + +**Leader:** Lamb of God, who takes away the sins of the world, +**All:** Grant [him/her] eternal rest. + +--- + +### `litany_departed_eternal_rest_1` +**Eternal Rest — Part 1** + +**Leader:** Eternal rest grant unto [him/her], O Lord, +**All:** and let perpetual light shine upon [him/her]. + +--- + +### `litany_departed_eternal_rest_2` +**Eternal Rest — Part 2** + +**Leader:** May [his/her] soul and the souls of all the faithful departed, +through the mercy of God, rest in peace. +**All:** Amen. + +--- + +### `litany_departed_concluding` +**Concluding Prayer** + +**Leader:** Let us pray. + +O God, whose nature it is always to have mercy and to spare, +we humbly entreat You on behalf of Your servant [Name], +whom You have this day called out of this world. +Do not deliver [him/her] into the hands of the enemy, +nor forget [him/her] forever, +but command Your holy angels to receive [him/her] +and take [him/her] to paradise, +that, having believed and hoped in You, +[he/she] may not undergo the pains of hell, +but may possess everlasting joys. +Through Christ our Lord. + +**All:** Amen. + +--- + +## Divine Mercy Novena + +--- + +### `dm_opening` +**Opening Prayer** *(said before the chaplet each day)* + +**Leader:** You expired, Jesus, but the source of life gushed forth for souls, +and the ocean of mercy opened up for the whole world. +O Fount of Life, unfathomable Divine Mercy, +envelop the whole world and empty Yourself out upon us. + +*(No congregational response — leader reads alone)* + +--- + +### `dm_blood_water` +**O Blood and Water** *(said 3 times — one file covers all three)* + +**Leader:** O Blood and Water, which gushed forth from the Heart of Jesus +as a fountain of Mercy for us, +**All:** I trust in You! + +--- + +### `dm_eternal_father` +**Eternal Father** *(chaplet large bead — said once per decade, 5 times total)* + +**Leader:** Eternal Father, I offer You the Body and Blood, +Soul and Divinity of Your dearly beloved Son, +Our Lord Jesus Christ, +**All:** in atonement for our sins and those of the whole world. + +--- + +### `dm_for_sake` +**For the Sake of His Sorrowful Passion** *(small bead — said 10 times per decade, 50 times total)* + +**Leader:** For the sake of His sorrowful Passion, +**All:** have mercy on us and on the whole world. + +--- + +### `dm_holy_god` +**Holy God** *(said 3 times at the close of the chaplet — one file covers all three)* + +**Leader:** Holy God, Holy Mighty One, Holy Immortal One, +**All:** have mercy on us and on the whole world. + +--- + +### `dm_intention_day_1` +**Day 1 — Jesus' Intention: All Mankind** + +**Leader:** Today bring to Me all mankind, especially all sinners, +and immerse them in the ocean of My mercy. +In this way you will console Me in the bitter grief +into which the loss of souls plunges Me. + +*(No congregational response — leader reads alone)* + +--- + +### `dm_prayer_day_1` +**Day 1 — Day Prayer** + +**Leader:** Most Merciful Jesus, whose very nature it is to have compassion on us +and to forgive us, do not look upon our sins +but upon our trust which we place in Your infinite goodness. +Receive us all into the abode of Your Most Compassionate Heart, +and never let us escape from It. +We beg this of You by Your love which unites You +to the Father and the Holy Spirit. + +**All:** Amen. + +--- + +### `dm_intention_day_2` +**Day 2 — Jesus' Intention: Priests and Religious** + +**Leader:** Today bring to Me the Souls of Priests and Religious, +and immerse them in My unfathomable mercy. +It was they who gave Me strength to endure My bitter Passion. +Through them as through channels My mercy flows out +upon mankind. + +*(No congregational response — leader reads alone)* + +--- + +### `dm_prayer_day_2` +**Day 2 — Day Prayer** + +**Leader:** Most Merciful Jesus, from whom comes all that is good, +increase Your grace in men and women consecrated to Your service, +that they may perform worthy works of mercy; +and that all who see them may glorify the Father of Mercy +who is in heaven. + +**All:** Amen. + +--- + +### `dm_intention_day_3` +**Day 3 — Jesus' Intention: All Devout and Faithful Souls** + +**Leader:** Today bring to Me all Devout and Faithful Souls, +and immerse them in the ocean of My mercy. +These souls brought Me consolation on the Way of the Cross. +They were that drop of consolation in the midst of an ocean of bitterness. + +*(No congregational response — leader reads alone)* + +--- + +### `dm_prayer_day_3` +**Day 3 — Day Prayer** + +**Leader:** Most Merciful Jesus, from the treasury of Your mercy, +You impart Your graces in great abundance to each and all. +Receive us into the abode of Your Most Compassionate Heart +and never let us escape from It. +We beg this grace of You by that most wondrous love +for the heavenly Father with which Your Heart burns so fiercely. + +**All:** Amen. + +--- + +### `dm_intention_day_4` +**Day 4 — Jesus' Intention: Those Who Do Not Believe** + +**Leader:** Today bring to Me those who do not believe in God +and those who do not yet know Me. +I was thinking also of them during My bitter Passion, +and their future zeal comforted My Heart. +Immerse them in the ocean of My mercy. + +*(No congregational response — leader reads alone)* + +--- + +### `dm_prayer_day_4` +**Day 4 — Day Prayer** + +**Leader:** Most Compassionate Jesus, You are the Light of the whole world. +Receive into the abode of Your Most Compassionate Heart +the souls of those who do not believe in God +and of those who as yet do not know You. +Let the rays of Your grace enlighten them +that they, too, together with us, +may extol Your wonderful mercy; +and do not let them escape from the abode +which is Your Most Compassionate Heart. + +**All:** Amen. + +--- + +### `dm_intention_day_5` +**Day 5 — Jesus' Intention: Separated Brethren** + +**Leader:** Today bring to Me the Souls of Heretics and Schismatics, +and immerse them in the ocean of My mercy. +During My bitter Passion they tore at My Body and Heart, +that is, My Church. +As they return to unity with the Church +My wounds heal and in this way they alleviate My Passion. + +*(No congregational response — leader reads alone)* + +--- + +### `dm_prayer_day_5` +**Day 5 — Day Prayer** + +**Leader:** Most Merciful Jesus, Goodness Itself, +You do not refuse light to those who seek it of You. +Receive into the abode of Your Most Compassionate Heart +the souls of heretics and schismatics +and draw them by Your light into the unity of the Church, +and do not let them escape from the abode +of Your Most Compassionate Heart; +but bring it about that they, too, +come to glorify the generosity of Your mercy. + +**All:** Amen. + +--- + +### `dm_intention_day_6` +**Day 6 — Jesus' Intention: Meek and Humble Souls** + +**Leader:** Today bring to Me the Meek and Humble Souls +and the Souls of Little Children, +and immerse them in My mercy. +These souls most closely resemble My Heart. +They strengthened Me during My bitter agony. +I saw them as earthly Angels, +who will keep vigil at My altars. +I pour out upon them whole torrents of grace. +Only the humble soul is capable of receiving My grace. +I favor humble souls with My confidence. + +*(No congregational response — leader reads alone)* + +--- + +### `dm_prayer_day_6` +**Day 6 — Day Prayer** + +**Leader:** Most Merciful Jesus, You yourself have said, +"Learn from Me for I am meek and humble of heart." +Receive into the abode of Your Most Compassionate Heart +all meek and humble souls and the souls of little children. +These souls send all heaven into ecstasy +and they are the heavenly Father's favorites. +They are a sweet-smelling bouquet before the throne of God; +God himself takes delight in their fragrance. +These souls have a permanent abode +in Your Most Compassionate Heart, O Jesus, +and they unceasingly sing out a hymn of love and mercy. + +**All:** Amen. + +--- + +### `dm_intention_day_7` +**Day 7 — Jesus' Intention: Souls Who Venerate God's Mercy** + +**Leader:** Today bring to Me the Souls +who especially venerate and glorify My Mercy, +and immerse them in My mercy. +These souls sorrowed most over My Passion +and entered most deeply into My spirit. +They are living reflections of My compassionate Heart. +These souls will shine with a special brightness +in the next life. +Not one of them will go into the fire of hell. +I shall particularly defend each one of them +at the hour of death. + +*(No congregational response — leader reads alone)* + +--- + +### `dm_prayer_day_7` +**Day 7 — Day Prayer** + +**Leader:** Most Merciful Jesus, whose Heart is Love Itself, +receive into the abode of Your Most Compassionate Heart +the souls of those who particularly extol +and venerate the greatness of Your mercy. +These souls are mighty with the very power of God Himself. +In the midst of all afflictions and adversities they go forward, +confident of Your mercy; +and united to You, O Jesus, +they carry all mankind on their shoulders. +These souls will not be judged severely, +but Your mercy will embrace them as they depart from this life. + +**All:** Amen. + +--- + +### `dm_intention_day_8` +**Day 8 — Jesus' Intention: Souls in Purgatory** + +**Leader:** Today bring to Me the Souls +that are detained in Purgatory, +and immerse them in the abyss of My mercy. +Let the torrents of My Blood cool down their scorching flames. +All these souls are greatly loved by Me. +They are making retribution to My justice. +It is in your power to bring them relief. +Draw all the indulgences from the treasury of My Church +and offer them on their behalf. + +*(No congregational response — leader reads alone)* + +--- + +### `dm_prayer_day_8` +**Day 8 — Day Prayer** + +**Leader:** Most Merciful Jesus, You Yourself have said +that You desire mercy; +so I bring into the abode of Your Most Compassionate Heart +the souls in Purgatory, +souls who are very dear to You, +and yet, who must make retribution to Your justice. +May the streams of Blood and Water +which gushed forth from Your Heart +put out the flames of Purgatory, +that there, too, the power of Your mercy may be celebrated. + +**All:** Amen. + +--- + +### `dm_intention_day_9` +**Day 9 — Jesus' Intention: Lukewarm Souls** + +**Leader:** Today bring to Me the Souls +who have become Lukewarm, +and immerse them in the abyss of My mercy. +These souls wound My Heart most painfully. +My soul suffered the most dreadful loathing in the Garden of Olives +because of lukewarm souls. +They were the reason I cried out: +"Father, take this cup away from Me, if it be Your will." +For them, the last hope of salvation +is to run to My mercy. + +*(No congregational response — leader reads alone)* + +--- + +### `dm_prayer_day_9` +**Day 9 — Day Prayer** + +**Leader:** Most Compassionate Jesus, You are Compassion Itself. +I bring lukewarm souls into the abode +of Your Most Compassionate Heart. +In this fire of Your pure love, +let these tepid souls, who, like corpses, +filled You with such deep loathing, +be once again set aflame. +O Most Compassionate Jesus, +exercise the omnipotence of Your mercy +and draw them into the very ardor of Your love, +and bestow upon them the gift of holy love, +for nothing is beyond Your power. + +**All:** Amen. + +--- + +*End of recording script — 86 audio keys total* diff --git a/register.php b/register.php new file mode 100644 index 0000000..28e435a --- /dev/null +++ b/register.php @@ -0,0 +1,177 @@ + '', 'display_name' => '', 'email' => '']; + +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $username = trim($_POST['username'] ?? ''); + $display_name = trim($_POST['display_name'] ?? ''); + $email = trim($_POST['email'] ?? ''); + $password = $_POST['password'] ?? ''; + $password_confirm = $_POST['password_confirm'] ?? ''; + + $fields = compact('username', 'display_name', 'email'); + + // Validate username + if (!preg_match('/^[a-zA-Z0-9_]{3,30}$/', $username)) { + $errors[] = 'Username must be 3-30 characters and contain only letters, numbers, and underscores.'; + } + // Validate email + if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { + $errors[] = 'Please enter a valid email address.'; + } + // Validate password + if (strlen($password) < 8) { + $errors[] = 'Password must be at least 8 characters.'; + } + if ($password !== $password_confirm) { + $errors[] = 'Passwords do not match.'; + } + + if (empty($errors)) { + $pdo = get_pdo(); + + // Check uniqueness + $chk = $pdo->prepare('SELECT id FROM users WHERE username = ? OR email = ?'); + $chk->execute([$username, $email]); + $existing = $chk->fetchAll(); + foreach ($existing as $row) { + // Re-check which field conflicts + } + if (!empty($existing)) { + $chk_u = $pdo->prepare('SELECT id FROM users WHERE username = ?'); + $chk_u->execute([$username]); + if ($chk_u->fetch()) $errors[] = 'That username is already taken.'; + + $chk_e = $pdo->prepare('SELECT id FROM users WHERE email = ?'); + $chk_e->execute([$email]); + if ($chk_e->fetch()) $errors[] = 'That email address is already registered.'; + } + } + + if (empty($errors)) { + $pdo = get_pdo(); + $smtp_host = get_setting('smtp_host'); + $auto_confirm = ($smtp_host === ''); // No SMTP = skip email confirmation + + $hash = password_hash($password, PASSWORD_BCRYPT); + $token = $auto_confirm ? null : bin2hex(random_bytes(32)); + + $pdo->prepare(" + INSERT INTO users (username, email, password_hash, display_name, role, rosary_limit, email_confirmed, confirm_token) + VALUES (?, ?, ?, ?, 'user', 1, ?, ?) + ")->execute([$username, $email, $hash, $display_name ?: $username, $auto_confirm ? 1 : 0, $token]); + + if (!$auto_confirm && $token) { + $site_url = rtrim(get_setting('site_url'), '/'); + $link = $site_url . '/confirm?token=' . urlencode($token); + $site_name = get_setting('site_name', APP_NAME); + $body_html = " +

Confirm your email

+

Hello, " . htmlspecialchars($display_name ?: $username) . "!

+

Thank you for registering with {$site_name}. Click the button below to confirm your email address:

+

+ Confirm Email +

+

Or copy this link: " . htmlspecialchars($link) . "

+

If you did not register, ignore this email.

+ "; + $html = email_template('Confirm your email — ' . $site_name, $body_html); + send_email($email, $display_name ?: $username, 'Confirm your email — ' . $site_name, $html); + } + + $success = true; + $auto_confirmed = $auto_confirm; + } +} +?> + + + + + + + Register — <?= htmlspecialchars(get_setting('site_name', APP_NAME)) ?> + + + + + + diff --git a/reset_password.php b/reset_password.php new file mode 100644 index 0000000..f008d6f --- /dev/null +++ b/reset_password.php @@ -0,0 +1,93 @@ +prepare('SELECT id, username FROM users WHERE reset_token = ? AND reset_expires > NOW() LIMIT 1'); +$stmt->execute([$token]); +$user = $stmt->fetch(); + +if (!$user) { + $token_invalid = true; +} + +if (!isset($token_invalid) && $_SERVER['REQUEST_METHOD'] === 'POST') { + $password = $_POST['password'] ?? ''; + $password_confirm = $_POST['password_confirm'] ?? ''; + + if (strlen($password) < 8) { + $errors[] = 'Password must be at least 8 characters.'; + } + if ($password !== $password_confirm) { + $errors[] = 'Passwords do not match.'; + } + + if (empty($errors)) { + $hash = password_hash($password, PASSWORD_BCRYPT); + $pdo->prepare('UPDATE users SET password_hash = ?, reset_token = NULL, reset_expires = NULL WHERE id = ?') + ->execute([$hash, $user['id']]); + $success = true; + header('Location: ' . BASE_URL . '/login?reset=1'); + exit; + } +} +?> + + + + + + + Reset Password — <?= htmlspecialchars($site_name) ?> + + + + + + diff --git a/setup.php b/setup.php new file mode 100644 index 0000000..c89e246 --- /dev/null +++ b/setup.php @@ -0,0 +1,6 @@ +