Initial commit — Rosary Presenter App
Full source for loveandrosary.com: slide-based Rosary/novena/Divine Mercy Chaplet presentation tool with multi-user roles, SVG bead ring, audio uploads, donate strip, and public session profiles. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+20
@@ -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
|
||||||
@@ -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]
|
||||||
@@ -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.
|
||||||
+386
@@ -0,0 +1,386 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* admin/audio.php — Manage pre-recorded prayer audio files.
|
||||||
|
* Admin only. Lists all known prayer keys grouped by category.
|
||||||
|
* Each row shows upload status and allows upload / delete.
|
||||||
|
*/
|
||||||
|
require_once __DIR__ . '/../config/db.php';
|
||||||
|
require_once __DIR__ . '/../includes/auth.php';
|
||||||
|
|
||||||
|
require_auth();
|
||||||
|
if (!has_role('admin')) {
|
||||||
|
header('Location: ' . BASE_URL . '/admin/');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = current_user();
|
||||||
|
$site_name = get_setting('site_name', APP_NAME);
|
||||||
|
|
||||||
|
// Scan uploads/audio/ to build a map of key → extension
|
||||||
|
$audio_dir = UPLOADS_DIR . 'audio/';
|
||||||
|
$uploaded_files = [];
|
||||||
|
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)) {
|
||||||
|
$uploaded_files[$k] = $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// All known prayer audio keys grouped by category
|
||||||
|
$AUDIO_KEYS = [
|
||||||
|
'Common Prayers' => [
|
||||||
|
'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++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<link rel="icon" type="image/svg+xml" href="<?= BASE_URL ?>/favicon.svg">
|
||||||
|
<title>Audio — <?= htmlspecialchars($site_name) ?></title>
|
||||||
|
<link rel="stylesheet" href="<?= BASE_URL ?>/assets/css/setup.css">
|
||||||
|
<style>
|
||||||
|
.audio-summary { display:flex; gap:24px; margin-bottom:28px; flex-wrap:wrap; }
|
||||||
|
.audio-summary-stat { background:#fff; border:1px solid #e5e7eb; border-radius:8px; padding:16px 24px; }
|
||||||
|
.audio-summary-stat .num { font-size:32px; font-weight:800; color:#1e3a5f; line-height:1; }
|
||||||
|
.audio-summary-stat .lbl { font-size:13px; color:#6b7280; margin-top:4px; }
|
||||||
|
.audio-section { margin-bottom:32px; }
|
||||||
|
.audio-section h3 { font-size:16px; font-weight:700; color:#1e3a5f; margin:0 0 12px;
|
||||||
|
border-bottom:2px solid #c9973d; padding-bottom:8px; display:inline-block; }
|
||||||
|
.audio-table { width:100%; border-collapse:collapse; font-size:14px; }
|
||||||
|
.audio-table th { text-align:left; padding:8px 12px; background:#f9fafb;
|
||||||
|
font-weight:600; border-bottom:2px solid #e5e7eb; }
|
||||||
|
.audio-table td { padding:8px 12px; border-bottom:1px solid #f3f4f6; vertical-align:middle; }
|
||||||
|
.audio-table tr:last-child td { border-bottom:none; }
|
||||||
|
.status-uploaded { color:#15803d; font-weight:600; font-size:13px; }
|
||||||
|
.status-missing { color:#9ca3af; font-size:13px; }
|
||||||
|
.file-ext { background:#e0f2fe; color:#0369a1; padding:2px 7px; border-radius:4px;
|
||||||
|
font-size:11px; font-weight:700; text-transform:uppercase; margin-left:6px; }
|
||||||
|
.upload-btn { font-size:13px; padding:5px 12px; }
|
||||||
|
.delete-btn { font-size:13px; padding:5px 10px; margin-left:6px; }
|
||||||
|
.key-code { font-family:monospace; font-size:12px; color:#6b7280; }
|
||||||
|
.help-note { background:#f0f9ff; border:1px solid #bae6fd; border-radius:8px;
|
||||||
|
padding:16px 20px; font-size:14px; color:#0c4a6e; margin-bottom:28px; }
|
||||||
|
.help-note strong { display:block; margin-bottom:6px; }
|
||||||
|
</style>
|
||||||
|
<script>var BASE_URL = '<?= BASE_URL ?>';</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="admin-container">
|
||||||
|
|
||||||
|
<header class="admin-header">
|
||||||
|
<h1>✝ <?= htmlspecialchars($site_name) ?></h1>
|
||||||
|
<div class="header-actions">
|
||||||
|
<a href="<?= BASE_URL ?>/" class="btn btn-ghost" style="font-size:13px">← View Site</a>
|
||||||
|
<?php if (has_role('admin')): ?>
|
||||||
|
<a href="<?= BASE_URL ?>/admin/users.php" class="btn btn-ghost">Users</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if (has_role('superadmin')): ?>
|
||||||
|
<a href="<?= BASE_URL ?>/admin/settings.php" class="btn btn-ghost">Settings</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
<a href="<?= BASE_URL ?>/admin/profile.php" class="btn btn-ghost"><?= htmlspecialchars($user['display_name'] ?: $user['username']) ?></a>
|
||||||
|
<a href="<?= BASE_URL ?>/admin/" class="btn btn-ghost">← Dashboard</a>
|
||||||
|
<a href="<?= BASE_URL ?>/logout" class="btn btn-ghost">Logout</a>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<h2 style="margin-bottom:8px">Prayer Audio</h2>
|
||||||
|
<p style="color:#6b7280;margin:0 0 24px">
|
||||||
|
Upload pre-recorded audio for each prayer. A 🔊 toggle appears in the presenter when audio is available.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="help-note">
|
||||||
|
<strong>How it works</strong>
|
||||||
|
Accepted formats: MP3, M4A, OGG, WAV • Max file size: 50 MB per file.<br>
|
||||||
|
Prayers that repeat (e.g. Our Father, Hail Mary, Glory Be) share a single recording — upload once and it plays on every occurrence.<br>
|
||||||
|
Uploading a new file for a key automatically replaces the old one.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="audio-summary">
|
||||||
|
<div class="audio-summary-stat">
|
||||||
|
<div class="num"><?= $uploaded_count ?></div>
|
||||||
|
<div class="lbl">of <?= $total_keys ?> prayers recorded</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="upload-msg" class="alert" style="display:none;margin-bottom:16px"></div>
|
||||||
|
|
||||||
|
<?php foreach ($AUDIO_KEYS as $category => $keys): ?>
|
||||||
|
<div class="audio-section">
|
||||||
|
<h3><?= htmlspecialchars($category) ?></h3>
|
||||||
|
<div class="sessions-table-wrap">
|
||||||
|
<table class="audio-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:38%">Prayer</th>
|
||||||
|
<th style="width:28%">Key</th>
|
||||||
|
<th style="width:16%">Status</th>
|
||||||
|
<th style="width:18%">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($keys as $key => $label):
|
||||||
|
$has = isset($uploaded_files[$key]);
|
||||||
|
$ext = $has ? $uploaded_files[$key] : null;
|
||||||
|
?>
|
||||||
|
<tr id="row-<?= $key ?>">
|
||||||
|
<td><?= htmlspecialchars($label) ?></td>
|
||||||
|
<td><span class="key-code"><?= htmlspecialchars($key) ?></span></td>
|
||||||
|
<td>
|
||||||
|
<?php if ($has): ?>
|
||||||
|
<span class="status-uploaded">✓ Uploaded<span class="file-ext"><?= $ext ?></span></span>
|
||||||
|
<?php else: ?>
|
||||||
|
<span class="status-missing">— Not uploaded</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<label class="btn btn-sm btn-secondary upload-btn" style="cursor:pointer;display:inline-block">
|
||||||
|
<?= $has ? 'Replace' : 'Upload' ?>
|
||||||
|
<input type="file" style="display:none" accept="audio/*"
|
||||||
|
data-key="<?= htmlspecialchars($key) ?>">
|
||||||
|
</label>
|
||||||
|
<?php if ($has): ?>
|
||||||
|
<button class="btn btn-sm btn-danger delete-btn"
|
||||||
|
data-key="<?= htmlspecialchars($key) ?>">Delete</button>
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var msgBox = document.getElementById('upload-msg');
|
||||||
|
|
||||||
|
function showMsg(type, text) {
|
||||||
|
msgBox.className = 'alert alert-' + type;
|
||||||
|
msgBox.textContent = text;
|
||||||
|
msgBox.style.display = '';
|
||||||
|
setTimeout(function () { msgBox.style.display = 'none'; }, 4000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateRow(key, ext) {
|
||||||
|
var row = document.getElementById('row-' + key);
|
||||||
|
if (!row) return;
|
||||||
|
|
||||||
|
var statusCell = row.cells[2];
|
||||||
|
var actionsCell = row.cells[3];
|
||||||
|
|
||||||
|
if (ext) {
|
||||||
|
statusCell.innerHTML =
|
||||||
|
'<span class="status-uploaded">✓ Uploaded' +
|
||||||
|
'<span class="file-ext">' + ext + '</span></span>';
|
||||||
|
|
||||||
|
// Rebuild actions cell
|
||||||
|
actionsCell.innerHTML =
|
||||||
|
'<label class="btn btn-sm btn-secondary upload-btn" style="cursor:pointer;display:inline-block">' +
|
||||||
|
'Replace<input type="file" style="display:none" accept="audio/*" data-key="' + key + '"></label>' +
|
||||||
|
' <button class="btn btn-sm btn-danger delete-btn" data-key="' + key + '">Delete</button>';
|
||||||
|
} else {
|
||||||
|
statusCell.innerHTML = '<span class="status-missing">— Not uploaded</span>';
|
||||||
|
actionsCell.innerHTML =
|
||||||
|
'<label class="btn btn-sm btn-secondary upload-btn" style="cursor:pointer;display:inline-block">' +
|
||||||
|
'Upload<input type="file" style="display:none" accept="audio/*" data-key="' + key + '"></label>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-attach listeners on rebuilt elements
|
||||||
|
wireRow(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
function wireRow(row) {
|
||||||
|
var fileInput = row.querySelector('input[type="file"]');
|
||||||
|
if (fileInput) {
|
||||||
|
fileInput.addEventListener('change', handleUpload);
|
||||||
|
}
|
||||||
|
var delBtn = row.querySelector('.delete-btn');
|
||||||
|
if (delBtn) {
|
||||||
|
delBtn.addEventListener('click', handleDelete);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleUpload() {
|
||||||
|
var key = this.dataset.key;
|
||||||
|
var file = this.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
var label = this.closest('label');
|
||||||
|
if (label) { label.textContent = 'Uploading\u2026'; }
|
||||||
|
|
||||||
|
var fd = new FormData();
|
||||||
|
fd.append('key', key);
|
||||||
|
fd.append('audio', file);
|
||||||
|
|
||||||
|
fetch(BASE_URL + '/api/upload_audio.php', { method: 'POST', body: fd })
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (data) {
|
||||||
|
if (data.error) {
|
||||||
|
showMsg('error', 'Upload failed: ' + data.error);
|
||||||
|
updateRow(key, null); // reset UI
|
||||||
|
} else {
|
||||||
|
showMsg('success', '\u2713 Uploaded: ' + key + '.' + data.ext);
|
||||||
|
updateRow(key, data.ext);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
showMsg('error', 'Network error during upload.');
|
||||||
|
updateRow(key, null);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDelete() {
|
||||||
|
var key = this.dataset.key;
|
||||||
|
if (!confirm('Delete audio for "' + key + '"?')) return;
|
||||||
|
|
||||||
|
var fd = new FormData();
|
||||||
|
fd.append('key', key);
|
||||||
|
|
||||||
|
fetch(BASE_URL + '/api/delete_audio.php', { method: 'POST', body: fd })
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (data) {
|
||||||
|
if (data.error) {
|
||||||
|
showMsg('error', 'Delete failed: ' + data.error);
|
||||||
|
} else {
|
||||||
|
showMsg('success', 'Deleted: ' + key);
|
||||||
|
updateRow(key, null);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function () { showMsg('error', 'Network error.'); });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wire all rows on load
|
||||||
|
document.querySelectorAll('tr[id^="row-"]').forEach(function (row) {
|
||||||
|
wireRow(row);
|
||||||
|
});
|
||||||
|
|
||||||
|
}());
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+247
@@ -0,0 +1,247 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* admin/index.php — Dashboard. Shows sessions for current user (or all for admin+).
|
||||||
|
*/
|
||||||
|
require_once __DIR__ . '/../config/db.php';
|
||||||
|
require_once __DIR__ . '/../includes/auth.php';
|
||||||
|
|
||||||
|
require_auth();
|
||||||
|
|
||||||
|
$pdo = get_pdo();
|
||||||
|
$user = current_user();
|
||||||
|
$uid = (int)$user['id'];
|
||||||
|
$is_admin = has_role('admin');
|
||||||
|
$site_name = get_setting('site_name', APP_NAME);
|
||||||
|
|
||||||
|
// Handle deletions
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
if (isset($_POST['delete_group_id'])) {
|
||||||
|
$gid = (int)$_POST['delete_group_id'];
|
||||||
|
// Verify ownership or admin
|
||||||
|
if ($is_admin) {
|
||||||
|
$pdo->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;
|
||||||
|
?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<link rel="icon" type="image/svg+xml" href="<?= BASE_URL ?>/favicon.svg">
|
||||||
|
<title>Dashboard — <?= htmlspecialchars($site_name) ?></title>
|
||||||
|
<link rel="stylesheet" href="<?= BASE_URL ?>/assets/css/setup.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="admin-container">
|
||||||
|
|
||||||
|
<header class="admin-header">
|
||||||
|
<h1>✝ <?= htmlspecialchars($site_name) ?></h1>
|
||||||
|
<div class="header-actions">
|
||||||
|
<a href="<?= BASE_URL ?>/" class="btn btn-ghost" style="font-size:13px">← View Site</a>
|
||||||
|
<?php if (has_role('admin')): ?>
|
||||||
|
<a href="<?= BASE_URL ?>/admin/users.php" class="btn btn-ghost">Users</a>
|
||||||
|
<a href="<?= BASE_URL ?>/admin/audio.php" class="btn btn-ghost">Audio</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if (has_role('superadmin')): ?>
|
||||||
|
<a href="<?= BASE_URL ?>/admin/settings.php" class="btn btn-ghost">Settings</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
<a href="<?= BASE_URL ?>/admin/profile.php" class="btn btn-ghost"><?= htmlspecialchars($user['display_name'] ?: $user['username']) ?></a>
|
||||||
|
<a href="<?= BASE_URL ?>/logout" class="btn btn-ghost">Logout</a>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<?php if ($novena_created > 0): ?>
|
||||||
|
<div class="alert alert-success">
|
||||||
|
✓ <?= $novena_created ?> novena day sessions created.
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:20px">
|
||||||
|
<h2 style="margin:0"><?= $is_admin ? 'All Sessions' : 'My Sessions' ?></h2>
|
||||||
|
<a href="<?= BASE_URL ?>/admin/setup.php" class="btn btn-primary">+ New Session</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if (empty($all_rows)): ?>
|
||||||
|
<div class="empty-state">
|
||||||
|
<p>No sessions yet.</p>
|
||||||
|
<a href="<?= BASE_URL ?>/admin/setup.php" class="btn btn-primary">Create Your First Session</a>
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="sessions-table-wrap">
|
||||||
|
<table class="sessions-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Occasion</th>
|
||||||
|
<th>Mysteries</th>
|
||||||
|
<?php if ($is_admin): ?><th>Owner</th><?php endif; ?>
|
||||||
|
<th>Public</th>
|
||||||
|
<th>Created</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($all_rows as $row): ?>
|
||||||
|
|
||||||
|
<?php if ($row['row_type'] === 'novena_group'): ?>
|
||||||
|
<tr class="novena-group-row">
|
||||||
|
<td class="session-name">
|
||||||
|
<span class="novena-group-icon">✝</span>
|
||||||
|
<?= htmlspecialchars($row['name']) ?>
|
||||||
|
<?php if ($row['subject_name']): ?>
|
||||||
|
<span class="subject-name"><?= htmlspecialchars($row['subject_name']) ?></span>
|
||||||
|
<?php endif; ?>
|
||||||
|
<span class="novena-badge">9-Day Novena</span>
|
||||||
|
</td>
|
||||||
|
<td><?= ($row['mystery_set'] === 'chaplet') ? 'Divine Mercy Novena' : 'Novena for Deceased' ?></td>
|
||||||
|
<td><?= htmlspecialchars($mystery_labels[$row['mystery_set']] ?? $row['mystery_set']) ?></td>
|
||||||
|
<?php if ($is_admin): ?>
|
||||||
|
<td><?= htmlspecialchars($row['display_name'] ?: ($row['username'] ?? '—')) ?></td>
|
||||||
|
<?php endif; ?>
|
||||||
|
<td><?= $row['is_public'] ? '<span style="color:#15803d">✓ Yes</span>' : '<span style="color:#9ca3af">No</span>' ?></td>
|
||||||
|
<td><?= date('M j, Y', strtotime($row['created_at'])) ?></td>
|
||||||
|
<td class="actions">
|
||||||
|
<a href="<?= BASE_URL ?>/admin/novena_group.php?id=<?= $row['id'] ?>"
|
||||||
|
class="btn btn-sm btn-primary">View Days →</a>
|
||||||
|
<?php if ($is_admin || (int)$row['user_id'] === $uid): ?>
|
||||||
|
<form method="post" style="display:inline"
|
||||||
|
onsubmit="return confirm('Delete this entire novena (all 9 days)?')">
|
||||||
|
<input type="hidden" name="delete_group_id" value="<?= $row['id'] ?>">
|
||||||
|
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
|
||||||
|
</form>
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<?php else: ?>
|
||||||
|
<tr>
|
||||||
|
<td class="session-name">
|
||||||
|
<?= htmlspecialchars($row['name']) ?>
|
||||||
|
<?php if ($row['subject_name']): ?>
|
||||||
|
<span class="subject-name"><?= htmlspecialchars($row['subject_name']) ?></span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
|
<td><?= htmlspecialchars($occasion_labels[$row['occasion']] ?? $row['occasion']) ?></td>
|
||||||
|
<td><?= htmlspecialchars($mystery_labels[$row['mystery_set']] ?? $row['mystery_set']) ?></td>
|
||||||
|
<?php if ($is_admin): ?>
|
||||||
|
<td><?= htmlspecialchars($row['display_name'] ?: ($row['username'] ?? '—')) ?></td>
|
||||||
|
<?php endif; ?>
|
||||||
|
<td><?= $row['is_public'] ? '<span style="color:#15803d">✓ Yes</span>' : '<span style="color:#9ca3af">No</span>' ?></td>
|
||||||
|
<td><?= date('M j, Y', strtotime($row['created_at'])) ?></td>
|
||||||
|
<td class="actions">
|
||||||
|
<?php
|
||||||
|
$slug_val = $row['slug'] ?? '';
|
||||||
|
$owner = $row['username'] ?? '';
|
||||||
|
$present_url = ($slug_val && $owner)
|
||||||
|
? BASE_URL . '/' . rawurlencode($owner) . '/' . rawurlencode($slug_val)
|
||||||
|
: BASE_URL . '/present.php?id=' . $row['id'];
|
||||||
|
?>
|
||||||
|
<a href="<?= htmlspecialchars($present_url) ?>"
|
||||||
|
target="_blank"
|
||||||
|
class="btn btn-sm btn-primary">Present</a>
|
||||||
|
<?php if ($is_admin || (int)$row['user_id'] === $uid): ?>
|
||||||
|
<a href="<?= BASE_URL ?>/admin/setup.php?id=<?= $row['id'] ?>"
|
||||||
|
class="btn btn-sm btn-secondary">Edit</a>
|
||||||
|
<form method="post" style="display:inline"
|
||||||
|
onsubmit="return confirm('Delete this session?')">
|
||||||
|
<input type="hidden" name="delete_id" value="<?= $row['id'] ?>">
|
||||||
|
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
|
||||||
|
</form>
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,338 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* admin/novena_group.php — View and manage one novena group.
|
||||||
|
*/
|
||||||
|
require_once __DIR__ . '/../config/db.php';
|
||||||
|
require_once __DIR__ . '/../includes/auth.php';
|
||||||
|
|
||||||
|
require_auth();
|
||||||
|
|
||||||
|
$pdo = get_pdo();
|
||||||
|
$user = current_user();
|
||||||
|
$uid = (int)$user['id'];
|
||||||
|
$gid = isset($_GET['id']) ? (int)$_GET['id'] : 0;
|
||||||
|
$site_name = get_setting('site_name', APP_NAME);
|
||||||
|
|
||||||
|
if ($gid < 1) {
|
||||||
|
header('Location: ' . BASE_URL . '/admin/');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load group and verify ownership
|
||||||
|
$group = $pdo->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',
|
||||||
|
];
|
||||||
|
?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<link rel="icon" type="image/svg+xml" href="<?= BASE_URL ?>/favicon.svg">
|
||||||
|
<title><?= htmlspecialchars($group['name']) ?> — <?= htmlspecialchars($site_name) ?></title>
|
||||||
|
<link rel="stylesheet" href="<?= BASE_URL ?>/assets/css/setup.css">
|
||||||
|
<script>var BASE_URL = '<?= BASE_URL ?>';</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="admin-container">
|
||||||
|
|
||||||
|
<header class="admin-header">
|
||||||
|
<h1>✝ <?= htmlspecialchars($site_name) ?></h1>
|
||||||
|
<div class="header-actions">
|
||||||
|
<a href="<?= BASE_URL ?>/" class="btn btn-ghost" style="font-size:13px">← View Site</a>
|
||||||
|
<?php if (has_role('admin')): ?>
|
||||||
|
<a href="<?= BASE_URL ?>/admin/users.php" class="btn btn-ghost">Users</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if (has_role('superadmin')): ?>
|
||||||
|
<a href="<?= BASE_URL ?>/admin/settings.php" class="btn btn-ghost">Settings</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
<a href="<?= BASE_URL ?>/admin/profile.php" class="btn btn-ghost"><?= htmlspecialchars($user['display_name'] ?: $user['username']) ?></a>
|
||||||
|
<a href="<?= BASE_URL ?>/admin/" class="btn btn-ghost">← All Sessions</a>
|
||||||
|
<a href="<?= BASE_URL ?>/logout" class="btn btn-ghost">Logout</a>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<nav class="breadcrumb">
|
||||||
|
<a href="<?= BASE_URL ?>/admin/">Sessions</a>
|
||||||
|
<span class="bc-sep">/</span>
|
||||||
|
<span><?= htmlspecialchars($group['name']) ?></span>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<?php if (isset($_GET['deleted'])): ?>
|
||||||
|
<div class="alert alert-success">Day deleted successfully.</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if ($save_success): ?>
|
||||||
|
<div class="alert alert-success">✓ Novena updated. Changes applied to all 9 day sessions.</div>
|
||||||
|
<?php elseif ($save_error): ?>
|
||||||
|
<div class="alert alert-error"><?= htmlspecialchars($save_error) ?></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<section class="card" style="margin-bottom:32px">
|
||||||
|
<h2 class="card-title">Novena Details</h2>
|
||||||
|
<form method="post">
|
||||||
|
<input type="hidden" name="save_group" value="1">
|
||||||
|
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="g_name">Novena Name</label>
|
||||||
|
<input type="text" id="g_name" name="g_name" class="form-input"
|
||||||
|
value="<?= htmlspecialchars($group['name']) ?>" required>
|
||||||
|
<p class="form-help">Used as the base name for all 9 days.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if (!$is_dm): ?>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="g_mystery">Mysteries</label>
|
||||||
|
<select id="g_mystery" name="g_mystery" class="form-input">
|
||||||
|
<?php foreach ([
|
||||||
|
'sorrowful' => 'All Sorrowful Mysteries',
|
||||||
|
'by_day_of_week' => 'By Day of Week',
|
||||||
|
'joyful' => 'All Joyful Mysteries',
|
||||||
|
'glorious' => 'All Glorious Mysteries',
|
||||||
|
'luminous' => 'All Luminous Mysteries',
|
||||||
|
] as $val => $label): ?>
|
||||||
|
<option value="<?= $val ?>" <?= $group['mystery_set'] === $val ? 'selected' : '' ?>>
|
||||||
|
<?= $label ?>
|
||||||
|
</option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="g_subject">Name of Deceased</label>
|
||||||
|
<input type="text" id="g_subject" name="g_subject" class="form-input"
|
||||||
|
value="<?= htmlspecialchars($group['subject_name'] ?? '') ?>">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Pronoun</label>
|
||||||
|
<div class="radio-row">
|
||||||
|
<label><input type="radio" name="g_pronoun" value="he"
|
||||||
|
<?= ($group['subject_pronoun'] ?? 'he') === 'he' ? 'checked' : '' ?>> He / Him</label>
|
||||||
|
<label><input type="radio" name="g_pronoun" value="she"
|
||||||
|
<?= ($group['subject_pronoun'] ?? '') === 'she' ? 'checked' : '' ?>> She / Her</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="g_dates">Life Dates</label>
|
||||||
|
<input type="text" id="g_dates" name="g_dates" class="form-input"
|
||||||
|
placeholder="e.g., March 15, 1942 – June 3, 2025"
|
||||||
|
value="<?= htmlspecialchars($group['subject_dates'] ?? '') ?>">
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<div class="form-group" id="photo-group">
|
||||||
|
<label class="form-label">Photo</label>
|
||||||
|
<?php if ($group['photo_path']): ?>
|
||||||
|
<div class="photo-preview-wrap">
|
||||||
|
<img id="photo-preview" src="<?= htmlspecialchars('/' . ltrim($group['photo_path'], '/')) ?>"
|
||||||
|
alt="Current photo" class="photo-preview">
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="photo-preview-wrap" style="display:none">
|
||||||
|
<img id="photo-preview" src="" alt="" class="photo-preview">
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<input type="hidden" id="g_photo" name="g_photo"
|
||||||
|
value="<?= htmlspecialchars($group['photo_path'] ?? '') ?>">
|
||||||
|
<label class="btn btn-secondary btn-upload" style="margin-top:8px">
|
||||||
|
<?= $group['photo_path'] ? 'Replace Photo' : 'Upload Photo' ?>
|
||||||
|
<input type="file" id="photo-file" accept="image/*" style="display:none">
|
||||||
|
</label>
|
||||||
|
<span id="photo-status" class="form-help"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label style="display:flex;align-items:center;gap:10px;cursor:pointer">
|
||||||
|
<input type="checkbox" name="is_public" value="1"
|
||||||
|
<?= $group['is_public'] ? 'checked' : '' ?>
|
||||||
|
style="width:18px;height:18px">
|
||||||
|
<span>Public (visible on your profile)</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" class="btn btn-primary">Save Changes to All Days</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2 style="margin-bottom:16px">Days (<?= count($days) ?> of 9)</h2>
|
||||||
|
<div class="sessions-table-wrap">
|
||||||
|
<table class="sessions-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Day</th>
|
||||||
|
<th>Mysteries</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php for ($d = 1; $d <= 9; $d++):
|
||||||
|
$ses = null;
|
||||||
|
foreach ($days as $day_row) {
|
||||||
|
if ((int)$day_row['novena_day'] === $d) { $ses = $day_row; break; }
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
<tr <?= !$ses ? 'class="day-missing"' : '' ?>>
|
||||||
|
<td><strong>Day <?= $d ?></strong></td>
|
||||||
|
<td><?= $ses ? htmlspecialchars($mystery_labels[$ses['mystery_set']] ?? $ses['mystery_set']) : '<em>missing</em>' ?></td>
|
||||||
|
<td class="actions">
|
||||||
|
<?php if ($ses): ?>
|
||||||
|
<a href="<?= BASE_URL ?>/present.php?id=<?= $ses['id'] ?>"
|
||||||
|
target="_blank"
|
||||||
|
class="btn btn-sm btn-primary">Present</a>
|
||||||
|
<form method="post" style="display:inline"
|
||||||
|
onsubmit="return confirm('Delete Day <?= $d ?>?')">
|
||||||
|
<input type="hidden" name="delete_day_id" value="<?= $ses['id'] ?>">
|
||||||
|
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
|
||||||
|
</form>
|
||||||
|
<?php else: ?>
|
||||||
|
<span class="text-muted">—</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endfor; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var fileInput = document.getElementById('photo-file');
|
||||||
|
var photoHidden = document.getElementById('g_photo');
|
||||||
|
var photoStatus = document.getElementById('photo-status');
|
||||||
|
var previewWrap = document.querySelector('.photo-preview-wrap');
|
||||||
|
var previewImg = document.getElementById('photo-preview');
|
||||||
|
|
||||||
|
if (!fileInput) return;
|
||||||
|
|
||||||
|
fileInput.addEventListener('change', function () {
|
||||||
|
var file = fileInput.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
var fd = new FormData();
|
||||||
|
fd.append('photo', file);
|
||||||
|
photoStatus.textContent = 'Uploading\u2026';
|
||||||
|
|
||||||
|
fetch(BASE_URL + '/api/upload_photo.php', { method: 'POST', body: fd })
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (data) {
|
||||||
|
if (data.path) {
|
||||||
|
photoHidden.value = data.path;
|
||||||
|
previewImg.src = '/' + data.path.replace(/^\//, '');
|
||||||
|
previewWrap.style.display = '';
|
||||||
|
photoStatus.textContent = 'Photo ready.';
|
||||||
|
} else {
|
||||||
|
photoStatus.textContent = 'Upload failed: ' + (data.error || 'unknown error');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function () { photoStatus.textContent = 'Upload failed.'; });
|
||||||
|
});
|
||||||
|
}());
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* admin/profile.php — Current user's account settings.
|
||||||
|
*/
|
||||||
|
require_once __DIR__ . '/../config/db.php';
|
||||||
|
require_once __DIR__ . '/../includes/auth.php';
|
||||||
|
|
||||||
|
require_auth();
|
||||||
|
|
||||||
|
$pdo = get_pdo();
|
||||||
|
$user = current_user();
|
||||||
|
$uid = (int)$user['id'];
|
||||||
|
$site_name = get_setting('site_name', APP_NAME);
|
||||||
|
$messages = [];
|
||||||
|
$errors = [];
|
||||||
|
|
||||||
|
// Superadmin can view/edit another user's profile via ?id=X
|
||||||
|
$target_id = $uid;
|
||||||
|
if (has_role('superadmin') && isset($_GET['id'])) {
|
||||||
|
$target_id = (int)$_GET['id'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load target user
|
||||||
|
$stmt = $pdo->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'];
|
||||||
|
?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<link rel="icon" type="image/svg+xml" href="<?= BASE_URL ?>/favicon.svg">
|
||||||
|
<title><?= $is_own ? 'My Account' : 'Edit User' ?> — <?= htmlspecialchars($site_name) ?></title>
|
||||||
|
<link rel="stylesheet" href="<?= BASE_URL ?>/assets/css/setup.css">
|
||||||
|
<style>
|
||||||
|
.profile-section{background:#fff;border:1px solid #e5e7eb;border-radius:8px;padding:28px;margin-bottom:24px}
|
||||||
|
.profile-section h3{margin:0 0 20px;font-size:16px;font-weight:700;color:#1e3a5f;border-bottom:2px solid #e5e7eb;padding-bottom:10px}
|
||||||
|
.readonly-field{background:#f9fafb;border:1px solid #e5e7eb;border-radius:6px;padding:8px 12px;font-size:14px;color:#374151}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="admin-container">
|
||||||
|
|
||||||
|
<header class="admin-header">
|
||||||
|
<h1>✝ <?= htmlspecialchars($site_name) ?></h1>
|
||||||
|
<div class="header-actions">
|
||||||
|
<a href="<?= BASE_URL ?>/" class="btn btn-ghost" style="font-size:13px">← View Site</a>
|
||||||
|
<a href="<?= BASE_URL ?>/admin/" class="btn btn-ghost">Dashboard</a>
|
||||||
|
<?php if (has_role('admin')): ?>
|
||||||
|
<a href="<?= BASE_URL ?>/admin/users.php" class="btn btn-ghost">Users</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if ($is_super): ?>
|
||||||
|
<a href="<?= BASE_URL ?>/admin/settings.php" class="btn btn-ghost">Settings</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
<a href="<?= BASE_URL ?>/logout" class="btn btn-ghost">Logout</a>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<?php foreach ($messages as $m): ?>
|
||||||
|
<div class="alert alert-success">✓ <?= htmlspecialchars($m) ?></div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php foreach ($errors as $e): ?>
|
||||||
|
<div class="alert alert-error"><?= htmlspecialchars($e) ?></div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
|
||||||
|
<h2 style="margin-bottom:24px"><?= $is_own ? 'My Account' : 'Edit: ' . htmlspecialchars($profile['username']) ?></h2>
|
||||||
|
|
||||||
|
<!-- Account Info -->
|
||||||
|
<div class="profile-section">
|
||||||
|
<h3>Account Info</h3>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Username</label>
|
||||||
|
<div class="readonly-field"><?= htmlspecialchars($profile['username']) ?></div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Role</label>
|
||||||
|
<div class="readonly-field"><?= htmlspecialchars($role_labels[$profile['role']] ?? $profile['role']) ?></div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Rosary Limit</label>
|
||||||
|
<div class="readonly-field">
|
||||||
|
<?= $profile['rosary_limit'] < 0 ? 'Unlimited' : (int)$profile['rosary_limit'] ?>
|
||||||
|
<?php if ($is_super && $target_id !== $uid): ?>
|
||||||
|
<a href="#limit-section" style="font-size:12px">(edit below)</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Member Since</label>
|
||||||
|
<div class="readonly-field"><?= date('F j, Y', strtotime($profile['created_at'])) ?></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Display Name -->
|
||||||
|
<div class="profile-section">
|
||||||
|
<h3>Display Name</h3>
|
||||||
|
<form method="post">
|
||||||
|
<input type="hidden" name="action" value="update_profile">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="display_name">Display Name</label>
|
||||||
|
<input type="text" id="display_name" name="display_name"
|
||||||
|
maxlength="100"
|
||||||
|
value="<?= htmlspecialchars($profile['display_name'] ?? '') ?>">
|
||||||
|
<p class="help-text">Shown publicly on your profile and rosary cards.</p>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">Save</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Email -->
|
||||||
|
<div class="profile-section">
|
||||||
|
<h3>Email Address</h3>
|
||||||
|
<form method="post">
|
||||||
|
<input type="hidden" name="action" value="update_email">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Current Email</label>
|
||||||
|
<div class="readonly-field"><?= htmlspecialchars($profile['email']) ?></div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="new_email">New Email</label>
|
||||||
|
<input type="email" id="new_email" name="new_email" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="cur_pass_email">Current Password (to confirm)</label>
|
||||||
|
<input type="password" id="cur_pass_email" name="current_password" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">Update Email</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Change Password -->
|
||||||
|
<div class="profile-section">
|
||||||
|
<h3>Change Password</h3>
|
||||||
|
<form method="post">
|
||||||
|
<input type="hidden" name="action" value="change_password">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="cur_pass">Current Password</label>
|
||||||
|
<input type="password" id="cur_pass" name="current_password" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="new_pass">New Password</label>
|
||||||
|
<input type="password" id="new_pass" name="new_password" minlength="8" required>
|
||||||
|
<p class="help-text">At least 8 characters.</p>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="conf_pass">Confirm New Password</label>
|
||||||
|
<input type="password" id="conf_pass" name="confirm_password" minlength="8" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">Change Password</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Rosary Limit (superadmin editing another user) -->
|
||||||
|
<?php if ($is_super && !$is_own): ?>
|
||||||
|
<div class="profile-section" id="limit-section">
|
||||||
|
<h3>Rosary Limit</h3>
|
||||||
|
<form method="post">
|
||||||
|
<input type="hidden" name="action" value="update_limit">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="rosary_limit">Limit (-1 = unlimited)</label>
|
||||||
|
<input type="number" id="rosary_limit" name="rosary_limit"
|
||||||
|
min="-1" value="<?= (int)$profile['rosary_limit'] ?>">
|
||||||
|
<p class="help-text">How many rosary sessions this user can create.</p>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">Save Limit</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* admin/settings.php — Site settings. Requires superadmin role.
|
||||||
|
*/
|
||||||
|
require_once __DIR__ . '/../config/db.php';
|
||||||
|
require_once __DIR__ . '/../includes/auth.php';
|
||||||
|
require_once __DIR__ . '/../includes/mailer.php';
|
||||||
|
|
||||||
|
require_role('superadmin');
|
||||||
|
|
||||||
|
$user = current_user();
|
||||||
|
$site_name = get_setting('site_name', APP_NAME);
|
||||||
|
$message = '';
|
||||||
|
$error = '';
|
||||||
|
|
||||||
|
// Save settings
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
$action = $_POST['action'] ?? 'save';
|
||||||
|
|
||||||
|
if ($action === 'save') {
|
||||||
|
$keys = ['site_name','site_url','smtp_host','smtp_port','smtp_user','smtp_pass','smtp_from','smtp_from_name',
|
||||||
|
'donate_enabled','donate_type','donate_handle','donate_label'];
|
||||||
|
foreach ($keys as $k) {
|
||||||
|
if (isset($_POST[$k])) {
|
||||||
|
set_setting($k, trim($_POST[$k]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$message = 'Settings saved.';
|
||||||
|
$site_name = get_setting('site_name', APP_NAME); // refresh
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'test_email') {
|
||||||
|
$to = $user['email'];
|
||||||
|
$tname = $user['display_name'] ?: $user['username'];
|
||||||
|
$body = email_template(
|
||||||
|
'Test Email — ' . $site_name,
|
||||||
|
"<h2 style='margin-top:0;color:#1e3a5f'>Test Email</h2>
|
||||||
|
<p>Hello, <strong>" . htmlspecialchars($tname) . "</strong>!</p>
|
||||||
|
<p>This is a test email from your {$site_name} installation. If you received this, your SMTP settings are working correctly.</p>"
|
||||||
|
);
|
||||||
|
$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', ''),
|
||||||
|
];
|
||||||
|
?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<link rel="icon" type="image/svg+xml" href="<?= BASE_URL ?>/favicon.svg">
|
||||||
|
<title>Settings — <?= htmlspecialchars($site_name) ?></title>
|
||||||
|
<link rel="stylesheet" href="<?= BASE_URL ?>/assets/css/setup.css">
|
||||||
|
<style>
|
||||||
|
.settings-section { background:#fff;border:1px solid #e5e7eb;border-radius:8px;padding:28px;margin-bottom:24px }
|
||||||
|
.settings-section h3 { margin:0 0 20px;font-size:16px;font-weight:700;color:#1e3a5f;border-bottom:2px solid #e5e7eb;padding-bottom:10px }
|
||||||
|
.pass-wrap { position:relative }
|
||||||
|
.pass-wrap input { padding-right:80px }
|
||||||
|
.pass-toggle { position:absolute;right:10px;top:50%;transform:translateY(-50%);background:none;border:none;cursor:pointer;font-size:12px;color:#6b7280;font-weight:600 }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="admin-container">
|
||||||
|
|
||||||
|
<header class="admin-header">
|
||||||
|
<h1>✝ <?= htmlspecialchars($site_name) ?></h1>
|
||||||
|
<div class="header-actions">
|
||||||
|
<a href="<?= BASE_URL ?>/" class="btn btn-ghost" style="font-size:13px">← View Site</a>
|
||||||
|
<a href="<?= BASE_URL ?>/admin/" class="btn btn-ghost">Dashboard</a>
|
||||||
|
<a href="<?= BASE_URL ?>/admin/users.php" class="btn btn-ghost">Users</a>
|
||||||
|
<a href="<?= BASE_URL ?>/admin/profile.php" class="btn btn-ghost"><?= htmlspecialchars($user['display_name'] ?: $user['username']) ?></a>
|
||||||
|
<a href="<?= BASE_URL ?>/logout" class="btn btn-ghost">Logout</a>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<?php if ($message): ?>
|
||||||
|
<div class="alert alert-success">✓ <?= htmlspecialchars($message) ?></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if ($error): ?>
|
||||||
|
<div class="alert alert-error"><?= htmlspecialchars($error) ?></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<h2 style="margin-bottom:24px">Site Settings</h2>
|
||||||
|
|
||||||
|
<form method="post">
|
||||||
|
<input type="hidden" name="action" value="save">
|
||||||
|
|
||||||
|
<div class="settings-section">
|
||||||
|
<h3>General</h3>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="site_name">Site Name</label>
|
||||||
|
<input type="text" id="site_name" name="site_name"
|
||||||
|
value="<?= htmlspecialchars($settings['site_name']) ?>" required>
|
||||||
|
<p class="help-text">Displayed in the browser tab, emails, and the site header.</p>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="site_url">Site URL</label>
|
||||||
|
<input type="url" id="site_url" name="site_url"
|
||||||
|
placeholder="https://yourdomain.com"
|
||||||
|
value="<?= htmlspecialchars($settings['site_url']) ?>">
|
||||||
|
<p class="help-text">Used for links in emails. Include protocol, no trailing slash.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-section">
|
||||||
|
<h3>SMTP Email</h3>
|
||||||
|
<p class="help-text" style="margin-top:0;margin-bottom:20px">
|
||||||
|
Leave SMTP Host blank to use PHP's built-in <code>mail()</code> function.
|
||||||
|
For Gmail: host=smtp.gmail.com, port=587, use an App Password.
|
||||||
|
</p>
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="smtp_host">SMTP Host</label>
|
||||||
|
<input type="text" id="smtp_host" name="smtp_host"
|
||||||
|
placeholder="smtp.gmail.com"
|
||||||
|
value="<?= htmlspecialchars($settings['smtp_host']) ?>">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="smtp_port">SMTP Port</label>
|
||||||
|
<input type="number" id="smtp_port" name="smtp_port"
|
||||||
|
placeholder="587" min="1" max="65535"
|
||||||
|
value="<?= htmlspecialchars($settings['smtp_port']) ?>">
|
||||||
|
<p class="help-text">587 for STARTTLS, 465 for SSL.</p>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="smtp_user">SMTP Username</label>
|
||||||
|
<input type="text" id="smtp_user" name="smtp_user"
|
||||||
|
autocomplete="off"
|
||||||
|
value="<?= htmlspecialchars($settings['smtp_user']) ?>">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="smtp_pass">SMTP Password</label>
|
||||||
|
<div class="pass-wrap">
|
||||||
|
<input type="password" id="smtp_pass" name="smtp_pass"
|
||||||
|
autocomplete="new-password"
|
||||||
|
value="<?= htmlspecialchars($settings['smtp_pass']) ?>">
|
||||||
|
<button type="button" class="pass-toggle" onclick="togglePass()">Show</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="smtp_from">From Email</label>
|
||||||
|
<input type="email" id="smtp_from" name="smtp_from"
|
||||||
|
placeholder="noreply@yourdomain.com"
|
||||||
|
value="<?= htmlspecialchars($settings['smtp_from']) ?>">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="smtp_from_name">From Name</label>
|
||||||
|
<input type="text" id="smtp_from_name" name="smtp_from_name"
|
||||||
|
value="<?= htmlspecialchars($settings['smtp_from_name']) ?>">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-section">
|
||||||
|
<h3>Donate Button</h3>
|
||||||
|
<p class="help-text" style="margin-top:0;margin-bottom:20px">
|
||||||
|
Show a small donation strip on the public home page. Choose a service, enter your handle or URL, and enable it when ready.
|
||||||
|
</p>
|
||||||
|
<div class="form-group">
|
||||||
|
<label style="display:flex;align-items:center;gap:10px;cursor:pointer">
|
||||||
|
<input type="checkbox" name="donate_enabled" value="1"
|
||||||
|
id="donate_enabled"
|
||||||
|
<?= $settings['donate_enabled'] ? 'checked' : '' ?>
|
||||||
|
style="width:18px;height:18px">
|
||||||
|
<span>Enable donate strip on public pages</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-grid" id="donate-fields">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="donate_type">Service</label>
|
||||||
|
<select id="donate_type" name="donate_type" class="form-input" onchange="updateDonateHint()">
|
||||||
|
<option value="paypal" <?= $settings['donate_type'] === 'paypal' ? 'selected' : '' ?>>PayPal (paypal.me)</option>
|
||||||
|
<option value="venmo" <?= $settings['donate_type'] === 'venmo' ? 'selected' : '' ?>>Venmo</option>
|
||||||
|
<option value="buymeacoffee" <?= $settings['donate_type'] === 'buymeacoffee' ? 'selected' : '' ?>>Buy Me a Coffee</option>
|
||||||
|
<option value="custom" <?= $settings['donate_type'] === 'custom' ? 'selected' : '' ?>>Custom URL</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="donate_handle" id="donate_handle_label">Handle / URL</label>
|
||||||
|
<input type="text" id="donate_handle" name="donate_handle"
|
||||||
|
value="<?= htmlspecialchars($settings['donate_handle']) ?>"
|
||||||
|
placeholder="">
|
||||||
|
<p class="help-text" id="donate_hint"></p>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="donate_label">Button Label <span style="font-weight:400;color:#6b7280">(optional)</span></label>
|
||||||
|
<input type="text" id="donate_label" name="donate_label"
|
||||||
|
value="<?= htmlspecialchars($settings['donate_label']) ?>"
|
||||||
|
placeholder="Leave blank for default">
|
||||||
|
<p class="help-text">Overrides the default label for the selected service.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" class="btn btn-primary">Save Settings</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="settings-section" style="margin-top:24px">
|
||||||
|
<h3>Test Email</h3>
|
||||||
|
<p class="help-text">Send a test email to <strong><?= htmlspecialchars($user['email']) ?></strong> to verify your SMTP settings.</p>
|
||||||
|
<form method="post">
|
||||||
|
<input type="hidden" name="action" value="test_email">
|
||||||
|
<button type="submit" class="btn btn-secondary">Send Test Email</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
var donateHints = {
|
||||||
|
paypal: { label: 'PayPal.me username', hint: 'Enter your PayPal.me handle, e.g. YourName — link becomes paypal.me/YourName' },
|
||||||
|
venmo: { label: 'Venmo username', hint: 'Enter your Venmo handle without @, e.g. YourName — link becomes venmo.com/u/YourName' },
|
||||||
|
buymeacoffee: { label: 'Buy Me a Coffee username', hint: 'Enter your buymeacoffee.com handle, e.g. YourName' },
|
||||||
|
custom: { label: 'Full URL', hint: 'Enter the complete URL, e.g. https://example.com/donate' },
|
||||||
|
};
|
||||||
|
|
||||||
|
function updateDonateHint() {
|
||||||
|
var type = document.getElementById('donate_type').value;
|
||||||
|
var info = donateHints[type] || donateHints.custom;
|
||||||
|
document.getElementById('donate_handle_label').textContent = info.label;
|
||||||
|
document.getElementById('donate_hint').textContent = info.hint;
|
||||||
|
}
|
||||||
|
updateDonateHint();
|
||||||
|
|
||||||
|
function togglePass() {
|
||||||
|
var inp = document.getElementById('smtp_pass');
|
||||||
|
var btn = inp.nextElementSibling;
|
||||||
|
if (inp.type === 'password') {
|
||||||
|
inp.type = 'text';
|
||||||
|
btn.textContent = 'Hide';
|
||||||
|
} else {
|
||||||
|
inp.type = 'password';
|
||||||
|
btn.textContent = 'Show';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+250
@@ -0,0 +1,250 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* admin/setup.php — Create or edit a rosary session.
|
||||||
|
*/
|
||||||
|
require_once __DIR__ . '/../config/db.php';
|
||||||
|
require_once __DIR__ . '/../includes/auth.php';
|
||||||
|
|
||||||
|
require_auth();
|
||||||
|
|
||||||
|
$pdo = get_pdo();
|
||||||
|
$user = current_user();
|
||||||
|
$uid = (int)$user['id'];
|
||||||
|
$site_name = get_setting('site_name', APP_NAME);
|
||||||
|
|
||||||
|
// Load existing session for editing
|
||||||
|
$session = null;
|
||||||
|
if (isset($_GET['id'])) {
|
||||||
|
$stmt = $pdo->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';
|
||||||
|
?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<link rel="icon" type="image/svg+xml" href="<?= BASE_URL ?>/favicon.svg">
|
||||||
|
<title><?= $page_title ?> — <?= htmlspecialchars($site_name) ?></title>
|
||||||
|
<link rel="stylesheet" href="<?= BASE_URL ?>/assets/css/setup.css">
|
||||||
|
<script>var BASE_URL = '<?= BASE_URL ?>';</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="admin-container">
|
||||||
|
<header class="admin-header">
|
||||||
|
<h1>✝ <?= htmlspecialchars($site_name) ?></h1>
|
||||||
|
<div class="header-actions">
|
||||||
|
<a href="<?= BASE_URL ?>/" class="btn btn-ghost" style="font-size:13px">← View Site</a>
|
||||||
|
<?php if (has_role('admin')): ?>
|
||||||
|
<a href="<?= BASE_URL ?>/admin/users.php" class="btn btn-ghost">Users</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if (has_role('superadmin')): ?>
|
||||||
|
<a href="<?= BASE_URL ?>/admin/settings.php" class="btn btn-ghost">Settings</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
<a href="<?= BASE_URL ?>/admin/profile.php" class="btn btn-ghost"><?= htmlspecialchars($user['display_name'] ?: $user['username']) ?></a>
|
||||||
|
<a href="<?= BASE_URL ?>/admin/" class="btn btn-ghost">← Back</a>
|
||||||
|
<a href="<?= BASE_URL ?>/logout" class="btn btn-ghost">Logout</a>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<h2><?= $page_title ?></h2>
|
||||||
|
|
||||||
|
<?php if ($limit_error): ?>
|
||||||
|
<div class="alert alert-error"><?= htmlspecialchars($limit_error) ?></div>
|
||||||
|
<?php else: ?>
|
||||||
|
|
||||||
|
<div id="form-message" class="alert" style="display:none"></div>
|
||||||
|
|
||||||
|
<form id="session-form" novalidate>
|
||||||
|
<?php if ($session): ?>
|
||||||
|
<input type="hidden" name="id" value="<?= (int)$session['id'] ?>">
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<!-- Session Name -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="name">Session Label <span class="required">*</span></label>
|
||||||
|
<input type="text" id="name" name="name"
|
||||||
|
placeholder="e.g. Medy"
|
||||||
|
value="<?= htmlspecialchars($session['name'] ?? '') ?>"
|
||||||
|
required>
|
||||||
|
<p class="help-text" id="name-help">
|
||||||
|
For novena: enter just the name (e.g. "Medy") — sessions will be created as
|
||||||
|
"Medy — Day 1" through "Medy — Day 9".
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Occasion -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="occasion">Occasion <span class="required">*</span></label>
|
||||||
|
<select id="occasion" name="occasion" required>
|
||||||
|
<option value="">— Select —</option>
|
||||||
|
<option value="novena_deceased"
|
||||||
|
<?= ($session['occasion'] ?? '') === 'novena_deceased' ? 'selected' : '' ?>>
|
||||||
|
Novena for Deceased
|
||||||
|
</option>
|
||||||
|
<option value="divine_mercy_novena"
|
||||||
|
<?= ($session['occasion'] ?? '') === 'divine_mercy_novena' ? 'selected' : '' ?>>
|
||||||
|
Divine Mercy Novena
|
||||||
|
</option>
|
||||||
|
<option value="general_rosary"
|
||||||
|
<?= ($session['occasion'] ?? '') === 'general_rosary' ? 'selected' : '' ?>>
|
||||||
|
General Rosary
|
||||||
|
</option>
|
||||||
|
<option value="memorial"
|
||||||
|
<?= ($session['occasion'] ?? '') === 'memorial' ? 'selected' : '' ?>>
|
||||||
|
Memorial / Month's Mind
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mystery Set (non-novena) -->
|
||||||
|
<div class="form-group" id="field-mystery_set_standard">
|
||||||
|
<label for="mystery_set">Mystery Set <span class="required">*</span></label>
|
||||||
|
<select id="mystery_set" name="mystery_set" required>
|
||||||
|
<option value="">— Select —</option>
|
||||||
|
<option value="sorrowful"
|
||||||
|
<?= ($session['mystery_set'] ?? '') === 'sorrowful' ? 'selected' : '' ?>>
|
||||||
|
Sorrowful Mysteries
|
||||||
|
</option>
|
||||||
|
<option value="joyful"
|
||||||
|
<?= ($session['mystery_set'] ?? '') === 'joyful' ? 'selected' : '' ?>>
|
||||||
|
Joyful Mysteries
|
||||||
|
</option>
|
||||||
|
<option value="glorious"
|
||||||
|
<?= ($session['mystery_set'] ?? '') === 'glorious' ? 'selected' : '' ?>>
|
||||||
|
Glorious Mysteries
|
||||||
|
</option>
|
||||||
|
<option value="luminous"
|
||||||
|
<?= ($session['mystery_set'] ?? '') === 'luminous' ? 'selected' : '' ?>>
|
||||||
|
Luminous Mysteries
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Novena Mystery Mode (only shown for novena_deceased) -->
|
||||||
|
<div class="form-group conditional-field" id="field-novena_mystery_mode" style="display:none">
|
||||||
|
<label for="novena_mystery_mode">Mysteries for this Novena <span class="required">*</span></label>
|
||||||
|
<select id="novena_mystery_mode" name="novena_mystery_mode">
|
||||||
|
<option value="all_sorrowful"
|
||||||
|
<?= ($session['mystery_set'] ?? '') === 'sorrowful' || ($session['mystery_set'] ?? '') === 'all_sorrowful' ? 'selected' : '' ?>>
|
||||||
|
All Sorrowful (traditional for deceased)
|
||||||
|
</option>
|
||||||
|
<option value="by_day_of_week"
|
||||||
|
<?= ($session['mystery_set'] ?? '') === 'by_day_of_week' ? 'selected' : '' ?>>
|
||||||
|
By Day of Week — auto-selected at presentation time
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<p class="help-text">
|
||||||
|
Day-of-week schedule: Sun & Wed = Glorious · Mon & Sat = Joyful ·
|
||||||
|
Tue & Fri = Sorrowful · Thu = Luminous
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Novena Day -->
|
||||||
|
<div class="form-group conditional-field" id="field-novena_day" style="display:none">
|
||||||
|
<?php if ($session): ?>
|
||||||
|
<label>Novena Day</label>
|
||||||
|
<div class="form-static">Day <?= (int)($session['novena_day'] ?? 1) ?> of 9</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="novena-auto-notice">
|
||||||
|
✓ Will automatically create <strong>Day 1 through Day 9</strong>.
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Subject Name (conditional) -->
|
||||||
|
<div class="form-group conditional-field" id="field-subject_name" style="display:none">
|
||||||
|
<label for="subject_name">Name of Deceased / Honoree</label>
|
||||||
|
<input type="text" id="subject_name" name="subject_name"
|
||||||
|
placeholder="e.g. Maria Santos"
|
||||||
|
value="<?= htmlspecialchars($session['subject_name'] ?? '') ?>">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pronoun (conditional) -->
|
||||||
|
<div class="form-group conditional-field" id="field-subject_pronoun" style="display:none">
|
||||||
|
<label for="subject_pronoun">Pronoun</label>
|
||||||
|
<select id="subject_pronoun" name="subject_pronoun">
|
||||||
|
<option value="he"
|
||||||
|
<?= ($session['subject_pronoun'] ?? 'he') === 'he' ? 'selected' : '' ?>>
|
||||||
|
He / Him / His
|
||||||
|
</option>
|
||||||
|
<option value="she"
|
||||||
|
<?= ($session['subject_pronoun'] ?? '') === 'she' ? 'selected' : '' ?>>
|
||||||
|
She / Her / Her
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Dates (conditional) -->
|
||||||
|
<div class="form-group conditional-field" id="field-subject_dates" style="display:none">
|
||||||
|
<label for="subject_dates">Life Dates</label>
|
||||||
|
<input type="text" id="subject_dates" name="subject_dates"
|
||||||
|
placeholder="e.g. Aug 12, 1949 – July 25, 2025"
|
||||||
|
value="<?= htmlspecialchars($session['subject_dates'] ?? '') ?>">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Photo -->
|
||||||
|
<div class="form-group" id="field-photo">
|
||||||
|
<label for="photo">Photo (optional)</label>
|
||||||
|
<?php if (!empty($session['photo_path'])): ?>
|
||||||
|
<div class="photo-preview">
|
||||||
|
<img src="<?= htmlspecialchars('/' . ltrim($session['photo_path'], '/')) ?>"
|
||||||
|
alt="Current photo" style="max-height:120px">
|
||||||
|
<p class="help-text">Current photo. Upload a new one to replace it.</p>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<input type="file" id="photo" name="photo" accept="image/*">
|
||||||
|
<p class="help-text">JPEG, PNG, WebP — max 5 MB. Recommended: square or landscape, at least 800 × 600 px. Shown on the home page card and cover slide.</p>
|
||||||
|
<input type="hidden" id="photo_path" name="photo_path"
|
||||||
|
value="<?= htmlspecialchars($session['photo_path'] ?? '') ?>">
|
||||||
|
<div id="upload-status" style="display:none" class="upload-status"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Public toggle -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label style="display:flex;align-items:center;gap:10px;cursor:pointer">
|
||||||
|
<input type="checkbox" name="is_public" value="1"
|
||||||
|
<?= (!$session || $session['is_public']) ? 'checked' : '' ?>
|
||||||
|
style="width:18px;height:18px">
|
||||||
|
<span>Make this session public (visible on your profile and the home page)</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" class="btn btn-primary btn-large" id="submit-btn"
|
||||||
|
data-label-default="<?= $session ? 'Save Changes' : 'Create Session' ?>"
|
||||||
|
data-label-novena-deceased="<?= $session ? 'Save Changes' : 'Create 9-Day Novena' ?>"
|
||||||
|
data-label-divine-mercy="<?= $session ? 'Save Changes' : 'Create Divine Mercy Novena' ?>">
|
||||||
|
<?= $session ? 'Save Changes' : 'Create Session' ?>
|
||||||
|
</button>
|
||||||
|
<a href="<?= BASE_URL ?>/admin/" class="btn btn-ghost">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<?php endif; ?>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="<?= BASE_URL ?>/assets/js/setup.js?v=5"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+425
@@ -0,0 +1,425 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* admin/users.php — User management. Requires admin role.
|
||||||
|
*/
|
||||||
|
require_once __DIR__ . '/../config/db.php';
|
||||||
|
require_once __DIR__ . '/../includes/auth.php';
|
||||||
|
require_once __DIR__ . '/../includes/mailer.php';
|
||||||
|
|
||||||
|
require_role('admin');
|
||||||
|
|
||||||
|
$pdo = get_pdo();
|
||||||
|
$user = current_user();
|
||||||
|
$uid = (int)$user['id'];
|
||||||
|
$is_super = has_role('superadmin');
|
||||||
|
$site_name = get_setting('site_name', APP_NAME);
|
||||||
|
$messages = [];
|
||||||
|
$errors = [];
|
||||||
|
|
||||||
|
// ── Handle actions ───────────────────────────────────────────────────────────
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
$action = $_POST['action'] ?? '';
|
||||||
|
|
||||||
|
// ── Create user ──────────────────────────────────────────────────────────
|
||||||
|
if ($action === 'create_user') {
|
||||||
|
$new_username = trim($_POST['new_username'] ?? '');
|
||||||
|
$new_display_name = trim($_POST['new_display_name'] ?? '');
|
||||||
|
$new_email = trim($_POST['new_email'] ?? '');
|
||||||
|
$new_password = $_POST['new_password'] ?? '';
|
||||||
|
$new_role = $_POST['new_role'] ?? 'user';
|
||||||
|
$new_limit = (int)($_POST['new_rosary_limit'] ?? 1);
|
||||||
|
|
||||||
|
// Validate role — admins can't set superadmin
|
||||||
|
$allowed_roles = $is_super ? ['user','superuser','admin','superadmin'] : ['user','superuser'];
|
||||||
|
if (!in_array($new_role, $allowed_roles, true)) $new_role = 'user';
|
||||||
|
|
||||||
|
if (!preg_match('/^[a-zA-Z0-9_]{3,30}$/', $new_username)) {
|
||||||
|
$errors[] = 'Invalid username.';
|
||||||
|
} elseif (!filter_var($new_email, FILTER_VALIDATE_EMAIL)) {
|
||||||
|
$errors[] = 'Invalid email.';
|
||||||
|
} elseif (strlen($new_password) < 8) {
|
||||||
|
$errors[] = 'Password must be at least 8 characters.';
|
||||||
|
} else {
|
||||||
|
$chk = $pdo->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 = "
|
||||||
|
<h2 style='margin-top:0;color:#1e3a5f'>Confirm your email</h2>
|
||||||
|
<p>Hello, <strong>" . htmlspecialchars($disp) . "</strong>!</p>
|
||||||
|
<p>An administrator has resent your confirmation email for {$site_name}. Click the button below to confirm your email address and activate your account:</p>
|
||||||
|
<p style='text-align:center;margin:28px 0'>
|
||||||
|
<a href='" . htmlspecialchars($link) . "' style='display:inline-block;background:#1e3a5f;color:#fff;padding:12px 28px;border-radius:6px;text-decoration:none;font-weight:600'>Confirm Email</a>
|
||||||
|
</p>
|
||||||
|
<p style='color:#6b7280;font-size:13px'>Or copy this link: " . htmlspecialchars($link) . "</p>
|
||||||
|
<p style='color:#6b7280;font-size:13px'>If you did not register, ignore this email.</p>
|
||||||
|
";
|
||||||
|
$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',
|
||||||
|
];
|
||||||
|
?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<link rel="icon" type="image/svg+xml" href="<?= BASE_URL ?>/favicon.svg">
|
||||||
|
<title>Users — <?= htmlspecialchars($site_name) ?></title>
|
||||||
|
<link rel="stylesheet" href="<?= BASE_URL ?>/assets/css/setup.css">
|
||||||
|
<style>
|
||||||
|
.role-badge{display:inline-block;padding:2px 10px;border-radius:99px;font-size:12px;font-weight:700;color:#fff}
|
||||||
|
.edit-panel{display:none;background:#f9fafb;border:1px solid #e5e7eb;border-radius:8px;padding:20px;margin-top:12px}
|
||||||
|
.edit-panel.open{display:block}
|
||||||
|
.user-row td{vertical-align:top;padding:12px 10px}
|
||||||
|
.mini-form{display:flex;flex-wrap:wrap;gap:12px;align-items:flex-end}
|
||||||
|
.mini-form .form-group{margin:0;min-width:160px}
|
||||||
|
.mini-form label{font-size:12px;font-weight:600;display:block;margin-bottom:4px;color:#374151}
|
||||||
|
.mini-form input,.mini-form select{font-size:14px;padding:6px 10px;border:1px solid #d1d5db;border-radius:6px;width:100%}
|
||||||
|
.create-panel{background:#fff;border:1px solid #e5e7eb;border-radius:8px;padding:24px;margin-bottom:28px;display:none}
|
||||||
|
.create-panel.open{display:block}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="admin-container">
|
||||||
|
|
||||||
|
<header class="admin-header">
|
||||||
|
<h1>✝ <?= htmlspecialchars($site_name) ?></h1>
|
||||||
|
<div class="header-actions">
|
||||||
|
<a href="<?= BASE_URL ?>/" class="btn btn-ghost" style="font-size:13px">← View Site</a>
|
||||||
|
<a href="<?= BASE_URL ?>/admin/" class="btn btn-ghost">Dashboard</a>
|
||||||
|
<?php if ($is_super): ?>
|
||||||
|
<a href="<?= BASE_URL ?>/admin/settings.php" class="btn btn-ghost">Settings</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
<a href="<?= BASE_URL ?>/admin/profile.php" class="btn btn-ghost"><?= htmlspecialchars($user['display_name'] ?: $user['username']) ?></a>
|
||||||
|
<a href="<?= BASE_URL ?>/logout" class="btn btn-ghost">Logout</a>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<?php foreach ($messages as $m): ?>
|
||||||
|
<div class="alert alert-success">✓ <?= htmlspecialchars($m) ?></div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php foreach ($errors as $e): ?>
|
||||||
|
<div class="alert alert-error"><?= htmlspecialchars($e) ?></div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:20px">
|
||||||
|
<h2 style="margin:0">Users (<?= count($users) ?>)</h2>
|
||||||
|
<button class="btn btn-primary" onclick="toggleCreate()">+ Create User</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create User Panel -->
|
||||||
|
<div class="create-panel" id="create-panel">
|
||||||
|
<h3 style="margin:0 0 16px">Create New User</h3>
|
||||||
|
<form method="post">
|
||||||
|
<input type="hidden" name="action" value="create_user">
|
||||||
|
<div class="mini-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Username *</label>
|
||||||
|
<input type="text" name="new_username" pattern="[a-zA-Z0-9_]{3,30}" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Display Name</label>
|
||||||
|
<input type="text" name="new_display_name" maxlength="100">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Email *</label>
|
||||||
|
<input type="email" name="new_email" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Password * (min 8)</label>
|
||||||
|
<input type="password" name="new_password" minlength="8" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Role</label>
|
||||||
|
<select name="new_role">
|
||||||
|
<option value="user">User</option>
|
||||||
|
<option value="superuser">Superuser</option>
|
||||||
|
<option value="admin">Admin</option>
|
||||||
|
<?php if ($is_super): ?>
|
||||||
|
<option value="superadmin">Superadmin</option>
|
||||||
|
<?php endif; ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Rosary Limit (-1 = unlimited)</label>
|
||||||
|
<input type="number" name="new_rosary_limit" value="1" min="-1">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button type="submit" class="btn btn-primary">Create</button>
|
||||||
|
<button type="button" class="btn btn-ghost" onclick="toggleCreate()">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Users Table -->
|
||||||
|
<div class="sessions-table-wrap">
|
||||||
|
<table class="sessions-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Username</th>
|
||||||
|
<th>Display Name</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Role</th>
|
||||||
|
<th>Limit</th>
|
||||||
|
<th>Rosaries</th>
|
||||||
|
<th>Joined</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($users as $u): ?>
|
||||||
|
<tr class="user-row">
|
||||||
|
<td>
|
||||||
|
<strong><?= htmlspecialchars($u['username']) ?></strong>
|
||||||
|
<?= !$u['email_confirmed'] ? '<br><span style="font-size:11px;color:#d97706">Unconfirmed</span>' : '' ?>
|
||||||
|
</td>
|
||||||
|
<td><?= htmlspecialchars($u['display_name'] ?? '') ?></td>
|
||||||
|
<td style="font-size:13px"><?= htmlspecialchars($u['email']) ?></td>
|
||||||
|
<td>
|
||||||
|
<span class="role-badge" style="background:<?= $role_colors[$u['role']] ?? '#6b7280' ?>">
|
||||||
|
<?= htmlspecialchars($role_labels[$u['role']] ?? $u['role']) ?>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td><?= $u['rosary_limit'] < 0 ? '∞' : (int)$u['rosary_limit'] ?></td>
|
||||||
|
<td><?= (int)$u['rosary_count'] ?></td>
|
||||||
|
<td style="font-size:12px"><?= date('M j, Y', strtotime($u['created_at'])) ?></td>
|
||||||
|
<td class="actions">
|
||||||
|
<?php $can_edit = $is_super || ($u['role'] !== 'superadmin' && $u['role'] !== 'admin'); ?>
|
||||||
|
<?php if ($can_edit || (int)$u['id'] !== $uid): ?>
|
||||||
|
<button class="btn btn-sm btn-secondary"
|
||||||
|
onclick="toggleEdit(<?= $u['id'] ?>)">Edit</button>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if (!$u['email_confirmed']): ?>
|
||||||
|
<form method="post" style="display:inline">
|
||||||
|
<input type="hidden" name="action" value="resend_confirmation">
|
||||||
|
<input type="hidden" name="target_id" value="<?= $u['id'] ?>">
|
||||||
|
<button type="submit" class="btn btn-sm"
|
||||||
|
style="background:#d97706;color:#fff;border:none"
|
||||||
|
title="Resend confirmation email to <?= htmlspecialchars($u['email']) ?>">
|
||||||
|
Resend Email
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if ((int)$u['id'] !== $uid && ($is_super || $u['role'] !== 'superadmin')): ?>
|
||||||
|
<form method="post" style="display:inline"
|
||||||
|
onsubmit="return confirm('Delete user <?= htmlspecialchars(addslashes($u['username'])) ?>? Their sessions will remain.')">
|
||||||
|
<input type="hidden" name="action" value="delete_user">
|
||||||
|
<input type="hidden" name="target_id" value="<?= $u['id'] ?>">
|
||||||
|
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
|
||||||
|
</form>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<!-- Edit Panel -->
|
||||||
|
<div class="edit-panel" id="edit-<?= $u['id'] ?>">
|
||||||
|
<form method="post" style="margin-bottom:16px">
|
||||||
|
<input type="hidden" name="action" value="update_user">
|
||||||
|
<input type="hidden" name="target_id" value="<?= $u['id'] ?>">
|
||||||
|
<div class="mini-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Display Name</label>
|
||||||
|
<input type="text" name="upd_display_name" maxlength="100"
|
||||||
|
value="<?= htmlspecialchars($u['display_name'] ?? '') ?>">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Email</label>
|
||||||
|
<input type="email" name="upd_email" required
|
||||||
|
value="<?= htmlspecialchars($u['email']) ?>">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Role</label>
|
||||||
|
<select name="upd_role">
|
||||||
|
<option value="user" <?= $u['role']==='user' ?'selected':'' ?>>User</option>
|
||||||
|
<option value="superuser" <?= $u['role']==='superuser' ?'selected':'' ?>>Superuser</option>
|
||||||
|
<option value="admin" <?= $u['role']==='admin' ?'selected':'' ?>>Admin</option>
|
||||||
|
<?php if ($is_super): ?>
|
||||||
|
<option value="superadmin" <?= $u['role']==='superadmin'?'selected':'' ?>>Superadmin</option>
|
||||||
|
<?php endif; ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Rosary Limit (-1=unlimited)</label>
|
||||||
|
<input type="number" name="upd_rosary_limit" min="-1"
|
||||||
|
value="<?= (int)$u['rosary_limit'] ?>">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button type="submit" class="btn btn-primary btn-sm">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form method="post">
|
||||||
|
<input type="hidden" name="action" value="reset_password">
|
||||||
|
<input type="hidden" name="target_id" value="<?= $u['id'] ?>">
|
||||||
|
<div class="mini-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>New Password (min 8)</label>
|
||||||
|
<input type="password" name="new_pass" minlength="8" required>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button type="submit" class="btn btn-secondary btn-sm"
|
||||||
|
onclick="return confirm('Reset this user\'s password?')">
|
||||||
|
Reset Password
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function toggleCreate() {
|
||||||
|
var p = document.getElementById('create-panel');
|
||||||
|
p.classList.toggle('open');
|
||||||
|
}
|
||||||
|
function toggleEdit(id) {
|
||||||
|
var p = document.getElementById('edit-' + id);
|
||||||
|
p.classList.toggle('open');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* api/delete_audio.php
|
||||||
|
* POST: delete the audio file for a specific prayer key.
|
||||||
|
* Admin only.
|
||||||
|
*
|
||||||
|
* POST params:
|
||||||
|
* key — audio key string
|
||||||
|
*
|
||||||
|
* Returns JSON: {"deleted": true|false}
|
||||||
|
*/
|
||||||
|
require_once __DIR__ . '/../config/db.php';
|
||||||
|
require_once __DIR__ . '/../includes/auth.php';
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
require_auth();
|
||||||
|
|
||||||
|
if (!has_role('admin')) {
|
||||||
|
http_response_code(403);
|
||||||
|
echo json_encode(['error' => '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]);
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* api/save_session.php
|
||||||
|
* POST: insert or update a session.
|
||||||
|
*
|
||||||
|
* NEW novena_deceased (no id): inserts group + 9 rows (Day 1–9), returns {"novena":true,"ids":[...]}
|
||||||
|
* EDIT any session (id provided): updates single row, returns {"id": N}
|
||||||
|
* NEW other occasion: inserts 1 row, returns {"id": N}
|
||||||
|
*/
|
||||||
|
require_once __DIR__ . '/../config/db.php';
|
||||||
|
require_once __DIR__ . '/../includes/auth.php';
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
require_auth();
|
||||||
|
|
||||||
|
$user = current_user();
|
||||||
|
$uid = (int)$user['id'];
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
http_response_code(405);
|
||||||
|
echo json_encode(['error' => '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()]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* api/toggle_pin.php
|
||||||
|
* POST: toggle is_pinned on a session or novena_group.
|
||||||
|
* Admin / superadmin only.
|
||||||
|
*
|
||||||
|
* POST params:
|
||||||
|
* type — 'session' | 'novena'
|
||||||
|
* id — integer row ID
|
||||||
|
*
|
||||||
|
* Returns JSON: {"pinned": true|false}
|
||||||
|
*/
|
||||||
|
require_once __DIR__ . '/../config/db.php';
|
||||||
|
require_once __DIR__ . '/../includes/auth.php';
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
_auth_start();
|
||||||
|
|
||||||
|
if (!has_role('admin')) {
|
||||||
|
http_response_code(403);
|
||||||
|
echo json_encode(['error' => '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']);
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* api/upload_audio.php
|
||||||
|
* POST: upload an audio file for a specific prayer key.
|
||||||
|
* Admin only. Replaces any existing file for that key.
|
||||||
|
*
|
||||||
|
* POST params:
|
||||||
|
* key — audio key string (alphanumeric + underscores)
|
||||||
|
* audio — uploaded file (MP3, M4A, OGG, WAV)
|
||||||
|
*
|
||||||
|
* Returns JSON: {"key": "...", "ext": "mp3"} or {"error": "..."}
|
||||||
|
*/
|
||||||
|
require_once __DIR__ . '/../config/db.php';
|
||||||
|
require_once __DIR__ . '/../includes/auth.php';
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
require_auth();
|
||||||
|
|
||||||
|
if (!has_role('admin')) {
|
||||||
|
http_response_code(403);
|
||||||
|
echo json_encode(['error' => '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]);
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* api/upload_photo.php
|
||||||
|
* POST: handle photo upload.
|
||||||
|
* Returns JSON: {"path": "uploads/filename.jpg"} on success, {"error": "..."} on failure.
|
||||||
|
*/
|
||||||
|
require_once __DIR__ . '/../config/db.php';
|
||||||
|
require_once __DIR__ . '/../includes/auth.php';
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
require_auth();
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
http_response_code(405);
|
||||||
|
echo json_encode(['error' => '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]);
|
||||||
@@ -0,0 +1,532 @@
|
|||||||
|
/* =========================================================
|
||||||
|
present.css — Full-screen presentation styles
|
||||||
|
========================================================= */
|
||||||
|
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: #0a0a1a;
|
||||||
|
--text: #f0f0f0;
|
||||||
|
--gold: #FFD700;
|
||||||
|
--blue: #87CEEB;
|
||||||
|
--mystery: #c8a84b;
|
||||||
|
--dim: rgba(255,255,255,0.35);
|
||||||
|
--nav-bg: rgba(0,0,0,0.55);
|
||||||
|
--font-prayer: Georgia, 'Times New Roman', serif;
|
||||||
|
--font-ui: system-ui, -apple-system, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--font-prayer);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------
|
||||||
|
Rosary overlay
|
||||||
|
---------------------------------------------------------------- */
|
||||||
|
#rosary-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
#rosary-overlay svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------
|
||||||
|
Presenter wrapper
|
||||||
|
---------------------------------------------------------------- */
|
||||||
|
#presenter {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 48px 72px 80px; /* space for bead ring */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------
|
||||||
|
Slide content area
|
||||||
|
---------------------------------------------------------------- */
|
||||||
|
#slide-content {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1100px;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0; /* CRITICAL: without this, flex:1 never shrinks below
|
||||||
|
content height, so overflow-y:auto never activates */
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
/* Thin, dark scrollbar — unobtrusive on a presentation screen */
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: rgba(255,255,255,0.15) transparent;
|
||||||
|
-webkit-overflow-scrolling: touch; /* iOS momentum scroll */
|
||||||
|
}
|
||||||
|
#slide-content::-webkit-scrollbar { width: 4px; }
|
||||||
|
#slide-content::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
#slide-content::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.15); border-radius: 2px; }
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------
|
||||||
|
Cover slide
|
||||||
|
---------------------------------------------------------------- */
|
||||||
|
.cover-slide {
|
||||||
|
text-align: center;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#cover-photo-wrap {
|
||||||
|
max-height: 38vh;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 6px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
#cover-photo {
|
||||||
|
max-height: 38vh;
|
||||||
|
max-width: 60vw;
|
||||||
|
border-radius: 6px;
|
||||||
|
display: block;
|
||||||
|
object-fit: cover;
|
||||||
|
/* Vignette effect */
|
||||||
|
mask-image: radial-gradient(ellipse 90% 90% at center, black 60%, transparent 100%);
|
||||||
|
-webkit-mask-image: radial-gradient(ellipse 90% 90% at center, black 60%, transparent 100%);
|
||||||
|
}
|
||||||
|
#cover-photo-wrap.no-photo {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#cover-title {
|
||||||
|
font-size: 2.6rem;
|
||||||
|
font-weight: normal;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--gold);
|
||||||
|
text-shadow: 0 0 20px rgba(255,215,0,0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#cover-body {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
line-height: 1.7;
|
||||||
|
white-space: pre-line;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------
|
||||||
|
Prayer slide
|
||||||
|
---------------------------------------------------------------- */
|
||||||
|
#prayer-slide {
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#slide-title {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.15em;
|
||||||
|
color: var(--dim);
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
#slide-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#leader-wrap, #all-wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
}
|
||||||
|
.leader-label { color: var(--gold); }
|
||||||
|
.all-label { color: var(--blue); }
|
||||||
|
|
||||||
|
#leader-text, #all-text,
|
||||||
|
#litany-leader, #litany-all {
|
||||||
|
font-size: clamp(1.6rem, 3.2vw, 2.4rem);
|
||||||
|
line-height: 1.65;
|
||||||
|
white-space: pre-line;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* When only one section is present, increase size */
|
||||||
|
.single-section #leader-text,
|
||||||
|
.single-section #all-text,
|
||||||
|
.single-section #litany-leader,
|
||||||
|
.single-section #litany-all {
|
||||||
|
font-size: clamp(1.8rem, 3.5vw, 2.6rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------
|
||||||
|
Mystery slide
|
||||||
|
---------------------------------------------------------------- */
|
||||||
|
#mystery-slide {
|
||||||
|
text-align: center;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mystery-number {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.2em;
|
||||||
|
color: var(--mystery);
|
||||||
|
}
|
||||||
|
|
||||||
|
#mystery-slide h2 {
|
||||||
|
font-size: 2.4rem;
|
||||||
|
font-weight: normal;
|
||||||
|
color: var(--gold);
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mystery-fruit {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: var(--blue);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------
|
||||||
|
Litany slide
|
||||||
|
---------------------------------------------------------------- */
|
||||||
|
#litany-slide {
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#litany-title {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.15em;
|
||||||
|
color: var(--dim);
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
#litany-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 22px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#litany-leader-wrap, #litany-all-wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------
|
||||||
|
Closing slide
|
||||||
|
---------------------------------------------------------------- */
|
||||||
|
#closing-slide {
|
||||||
|
text-align: center;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#closing-photo-wrap {
|
||||||
|
max-height: 34vh;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
#closing-photo {
|
||||||
|
max-height: 34vh;
|
||||||
|
max-width: 55vw;
|
||||||
|
border-radius: 6px;
|
||||||
|
display: block;
|
||||||
|
object-fit: cover;
|
||||||
|
mask-image: radial-gradient(ellipse 90% 90% at center, black 60%, transparent 100%);
|
||||||
|
-webkit-mask-image: radial-gradient(ellipse 90% 90% at center, black 60%, transparent 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.closing-cross {
|
||||||
|
font-size: 2.8rem;
|
||||||
|
color: var(--gold);
|
||||||
|
text-shadow: 0 0 30px rgba(255,215,0,0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
#closing-slide h2 {
|
||||||
|
font-size: 1.9rem;
|
||||||
|
font-weight: normal;
|
||||||
|
color: var(--gold);
|
||||||
|
}
|
||||||
|
|
||||||
|
#closing-subtitle {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: var(--dim);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
#closing-body {
|
||||||
|
font-size: clamp(1.5rem, 3vw, 2.2rem);
|
||||||
|
line-height: 1.75;
|
||||||
|
white-space: pre-line;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------
|
||||||
|
Navigation bar
|
||||||
|
---------------------------------------------------------------- */
|
||||||
|
#presenter-nav {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 24px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
|
background: var(--nav-bg);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
border-radius: 40px;
|
||||||
|
padding: 10px 24px;
|
||||||
|
border: 1px solid rgba(255,255,255,0.1);
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Thin vertical divider inside the nav pill */
|
||||||
|
.nav-sep {
|
||||||
|
width: 1px;
|
||||||
|
height: 18px;
|
||||||
|
background: rgba(255,255,255,0.18);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Exit button — smaller and muted vs the main nav buttons */
|
||||||
|
.nav-exit {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: rgba(255,255,255,0.4);
|
||||||
|
padding: 6px 10px;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
.nav-exit:hover:not(:disabled) {
|
||||||
|
background: rgba(255,255,255,0.08);
|
||||||
|
color: rgba(255,255,255,0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Audio toggle button */
|
||||||
|
.nav-audio {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: rgba(255,255,255,0.4);
|
||||||
|
padding: 6px 10px;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
.nav-audio:hover:not(:disabled) {
|
||||||
|
background: rgba(255,255,255,0.08);
|
||||||
|
color: rgba(255,255,255,0.85);
|
||||||
|
}
|
||||||
|
.nav-audio.audio-on {
|
||||||
|
color: #c9973d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 1.4rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: background 0.15s;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.nav-btn:hover:not(:disabled) {
|
||||||
|
background: rgba(255,255,255,0.12);
|
||||||
|
}
|
||||||
|
.nav-btn:disabled {
|
||||||
|
opacity: 0.3;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
#slide-counter {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--dim);
|
||||||
|
min-width: 100px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------
|
||||||
|
Resume toast — auto-fades after resume
|
||||||
|
---------------------------------------------------------------- */
|
||||||
|
#resume-toast {
|
||||||
|
position: fixed;
|
||||||
|
top: 14px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: rgba(255,215,0,0.12);
|
||||||
|
border: 1px solid rgba(255,215,0,0.3);
|
||||||
|
color: var(--gold);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
padding: 5px 18px;
|
||||||
|
border-radius: 20px;
|
||||||
|
z-index: 30;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.5s ease;
|
||||||
|
}
|
||||||
|
#resume-toast.visible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------
|
||||||
|
Keyboard hint
|
||||||
|
---------------------------------------------------------------- */
|
||||||
|
#key-hint {
|
||||||
|
position: fixed;
|
||||||
|
top: 12px;
|
||||||
|
right: 16px;
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: rgba(255,255,255,0.2);
|
||||||
|
z-index: 20;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------
|
||||||
|
Slide transitions
|
||||||
|
---------------------------------------------------------------- */
|
||||||
|
#slide-content > div {
|
||||||
|
animation: fadeIn 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; transform: translateY(6px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------
|
||||||
|
Fullscreen state — hide nav hint
|
||||||
|
---------------------------------------------------------------- */
|
||||||
|
:fullscreen #key-hint,
|
||||||
|
:-webkit-full-screen #key-hint {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------
|
||||||
|
Repeat-run badge — shows "2 of 3" when same content repeats.
|
||||||
|
Lives inside the slide panel, above the title (inline, not fixed).
|
||||||
|
---------------------------------------------------------------- */
|
||||||
|
.repeat-badge {
|
||||||
|
display: block;
|
||||||
|
align-self: center;
|
||||||
|
background: rgba(255, 215, 0, 0.12);
|
||||||
|
border: 1px solid rgba(255, 215, 0, 0.35);
|
||||||
|
color: var(--gold);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: 4px 14px;
|
||||||
|
border-radius: 20px;
|
||||||
|
pointer-events: none;
|
||||||
|
animation: badgePulse 0.4s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes badgePulse {
|
||||||
|
from { opacity: 0; transform: scale(0.85); }
|
||||||
|
to { opacity: 1; transform: scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------
|
||||||
|
Mobile — tablets and phones (≤ 768 px)
|
||||||
|
---------------------------------------------------------------- */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
/* Tighter inset: beads sit 28 px from edge (center) + 10 px radius = 38 px.
|
||||||
|
48 px padding gives 10 px clearance on all sides. */
|
||||||
|
#presenter {
|
||||||
|
padding: 44px 48px 68px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Shrink fixed-size text that doesn't use clamp() */
|
||||||
|
#cover-title { font-size: 1.9rem; }
|
||||||
|
#cover-body { font-size: 1.25rem; }
|
||||||
|
#mystery-slide h2 { font-size: 1.8rem; }
|
||||||
|
#mystery-fruit { font-size: 1.15rem; }
|
||||||
|
#closing-slide h2 { font-size: 1.5rem; }
|
||||||
|
#closing-body { font-size: 1.1rem; }
|
||||||
|
|
||||||
|
/* Compact nav pill */
|
||||||
|
#presenter-nav {
|
||||||
|
padding: 8px 14px;
|
||||||
|
gap: 8px;
|
||||||
|
bottom: 14px;
|
||||||
|
}
|
||||||
|
.nav-exit { font-size: 1rem; padding: 6px 8px; }
|
||||||
|
|
||||||
|
/* WCAG 2.5.5 minimum 44 × 44 px touch target */
|
||||||
|
.nav-btn {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
padding: 6px 14px;
|
||||||
|
min-width: 44px;
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#slide-counter {
|
||||||
|
min-width: 76px;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Keyboard hint is irrelevant on touch devices */
|
||||||
|
#key-hint { display: none; }
|
||||||
|
|
||||||
|
/* Cover photo — a bit smaller on portrait phones */
|
||||||
|
#cover-photo-wrap,
|
||||||
|
#cover-photo { max-height: 32vh; }
|
||||||
|
|
||||||
|
#closing-photo-wrap,
|
||||||
|
#closing-photo { max-height: 28vh; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------
|
||||||
|
Mobile — phones (≤ 480 px)
|
||||||
|
---------------------------------------------------------------- */
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
#presenter {
|
||||||
|
padding: 44px 40px 66px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#cover-title { font-size: 1.4rem; }
|
||||||
|
#mystery-slide h2 { font-size: 1.4rem; }
|
||||||
|
#mystery-fruit { font-size: 1.05rem; }
|
||||||
|
|
||||||
|
/* Slightly smaller slide title label */
|
||||||
|
#slide-title,
|
||||||
|
#litany-title { font-size: 0.9rem; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,617 @@
|
|||||||
|
/* public.css — Clean public-facing styles */
|
||||||
|
*, *::before, *::after { box-sizing: border-box; }
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--navy: #1e3a5f;
|
||||||
|
--gold: #c9973d;
|
||||||
|
--text: #111827;
|
||||||
|
--muted: #6b7280;
|
||||||
|
--bg: #f9fafb;
|
||||||
|
--white: #ffffff;
|
||||||
|
--border:#e5e7eb;
|
||||||
|
--radius:8px;
|
||||||
|
--shadow:0 1px 4px rgba(0,0,0,.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Nav ── */
|
||||||
|
.pub-nav {
|
||||||
|
background: var(--navy);
|
||||||
|
color: #fff;
|
||||||
|
padding: 0 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
height: 56px;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 100;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pub-nav-brand {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
text-decoration: none;
|
||||||
|
letter-spacing: .01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pub-nav-brand span { color: var(--gold); }
|
||||||
|
|
||||||
|
.pub-nav-links {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pub-nav-links a {
|
||||||
|
color: rgba(255,255,255,.85);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: background .15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pub-nav-links a:hover { background: rgba(255,255,255,.12); }
|
||||||
|
|
||||||
|
.pub-nav-links a.btn-nav {
|
||||||
|
background: var(--gold);
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pub-nav-links a.btn-nav:hover { background: #b8872e; }
|
||||||
|
|
||||||
|
/* ── Hero ── */
|
||||||
|
.pub-hero {
|
||||||
|
background: linear-gradient(135deg, var(--navy) 0%, #2d5a8e 100%);
|
||||||
|
color: #fff;
|
||||||
|
text-align: center;
|
||||||
|
padding: 64px 24px 56px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pub-hero h1 {
|
||||||
|
font-size: clamp(28px, 5vw, 48px);
|
||||||
|
font-weight: 800;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
letter-spacing: -.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pub-hero p {
|
||||||
|
font-size: 18px;
|
||||||
|
color: rgba(255,255,255,.8);
|
||||||
|
margin: 0;
|
||||||
|
max-width: 480px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Section ── */
|
||||||
|
.pub-section {
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 48px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pub-section h2 {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--navy);
|
||||||
|
margin: 0 0 24px;
|
||||||
|
border-bottom: 2px solid var(--gold);
|
||||||
|
padding-bottom: 10px;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Card grid ── */
|
||||||
|
.card-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rosary-card {
|
||||||
|
background: var(--white);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--text);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rosary-card-photo {
|
||||||
|
width: 100%;
|
||||||
|
height: 160px;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
background: #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rosary-card-photo-placeholder {
|
||||||
|
width: 100%;
|
||||||
|
height: 160px;
|
||||||
|
background: linear-gradient(135deg, var(--navy) 0%, #2d5a8e 100%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 48px;
|
||||||
|
color: rgba(255,255,255,.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rosary-card-body {
|
||||||
|
padding: 16px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rosary-card-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0 0 4px;
|
||||||
|
color: var(--navy);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rosary-card-meta {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--muted);
|
||||||
|
margin: 0 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rosary-card-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
padding-top: 10px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rosary-card-by {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rosary-card-link {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--navy);
|
||||||
|
text-decoration: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rosary-card-link:hover {
|
||||||
|
color: var(--gold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-novena {
|
||||||
|
display: inline-block;
|
||||||
|
background: #ede9fe;
|
||||||
|
color: #5b21b6;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 99px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Profile header ── */
|
||||||
|
.profile-header {
|
||||||
|
background: var(--white);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
padding: 32px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-header-inner {
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-avatar {
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--navy);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-info h1 {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 800;
|
||||||
|
margin: 0 0 2px;
|
||||||
|
color: var(--navy);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-info p {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--muted);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Empty state ── */
|
||||||
|
.pub-empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 56px 24px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pub-empty .cross {
|
||||||
|
font-size: 48px;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
opacity: .3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pub-empty p {
|
||||||
|
font-size: 16px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Donate strip ── */
|
||||||
|
.donate-strip {
|
||||||
|
text-align: center;
|
||||||
|
padding: 28px 24px 32px;
|
||||||
|
background: var(--white);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.donate-strip-text {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: .03em;
|
||||||
|
color: var(--muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.donate-strip-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: .01em;
|
||||||
|
color: #fff;
|
||||||
|
text-decoration: none;
|
||||||
|
background: linear-gradient(135deg, var(--navy) 0%, #2d5a8e 100%);
|
||||||
|
padding: 11px 28px;
|
||||||
|
border-radius: 99px;
|
||||||
|
box-shadow: 0 2px 10px rgba(30,58,95,.25);
|
||||||
|
transition: transform .15s, box-shadow .15s, background .15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.donate-strip-link:hover {
|
||||||
|
background: linear-gradient(135deg, #c9973d 0%, #b8872e 100%);
|
||||||
|
box-shadow: 0 4px 16px rgba(201,151,61,.35);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Footer ── */
|
||||||
|
.pub-footer {
|
||||||
|
text-align: center;
|
||||||
|
padding: 24px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--muted);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
margin-top: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Novena hero (day-picker page) ── */
|
||||||
|
.novena-hero {
|
||||||
|
background: linear-gradient(135deg, var(--navy) 0%, #2d5a8e 100%);
|
||||||
|
color: #fff;
|
||||||
|
padding: 48px 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 40px;
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.novena-hero-photo-wrap {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.novena-hero-photo {
|
||||||
|
width: 160px;
|
||||||
|
height: 160px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 3px solid rgba(255,255,255,.25);
|
||||||
|
display: block;
|
||||||
|
mask-image: radial-gradient(ellipse 90% 90% at center, black 60%, transparent 100%);
|
||||||
|
-webkit-mask-image: radial-gradient(ellipse 90% 90% at center, black 60%, transparent 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.novena-hero-cross {
|
||||||
|
font-size: 72px;
|
||||||
|
color: rgba(255,255,255,.3);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.novena-hero-text {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.novena-hero-label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .12em;
|
||||||
|
color: var(--gold);
|
||||||
|
margin: 0 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.novena-hero-title {
|
||||||
|
font-size: clamp(24px, 4vw, 40px);
|
||||||
|
font-weight: 800;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
letter-spacing: -.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.novena-hero-subject {
|
||||||
|
font-size: 18px;
|
||||||
|
margin: 0 0 4px;
|
||||||
|
color: rgba(255,255,255,.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.novena-hero-dates {
|
||||||
|
font-size: 14px;
|
||||||
|
color: rgba(255,255,255,.6);
|
||||||
|
margin: 0 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.novena-hero-by {
|
||||||
|
font-size: 13px;
|
||||||
|
color: rgba(255,255,255,.5);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Novena day grid ── */
|
||||||
|
.novena-days-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.novena-day-card {
|
||||||
|
background: var(--white);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 20px 18px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.novena-day-missing {
|
||||||
|
background: #f9fafb;
|
||||||
|
opacity: .55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.novena-day-number {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .08em;
|
||||||
|
color: var(--navy);
|
||||||
|
}
|
||||||
|
|
||||||
|
.novena-day-mysteries {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--muted);
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.novena-day-link {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--navy);
|
||||||
|
text-decoration: none;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
padding-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.novena-day-link:hover {
|
||||||
|
color: var(--gold);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Search bar ── */
|
||||||
|
.search-wrap {
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 28px 24px 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-search-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
background: var(--white);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 40px;
|
||||||
|
padding: 0 18px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
transition: border-color .15s, box-shadow .15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-search-row:focus-within {
|
||||||
|
border-color: var(--navy);
|
||||||
|
box-shadow: 0 0 0 3px rgba(30,58,95,.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-search-icon {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 15px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-search-input {
|
||||||
|
flex: 1;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
background: transparent;
|
||||||
|
font-size: 15px;
|
||||||
|
font-family: inherit;
|
||||||
|
color: var(--text);
|
||||||
|
padding: 13px 0;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-search-input::placeholder { color: var(--muted); }
|
||||||
|
|
||||||
|
.home-search-clear {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 16px;
|
||||||
|
padding: 4px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
display: none;
|
||||||
|
line-height: 1;
|
||||||
|
transition: color .15s;
|
||||||
|
}
|
||||||
|
.home-search-clear:hover { color: var(--text); }
|
||||||
|
.home-search-clear.visible { display: block; }
|
||||||
|
|
||||||
|
/* User profile links that appear when search matches a user */
|
||||||
|
.search-user-results {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-user-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
background: var(--white);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 99px;
|
||||||
|
padding: 5px 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--navy);
|
||||||
|
text-decoration: none;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
transition: background .15s, border-color .15s;
|
||||||
|
}
|
||||||
|
.search-user-pill:hover {
|
||||||
|
background: var(--navy);
|
||||||
|
color: #fff;
|
||||||
|
border-color: var(--navy);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* No-results message */
|
||||||
|
.search-no-results {
|
||||||
|
display: none;
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px 24px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Pinned / Featured section ── */
|
||||||
|
.pinned-section {
|
||||||
|
background: linear-gradient(180deg, #fefdf6 0%, var(--bg) 100%);
|
||||||
|
border-bottom: 1px solid #e8dfc0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pinned-section .pub-section {
|
||||||
|
padding-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pinned-section .pub-section h2 {
|
||||||
|
color: #8a6820;
|
||||||
|
border-bottom-color: var(--gold);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Gold top-border on pinned cards */
|
||||||
|
.rosary-card.is-pinned {
|
||||||
|
border-top: 3px solid var(--gold);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pin/unpin toggle button — floats top-right of card, admin-only */
|
||||||
|
.rosary-card {
|
||||||
|
position: relative; /* anchor for .pin-btn */
|
||||||
|
}
|
||||||
|
|
||||||
|
.pin-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 8px;
|
||||||
|
background: rgba(255,255,255,.88);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 3px 7px;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--muted);
|
||||||
|
transition: background .15s, color .15s, border-color .15s;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
.pin-btn:hover {
|
||||||
|
background: var(--gold);
|
||||||
|
border-color: var(--gold);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.pin-btn.is-pinned {
|
||||||
|
color: #8a6820;
|
||||||
|
border-color: var(--gold);
|
||||||
|
background: #fff9e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Badge for Divine Mercy Novena */
|
||||||
|
.badge-divine-mercy {
|
||||||
|
display: inline-block;
|
||||||
|
background: #fce7e7;
|
||||||
|
color: #9b1c1c;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 99px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Responsive ── */
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.pub-nav { padding: 0 16px; }
|
||||||
|
.pub-hero { padding: 40px 16px; }
|
||||||
|
.pub-section { padding: 32px 16px; }
|
||||||
|
.card-grid { grid-template-columns: 1fr; }
|
||||||
|
.search-wrap { padding: 20px 16px 0; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,576 @@
|
|||||||
|
/* =========================================================
|
||||||
|
setup.css — Admin / setup page styles
|
||||||
|
========================================================= */
|
||||||
|
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: #f8f9fb;
|
||||||
|
--bg-card: #ffffff;
|
||||||
|
--border: #d0d5dd;
|
||||||
|
--text: #1d2027;
|
||||||
|
--muted: #6b7280;
|
||||||
|
--primary: #2563eb;
|
||||||
|
--primary-h: #1d4ed8;
|
||||||
|
--danger: #dc2626;
|
||||||
|
--danger-h: #b91c1c;
|
||||||
|
--gold: #b8860b;
|
||||||
|
--success-bg: #f0fdf4;
|
||||||
|
--success-border: #86efac;
|
||||||
|
--error-bg: #fef2f2;
|
||||||
|
--error-border: #fca5a5;
|
||||||
|
--radius: 8px;
|
||||||
|
--font: system-ui, -apple-system, 'Segoe UI', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--font);
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.5;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------
|
||||||
|
Login page
|
||||||
|
---------------------------------------------------------------- */
|
||||||
|
.login-page {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-box {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 40px 48px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
box-shadow: 0 4px 24px rgba(0,0,0,0.06);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-box h1 {
|
||||||
|
font-size: 1.6rem;
|
||||||
|
color: var(--gold);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.login-box h2 {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-bottom: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------
|
||||||
|
Admin container & header
|
||||||
|
---------------------------------------------------------------- */
|
||||||
|
.admin-container {
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 24px 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 20px 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-header h1 {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
color: var(--gold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
main h2 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------
|
||||||
|
Buttons
|
||||||
|
---------------------------------------------------------------- */
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 18px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
transition: background 0.15s, border-color 0.15s, opacity 0.15s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.btn:disabled { opacity: 0.55; cursor: not-allowed; }
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--primary);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.btn-primary:hover:not(:disabled) { background: var(--primary-h); }
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: #fff;
|
||||||
|
color: var(--text);
|
||||||
|
border-color: var(--border);
|
||||||
|
}
|
||||||
|
.btn-secondary:hover:not(:disabled) { background: #f3f4f6; }
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: var(--danger);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.btn-danger:hover:not(:disabled) { background: var(--danger-h); }
|
||||||
|
|
||||||
|
.btn-ghost {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
.btn-ghost:hover:not(:disabled) { background: #f3f4f6; color: var(--text); }
|
||||||
|
|
||||||
|
.btn-full { width: 100%; }
|
||||||
|
.btn-large { padding: 12px 28px; font-size: 1rem; }
|
||||||
|
|
||||||
|
.btn-sm {
|
||||||
|
padding: 5px 12px;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------
|
||||||
|
Forms
|
||||||
|
---------------------------------------------------------------- */
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"],
|
||||||
|
input[type="password"],
|
||||||
|
input[type="file"],
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 9px 13px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-family: var(--font);
|
||||||
|
background: #fff;
|
||||||
|
color: var(--text);
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.15s, box-shadow 0.15s;
|
||||||
|
max-width: 520px;
|
||||||
|
}
|
||||||
|
input[type="file"] {
|
||||||
|
padding: 6px 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus, select:focus {
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 3px rgba(37,99,235,0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.required {
|
||||||
|
color: var(--danger);
|
||||||
|
margin-left: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-text {
|
||||||
|
margin-top: 5px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
margin-top: 32px;
|
||||||
|
padding-top: 24px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------
|
||||||
|
Alerts
|
||||||
|
---------------------------------------------------------------- */
|
||||||
|
.alert {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
border-width: 1px;
|
||||||
|
border-style: solid;
|
||||||
|
}
|
||||||
|
.alert-error {
|
||||||
|
background: var(--error-bg);
|
||||||
|
border-color: var(--error-border);
|
||||||
|
color: #991b1b;
|
||||||
|
}
|
||||||
|
.alert-success {
|
||||||
|
background: var(--success-bg);
|
||||||
|
border-color: var(--success-border);
|
||||||
|
color: #166534;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------
|
||||||
|
Novena auto-create notice
|
||||||
|
---------------------------------------------------------------- */
|
||||||
|
.novena-auto-notice {
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: #f0f9ff;
|
||||||
|
border: 1px solid #bae6fd;
|
||||||
|
color: #0c4a6e;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Read-only field value display */
|
||||||
|
.form-static {
|
||||||
|
padding: 9px 13px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #f8f9fb;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--muted);
|
||||||
|
max-width: 520px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------
|
||||||
|
Upload status
|
||||||
|
---------------------------------------------------------------- */
|
||||||
|
.upload-status {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
.upload-status.uploading { background: #eff6ff; color: #1d4ed8; }
|
||||||
|
.upload-status.success { background: var(--success-bg); color: #166534; }
|
||||||
|
.upload-status.error { background: var(--error-bg); color: #991b1b; }
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------
|
||||||
|
Photo preview in edit mode
|
||||||
|
---------------------------------------------------------------- */
|
||||||
|
.photo-preview {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.photo-preview img {
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------
|
||||||
|
Sessions table
|
||||||
|
---------------------------------------------------------------- */
|
||||||
|
.sessions-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sessions-table th {
|
||||||
|
text-align: left;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-bottom: 2px solid var(--border);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sessions-table td {
|
||||||
|
padding: 13px 14px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sessions-table tbody tr:hover {
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-name {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.subject-name {
|
||||||
|
display: block;
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------
|
||||||
|
Novena group rows — index.php
|
||||||
|
---------------------------------------------------------------- */
|
||||||
|
.novena-group-row {
|
||||||
|
background: rgba(37, 99, 235, 0.03);
|
||||||
|
}
|
||||||
|
.novena-group-row td:first-child {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.novena-group-icon {
|
||||||
|
color: var(--gold);
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
.novena-badge {
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: 8px;
|
||||||
|
padding: 1px 8px;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
border-radius: 20px;
|
||||||
|
background: rgba(37, 99, 235, 0.1);
|
||||||
|
color: var(--primary);
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------
|
||||||
|
Cards — novena_group.php
|
||||||
|
---------------------------------------------------------------- */
|
||||||
|
.card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 28px 32px;
|
||||||
|
}
|
||||||
|
.card-title {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------
|
||||||
|
Breadcrumb — novena_group.php
|
||||||
|
---------------------------------------------------------------- */
|
||||||
|
.breadcrumb {
|
||||||
|
font-size: 0.88rem;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
.breadcrumb a {
|
||||||
|
color: var(--primary);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.breadcrumb a:hover { text-decoration: underline; }
|
||||||
|
.bc-sep {
|
||||||
|
margin: 0 8px;
|
||||||
|
color: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------
|
||||||
|
Missing day row
|
||||||
|
---------------------------------------------------------------- */
|
||||||
|
.day-missing td {
|
||||||
|
color: var(--muted);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------
|
||||||
|
Alert banners
|
||||||
|
---------------------------------------------------------------- */
|
||||||
|
.alert {
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 14px 18px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
}
|
||||||
|
.alert-success {
|
||||||
|
background: var(--success-bg);
|
||||||
|
border: 1px solid var(--success-border);
|
||||||
|
color: #166534;
|
||||||
|
}
|
||||||
|
.alert-error {
|
||||||
|
background: var(--error-bg);
|
||||||
|
border: 1px solid var(--error-border);
|
||||||
|
color: #991b1b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------
|
||||||
|
Form grid (two columns on wider screens)
|
||||||
|
---------------------------------------------------------------- */
|
||||||
|
.form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0 32px;
|
||||||
|
}
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.form-grid { grid-template-columns: 1fr; }
|
||||||
|
.card { padding: 20px 16px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------
|
||||||
|
Photo preview
|
||||||
|
---------------------------------------------------------------- */
|
||||||
|
.photo-preview-wrap {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.photo-preview {
|
||||||
|
max-height: 120px;
|
||||||
|
max-width: 200px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------
|
||||||
|
Form label helpers
|
||||||
|
---------------------------------------------------------------- */
|
||||||
|
.form-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.form-help {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
.form-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 9px 13px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-family: var(--font);
|
||||||
|
background: var(--bg-card);
|
||||||
|
color: var(--text);
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
.form-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12);
|
||||||
|
}
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
.radio-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
padding: 10px 0;
|
||||||
|
}
|
||||||
|
.radio-row label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-weight: 400;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------
|
||||||
|
Empty state
|
||||||
|
---------------------------------------------------------------- */
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 60px 24px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.empty-state p {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------
|
||||||
|
Mobile — tablets and phones (≤ 768 px)
|
||||||
|
---------------------------------------------------------------- */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.admin-container {
|
||||||
|
padding: 0 16px 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-header {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 14px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-header h1 {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Make the sessions table horizontally scrollable */
|
||||||
|
.sessions-table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
margin: 0 -16px;
|
||||||
|
padding: 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sessions-table {
|
||||||
|
min-width: 560px; /* keep columns readable while allowing scroll */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stack action buttons vertically */
|
||||||
|
.actions {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-sm {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Forms: full-width inputs on small screens */
|
||||||
|
input[type="text"],
|
||||||
|
input[type="password"],
|
||||||
|
input[type="file"],
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions .btn {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
text-align: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,668 @@
|
|||||||
|
/**
|
||||||
|
* assets/js/presenter.js (v11)
|
||||||
|
*
|
||||||
|
* Controls slide navigation and DOM updates for the presentation view.
|
||||||
|
* Depends on:
|
||||||
|
* - SLIDES (global, JSON array from PHP)
|
||||||
|
* - SESSION_ID (global, integer session ID)
|
||||||
|
* - BACK_URL (global, URL to return to on exit)
|
||||||
|
* - RosaryRing (global, from rosary.js)
|
||||||
|
*/
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
if (!window.SLIDES || !SLIDES.length) {
|
||||||
|
console.error('No slides data found.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentIndex = 0;
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Slide expansion — pre-split long prayers into "Part 1 / 2" / "Part 2 / 2"
|
||||||
|
// so text stays large and readable on Zoom / projector rather than shrinking
|
||||||
|
// to illegibility. Prayers with more than SPLIT_THRESHOLD combined lines are
|
||||||
|
// automatically split at the nearest paragraph break.
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
var SPLIT_THRESHOLD = 10;
|
||||||
|
|
||||||
|
function countLines(text) {
|
||||||
|
if (!text || !text.trim()) return 0;
|
||||||
|
return text.split('\n').length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitText(text) {
|
||||||
|
// Prefer splitting at paragraph breaks (\n\n)
|
||||||
|
var paras = text.split('\n\n');
|
||||||
|
if (paras.length >= 2) {
|
||||||
|
var mid = Math.ceil(paras.length / 2);
|
||||||
|
var p1 = paras.slice(0, mid).join('\n\n').trim();
|
||||||
|
var p2 = paras.slice(mid).join('\n\n').trim();
|
||||||
|
if (p1 && p2) return [p1, p2];
|
||||||
|
}
|
||||||
|
// Fall back: split at line midpoint
|
||||||
|
var lines = text.split('\n');
|
||||||
|
var mid = Math.ceil(lines.length / 2);
|
||||||
|
return [lines.slice(0, mid).join('\n').trim(), lines.slice(mid).join('\n').trim()];
|
||||||
|
}
|
||||||
|
|
||||||
|
function expandSlides(rawSlides) {
|
||||||
|
var result = [];
|
||||||
|
for (var i = 0; i < rawSlides.length; i++) {
|
||||||
|
var s = rawSlides[i];
|
||||||
|
var leaderLines = countLines(s.leader);
|
||||||
|
var allLines = countLines(s.all);
|
||||||
|
if (leaderLines + allLines <= SPLIT_THRESHOLD) {
|
||||||
|
result.push(s);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Decide which field to split (the longer one)
|
||||||
|
var field = (allLines >= leaderLines) ? 'all' : 'leader';
|
||||||
|
var halves = splitText(s[field]);
|
||||||
|
if (!halves[0] || !halves[1]) { result.push(s); continue; } // safety
|
||||||
|
|
||||||
|
var p1 = Object.assign({}, s);
|
||||||
|
var p2 = Object.assign({}, s);
|
||||||
|
p1._partNum = 1; p1._partTotal = 2;
|
||||||
|
p2._partNum = 2; p2._partTotal = 2;
|
||||||
|
p2.bead_index = null; // bead was "prayed" in part 1
|
||||||
|
|
||||||
|
if (field === 'all') {
|
||||||
|
p1.all = halves[0];
|
||||||
|
p2.leader = '';
|
||||||
|
p2.all = halves[1];
|
||||||
|
} else {
|
||||||
|
p1.leader = halves[0];
|
||||||
|
p1.all = '';
|
||||||
|
p2.leader = halves[1];
|
||||||
|
}
|
||||||
|
result.push(p1, p2);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
var slides = expandSlides(SLIDES);
|
||||||
|
var total = slides.length;
|
||||||
|
|
||||||
|
// localStorage key for resume position
|
||||||
|
var RESUME_KEY = 'rosary_pos_' + (window.SESSION_ID || '0');
|
||||||
|
|
||||||
|
// Pre-compute repeat-run info for every (possibly expanded) slide.
|
||||||
|
// Group consecutive slides by prayer content (leader + all text), not title —
|
||||||
|
// so opening Hail Marys "in Faith / Hope / Charity" and decade Hail Marys all
|
||||||
|
// detect as runs even though their titles differ.
|
||||||
|
var slideRunInfo = (function () {
|
||||||
|
var info = new Array(total).fill(null);
|
||||||
|
var i = 0;
|
||||||
|
while (i < total) {
|
||||||
|
var s = slides[i];
|
||||||
|
var key = s.type + '||' + (s.leader || '') + '||' + (s.all || '');
|
||||||
|
var start = i;
|
||||||
|
while (i < total) {
|
||||||
|
var si = slides[i];
|
||||||
|
var ki = si.type + '||' + (si.leader || '') + '||' + (si.all || '');
|
||||||
|
if (ki !== key) break;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
var len = i - start;
|
||||||
|
if (len > 1) {
|
||||||
|
for (var j = start; j < i; j++) {
|
||||||
|
info[j] = { pos: j - start + 1, total: len };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return info;
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Map bead index → first slide index that uses that bead.
|
||||||
|
// Used by bead click navigation so clicking a bead jumps to the right slide.
|
||||||
|
var beadToSlide = {};
|
||||||
|
(function () {
|
||||||
|
for (var i = 0; i < total; i++) {
|
||||||
|
var bi = slides[i].bead_index;
|
||||||
|
if (bi !== null && bi !== undefined && !(bi in beadToSlide)) {
|
||||||
|
beadToSlide[bi] = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Scan slides 0..upToIndex and return the highest non-null bead_index seen.
|
||||||
|
// Used so "between-bead" slides (Glory Be, Fatima, litanies) keep prayed
|
||||||
|
// beads red instead of resetting everything to gray.
|
||||||
|
function getLastPrayedBead(upToIndex) {
|
||||||
|
var last = null;
|
||||||
|
for (var i = 0; i <= upToIndex; i++) {
|
||||||
|
var bi = slides[i] ? slides[i].bead_index : null;
|
||||||
|
if (bi !== null && bi !== undefined) {
|
||||||
|
last = bi;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return last;
|
||||||
|
}
|
||||||
|
|
||||||
|
// DOM refs
|
||||||
|
var coverSlide = document.getElementById('cover-slide');
|
||||||
|
var prayerSlide = document.getElementById('prayer-slide');
|
||||||
|
var mysterySlide = document.getElementById('mystery-slide');
|
||||||
|
var litanySlide = document.getElementById('litany-slide');
|
||||||
|
var closingSlide = document.getElementById('closing-slide');
|
||||||
|
|
||||||
|
var btnPrev = document.getElementById('btn-prev');
|
||||||
|
var btnNext = document.getElementById('btn-next');
|
||||||
|
var btnExit = document.getElementById('btn-exit');
|
||||||
|
var btnAudio = document.getElementById('btn-audio');
|
||||||
|
var slideCounter = document.getElementById('slide-counter');
|
||||||
|
var resumeToast = document.getElementById('resume-toast');
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
// Audio
|
||||||
|
// AUDIO_MANIFEST: {key: ext, ...} — provided by present.php
|
||||||
|
// AUDIO_BASE_URL: '/uploads/audio/'
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
var AUDIO_PREF_KEY = 'rosary_audio'; // localStorage — global pref, not per-session
|
||||||
|
var audioEnabled = false;
|
||||||
|
var currentAudio = null;
|
||||||
|
|
||||||
|
var hasAudioFiles = window.AUDIO_MANIFEST && Object.keys(AUDIO_MANIFEST).length > 0;
|
||||||
|
|
||||||
|
if (hasAudioFiles) {
|
||||||
|
var stored = localStorage.getItem(AUDIO_PREF_KEY);
|
||||||
|
// Default ON when audio is available; user can mute
|
||||||
|
audioEnabled = (stored === null) ? true : (stored === 'on');
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateAudioBtn() {
|
||||||
|
if (!btnAudio) return;
|
||||||
|
if (audioEnabled) {
|
||||||
|
btnAudio.innerHTML = '🔈'; // 🔊
|
||||||
|
btnAudio.title = 'Audio on — click to mute';
|
||||||
|
} else {
|
||||||
|
btnAudio.innerHTML = '🔇'; // 🔇
|
||||||
|
btnAudio.title = 'Audio off — click to enable';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopAudio() {
|
||||||
|
if (currentAudio) {
|
||||||
|
currentAudio.pause();
|
||||||
|
currentAudio.currentTime = 0;
|
||||||
|
currentAudio = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map a slide's id to a canonical audio key.
|
||||||
|
// Many slides share one recording (e.g. all 55 Hail Marys → 'hail_mary').
|
||||||
|
function getAudioKey(slide) {
|
||||||
|
if (!slide) return null;
|
||||||
|
var id = slide.id || '';
|
||||||
|
if (!id || id === 'cover') return null;
|
||||||
|
|
||||||
|
// Common prayers — many slides → one key
|
||||||
|
if (id === 'sign_of_cross' || id === 'dm_sign_of_cross') return 'sign_of_cross';
|
||||||
|
if (id === 'apostles_creed' || id === 'dm_apostles_creed') return 'apostles_creed';
|
||||||
|
if (id.indexOf('our_father') === 0 || id === 'dm_our_father_opening') return 'our_father';
|
||||||
|
if (id.indexOf('hail_mary') === 0 || id === 'dm_hail_mary_opening') return 'hail_mary';
|
||||||
|
if (id.indexOf('glory_be') === 0) return 'glory_be';
|
||||||
|
if (id.indexOf('fatima_') === 0) return 'fatima_prayer';
|
||||||
|
|
||||||
|
// Mysteries — unique per mystery
|
||||||
|
if (id.indexOf('mystery_') === 0) return id;
|
||||||
|
|
||||||
|
// Hail Holy Queen — both slides share one audio
|
||||||
|
if (id.indexOf('hail_holy_queen') === 0) return 'hail_holy_queen';
|
||||||
|
|
||||||
|
// Rosary closing prayer + generic closing
|
||||||
|
if (id === 'rosary_closing_prayer') return 'rosary_closing_prayer';
|
||||||
|
if (id === 'closing') return 'closing';
|
||||||
|
|
||||||
|
// Litany of Passion — unique per entry
|
||||||
|
if (id.indexOf('litany_passion_') === 0) return id;
|
||||||
|
|
||||||
|
// Novena deceased day prayers — unique per day
|
||||||
|
if (id.indexOf('novena_day_') === 0) return id;
|
||||||
|
|
||||||
|
// Litany for Departed — unique per entry
|
||||||
|
if (id.indexOf('litany_departed_') === 0) return id;
|
||||||
|
|
||||||
|
// Divine Mercy
|
||||||
|
if (id === 'dm_opening') return 'dm_opening';
|
||||||
|
if (id.indexOf('dm_blood_water') === 0) return 'dm_blood_water';
|
||||||
|
if (id.indexOf('dm_intention_day_') === 0) return id;
|
||||||
|
if (id.indexOf('dm_prayer_day_') === 0) return id;
|
||||||
|
if (id.indexOf('dm_eternal_father') === 0) return 'dm_eternal_father';
|
||||||
|
if (id.indexOf('dm_for_sake') === 0) return 'dm_for_sake';
|
||||||
|
if (id.indexOf('dm_holy_god') === 0) return 'dm_holy_god';
|
||||||
|
|
||||||
|
return id; // fallback: use slide ID directly
|
||||||
|
}
|
||||||
|
|
||||||
|
function playSlideAudio(slide) {
|
||||||
|
stopAudio();
|
||||||
|
if (!audioEnabled || !hasAudioFiles) return;
|
||||||
|
var key = getAudioKey(slide);
|
||||||
|
if (!key || !AUDIO_MANIFEST[key]) return;
|
||||||
|
var audio = new Audio(window.AUDIO_BASE_URL + key + '.' + AUDIO_MANIFEST[key]);
|
||||||
|
audio.play().catch(function () { /* silently skip — file may be absent */ });
|
||||||
|
currentAudio = audio;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (btnAudio) {
|
||||||
|
btnAudio.addEventListener('click', function () {
|
||||||
|
audioEnabled = !audioEnabled;
|
||||||
|
try { localStorage.setItem(AUDIO_PREF_KEY, audioEnabled ? 'on' : 'off'); } catch (e) {}
|
||||||
|
updateAudioBtn();
|
||||||
|
if (!audioEnabled) stopAudio();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
updateAudioBtn();
|
||||||
|
|
||||||
|
// Hide all slide panels
|
||||||
|
function hideAll() {
|
||||||
|
coverSlide.style.display = 'none';
|
||||||
|
prayerSlide.style.display = 'none';
|
||||||
|
mysterySlide.style.display = 'none';
|
||||||
|
litanySlide.style.display = 'none';
|
||||||
|
closingSlide.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helpers
|
||||||
|
function setText(id, value) {
|
||||||
|
var el = document.getElementById(id);
|
||||||
|
if (el) el.textContent = value || '';
|
||||||
|
}
|
||||||
|
function setHTML(id, value) {
|
||||||
|
var el = document.getElementById(id);
|
||||||
|
if (el) el.innerHTML = value || '';
|
||||||
|
}
|
||||||
|
function showEl(id) {
|
||||||
|
var el = document.getElementById(id);
|
||||||
|
if (el) el.style.display = '';
|
||||||
|
}
|
||||||
|
function hideEl(id) {
|
||||||
|
var el = document.getElementById(id);
|
||||||
|
if (el) el.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert newlines to <br> for display, keeping pre-line behavior
|
||||||
|
function nl(text) {
|
||||||
|
if (!text) return '';
|
||||||
|
return text.replace(/\n/g, '\n'); // already handled by white-space: pre-line
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
// Render a slide
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
function render(index) {
|
||||||
|
hideAll();
|
||||||
|
var slide = slides[index];
|
||||||
|
if (!slide) return;
|
||||||
|
|
||||||
|
// Trigger CSS fade-in by re-inserting / touching the element
|
||||||
|
switch (slide.type) {
|
||||||
|
|
||||||
|
case 'cover':
|
||||||
|
renderCover(slide);
|
||||||
|
coverSlide.style.display = '';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'mystery':
|
||||||
|
renderMystery(slide);
|
||||||
|
mysterySlide.style.display = '';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'litany':
|
||||||
|
renderLitany(slide);
|
||||||
|
litanySlide.style.display = '';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'closing':
|
||||||
|
renderClosing(slide);
|
||||||
|
closingSlide.style.display = '';
|
||||||
|
break;
|
||||||
|
|
||||||
|
default: // 'prayer'
|
||||||
|
renderPrayer(slide);
|
||||||
|
prayerSlide.style.display = '';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update counter (cover is slide 0, not counted in "Prayer X of Y")
|
||||||
|
updateCounter(index);
|
||||||
|
|
||||||
|
// Show repeat badge when the same content repeats (e.g. Agnus Dei × 3)
|
||||||
|
updateRepeatBadge(index);
|
||||||
|
|
||||||
|
// Shrink text so the slide fits on screen without scrolling
|
||||||
|
fitTextToScreen();
|
||||||
|
|
||||||
|
// Update rosary bead ring — pass both the current bead and the last prayed bead
|
||||||
|
// so "between-bead" slides (Glory Be, Fatima, litanies) keep the ring colored.
|
||||||
|
if (typeof RosaryRing !== 'undefined') {
|
||||||
|
var currentBead = (slide.bead_index !== undefined) ? slide.bead_index : null;
|
||||||
|
var lastBead = getLastPrayedBead(index);
|
||||||
|
RosaryRing.update(currentBead, lastBead);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scroll slide content back to top on each navigation
|
||||||
|
var content = document.getElementById('slide-content');
|
||||||
|
if (content) content.scrollTop = 0;
|
||||||
|
|
||||||
|
// Update nav buttons
|
||||||
|
btnPrev.disabled = (index === 0);
|
||||||
|
btnNext.disabled = (index === total - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCover(slide) {
|
||||||
|
setText('cover-title', slide.title);
|
||||||
|
setText('cover-body', slide.all);
|
||||||
|
|
||||||
|
var photoWrap = document.getElementById('cover-photo-wrap');
|
||||||
|
var photoImg = document.getElementById('cover-photo');
|
||||||
|
|
||||||
|
if (slide.photo_path && photoImg) {
|
||||||
|
photoImg.src = slide.photo_path;
|
||||||
|
photoImg.style.display = '';
|
||||||
|
photoWrap.classList.remove('no-photo');
|
||||||
|
} else {
|
||||||
|
photoWrap.classList.add('no-photo');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPrayer(slide) {
|
||||||
|
setText('slide-title', slide.title);
|
||||||
|
|
||||||
|
var hasLeader = !!(slide.leader && slide.leader.trim());
|
||||||
|
var hasAll = !!(slide.all && slide.all.trim());
|
||||||
|
|
||||||
|
if (hasLeader) {
|
||||||
|
showEl('leader-wrap');
|
||||||
|
setText('leader-text', slide.leader);
|
||||||
|
} else {
|
||||||
|
hideEl('leader-wrap');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasAll) {
|
||||||
|
showEl('all-wrap');
|
||||||
|
setText('all-text', slide.all);
|
||||||
|
} else {
|
||||||
|
hideEl('all-wrap');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bump font size when only one section is present
|
||||||
|
var slideText = document.getElementById('slide-text');
|
||||||
|
if (slideText) {
|
||||||
|
if (hasLeader !== hasAll) {
|
||||||
|
slideText.classList.add('single-section');
|
||||||
|
} else {
|
||||||
|
slideText.classList.remove('single-section');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMystery(slide) {
|
||||||
|
// title is like "The Third Sorrowful Mystery"
|
||||||
|
// leader is the name of the mystery
|
||||||
|
// all is the fruit/intention
|
||||||
|
setText('mystery-number', slide.title);
|
||||||
|
setText('mystery-title', slide.leader);
|
||||||
|
setText('mystery-fruit', slide.all);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLitany(slide) {
|
||||||
|
setText('litany-title', slide.title);
|
||||||
|
|
||||||
|
var hasLeader = !!(slide.leader && slide.leader.trim());
|
||||||
|
var hasAll = !!(slide.all && slide.all.trim());
|
||||||
|
|
||||||
|
if (hasLeader) {
|
||||||
|
showEl('litany-leader-wrap');
|
||||||
|
setText('litany-leader', slide.leader);
|
||||||
|
} else {
|
||||||
|
hideEl('litany-leader-wrap');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasAll) {
|
||||||
|
showEl('litany-all-wrap');
|
||||||
|
setText('litany-all', slide.all);
|
||||||
|
} else {
|
||||||
|
hideEl('litany-all-wrap');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderClosing(slide) {
|
||||||
|
setText('closing-title', slide.title);
|
||||||
|
setText('closing-subtitle', slide.subtitle || '');
|
||||||
|
setText('closing-body', slide.all);
|
||||||
|
|
||||||
|
var photoWrap = document.getElementById('closing-photo-wrap');
|
||||||
|
var photoImg = document.getElementById('closing-photo');
|
||||||
|
if (slide.photo_path && photoImg) {
|
||||||
|
photoImg.src = slide.photo_path;
|
||||||
|
photoWrap.style.display = '';
|
||||||
|
} else if (photoWrap) {
|
||||||
|
photoWrap.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
// fitTextToScreen — shrink prayer/litany text until the slide fits on screen
|
||||||
|
// without a scrollbar. Uses binary search over pixel font-size.
|
||||||
|
// Called after every render so long prayers (e.g. Day 5 novena, Concluding
|
||||||
|
// Prayer) automatically scale down rather than requiring a scroll.
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
var FIT_TEXT_ELS = '#leader-text, #all-text, #litany-leader, #litany-all, #closing-body';
|
||||||
|
var FIT_MIN_PX = 22; // floor — stay readable for Zoom / projector audiences
|
||||||
|
|
||||||
|
function fitTextToScreen() {
|
||||||
|
var container = document.getElementById('slide-content');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
var textEls = container.querySelectorAll(FIT_TEXT_ELS);
|
||||||
|
|
||||||
|
// Reset any previous override so we measure the natural (CSS) size
|
||||||
|
for (var k = 0; k < textEls.length; k++) {
|
||||||
|
textEls[k].style.fontSize = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Already fits — nothing to do
|
||||||
|
if (container.scrollHeight <= container.clientHeight + 2) return;
|
||||||
|
|
||||||
|
if (!textEls.length) return; // no adjustable text (cover, mystery, etc.)
|
||||||
|
|
||||||
|
// Upper bound = current computed size; lower bound = readable minimum
|
||||||
|
var naturalPx = parseFloat(window.getComputedStyle(textEls[0]).fontSize);
|
||||||
|
var lo = FIT_MIN_PX;
|
||||||
|
var hi = naturalPx;
|
||||||
|
|
||||||
|
// Binary search: 20 iterations converges to < 0.1 px accuracy
|
||||||
|
for (var iter = 0; iter < 20; iter++) {
|
||||||
|
var mid = (lo + hi) / 2;
|
||||||
|
for (var k = 0; k < textEls.length; k++) {
|
||||||
|
textEls[k].style.fontSize = mid + 'px';
|
||||||
|
}
|
||||||
|
if (container.scrollHeight <= container.clientHeight + 2) {
|
||||||
|
lo = mid; // fits — try a touch larger
|
||||||
|
} else {
|
||||||
|
hi = mid; // overflows — try smaller
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lock in the converged size
|
||||||
|
for (var k = 0; k < textEls.length; k++) {
|
||||||
|
textEls[k].style.fontSize = lo + 'px';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCounter(index) {
|
||||||
|
var prayerTotal = total - 1; // exclude cover
|
||||||
|
if (index === 0) {
|
||||||
|
slideCounter.textContent = '';
|
||||||
|
} else {
|
||||||
|
slideCounter.textContent = 'Prayer ' + index + ' of ' + prayerTotal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateRepeatBadge(index) {
|
||||||
|
// Hide every badge first (avoid NodeList.forEach for compatibility)
|
||||||
|
var allBadges = document.querySelectorAll('.repeat-badge');
|
||||||
|
for (var k = 0; k < allBadges.length; k++) {
|
||||||
|
allBadges[k].style.display = 'none';
|
||||||
|
allBadges[k].textContent = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
var slide = slides[index];
|
||||||
|
|
||||||
|
// Part badge takes priority (long prayer split across slides)
|
||||||
|
var badgeText = '';
|
||||||
|
if (slide._partNum) {
|
||||||
|
badgeText = 'Part ' + slide._partNum + ' / ' + slide._partTotal;
|
||||||
|
} else {
|
||||||
|
var run = slideRunInfo[index];
|
||||||
|
if (run) badgeText = run.pos + ' of ' + run.total;
|
||||||
|
}
|
||||||
|
if (!badgeText) return;
|
||||||
|
|
||||||
|
// Show the badge inside the currently-visible slide panel
|
||||||
|
var panelId = (slide.type === 'litany') ? 'litany-slide' : 'prayer-slide';
|
||||||
|
var panel = document.getElementById(panelId);
|
||||||
|
var badge = panel ? panel.querySelector('.repeat-badge') : null;
|
||||||
|
if (badge) {
|
||||||
|
badge.textContent = badgeText;
|
||||||
|
badge.style.display = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
// Navigation
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
function goTo(index) {
|
||||||
|
if (index < 0 || index >= total) return;
|
||||||
|
currentIndex = index;
|
||||||
|
render(currentIndex);
|
||||||
|
// Play audio for this slide (stops previous audio automatically)
|
||||||
|
playSlideAudio(slides[index]);
|
||||||
|
// Persist position so the user can resume after exiting.
|
||||||
|
// Clear it when they reach the closing slide (rosary complete).
|
||||||
|
try {
|
||||||
|
if (index > 0 && index < total - 1) {
|
||||||
|
localStorage.setItem(RESUME_KEY, index);
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem(RESUME_KEY);
|
||||||
|
}
|
||||||
|
} catch (e) { /* storage unavailable */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function prev() { goTo(currentIndex - 1); }
|
||||||
|
function next() { goTo(currentIndex + 1); }
|
||||||
|
|
||||||
|
btnPrev.addEventListener('click', prev);
|
||||||
|
btnNext.addEventListener('click', next);
|
||||||
|
|
||||||
|
if (btnExit) {
|
||||||
|
btnExit.addEventListener('click', function () {
|
||||||
|
// Position already saved by goTo; navigate back
|
||||||
|
window.location.href = window.BACK_URL || '/';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keyboard
|
||||||
|
document.addEventListener('keydown', function (e) {
|
||||||
|
switch (e.key) {
|
||||||
|
case 'ArrowRight':
|
||||||
|
case 'ArrowDown':
|
||||||
|
case ' ':
|
||||||
|
e.preventDefault();
|
||||||
|
next();
|
||||||
|
break;
|
||||||
|
case 'ArrowLeft':
|
||||||
|
case 'ArrowUp':
|
||||||
|
e.preventDefault();
|
||||||
|
prev();
|
||||||
|
break;
|
||||||
|
case 'f':
|
||||||
|
case 'F':
|
||||||
|
toggleFullscreen();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Touch swipe
|
||||||
|
var touchStartX = null;
|
||||||
|
var touchStartY = null;
|
||||||
|
|
||||||
|
document.addEventListener('touchstart', function (e) {
|
||||||
|
touchStartX = e.touches[0].clientX;
|
||||||
|
touchStartY = e.touches[0].clientY;
|
||||||
|
}, { passive: true });
|
||||||
|
|
||||||
|
document.addEventListener('touchend', function (e) {
|
||||||
|
if (touchStartX === null) return;
|
||||||
|
var dx = e.changedTouches[0].clientX - touchStartX;
|
||||||
|
var dy = e.changedTouches[0].clientY - touchStartY;
|
||||||
|
|
||||||
|
// Only respond to mostly-horizontal swipes
|
||||||
|
if (Math.abs(dx) > Math.abs(dy) && Math.abs(dx) > 40) {
|
||||||
|
if (dx < 0) next();
|
||||||
|
else prev();
|
||||||
|
}
|
||||||
|
touchStartX = null;
|
||||||
|
touchStartY = null;
|
||||||
|
}, { passive: true });
|
||||||
|
|
||||||
|
// Fullscreen
|
||||||
|
function toggleFullscreen() {
|
||||||
|
if (!document.fullscreenElement) {
|
||||||
|
document.documentElement.requestFullscreen().catch(function () {});
|
||||||
|
} else {
|
||||||
|
document.exitFullscreen().catch(function () {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-fit text when the window is resized (e.g. projector moved to a
|
||||||
|
// different monitor, fullscreen toggled, or phone rotated).
|
||||||
|
var resizeTimer = null;
|
||||||
|
window.addEventListener('resize', function () {
|
||||||
|
clearTimeout(resizeTimer);
|
||||||
|
resizeTimer = setTimeout(function () { render(currentIndex); }, 150);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
// Initialize
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Check for a saved resume position
|
||||||
|
var resumeIndex = 0;
|
||||||
|
try {
|
||||||
|
var saved = localStorage.getItem(RESUME_KEY);
|
||||||
|
if (saved !== null) {
|
||||||
|
var parsed = parseInt(saved, 10);
|
||||||
|
if (!isNaN(parsed) && parsed > 0 && parsed < total - 1) {
|
||||||
|
resumeIndex = parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) { /* storage unavailable */ }
|
||||||
|
|
||||||
|
render(resumeIndex);
|
||||||
|
currentIndex = resumeIndex;
|
||||||
|
|
||||||
|
// Show fade-out toast if resuming mid-prayer
|
||||||
|
if (resumeIndex > 0 && resumeToast) {
|
||||||
|
resumeToast.textContent = 'Resumed at prayer ' + resumeIndex + ' of ' + (total - 1);
|
||||||
|
resumeToast.classList.add('visible');
|
||||||
|
setTimeout(function () {
|
||||||
|
resumeToast.classList.remove('visible');
|
||||||
|
}, 2800);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wire bead clicks: jump to the first slide that uses that bead
|
||||||
|
if (typeof RosaryRing !== 'undefined' && RosaryRing.onBeadClick) {
|
||||||
|
RosaryRing.onBeadClick(function (beadIndex) {
|
||||||
|
if (beadIndex in beadToSlide) {
|
||||||
|
goTo(beadToSlide[beadIndex]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
})();
|
||||||
@@ -0,0 +1,364 @@
|
|||||||
|
/**
|
||||||
|
* assets/js/rosary.js
|
||||||
|
*
|
||||||
|
* Draws an SVG rosary ring around the viewport edges.
|
||||||
|
*
|
||||||
|
* Layout: 60 beads distributed clockwise, starting at the bottom-center
|
||||||
|
* with the crucifix, then going right → up the right edge → left along top
|
||||||
|
* → down the left edge → back to bottom-center.
|
||||||
|
*
|
||||||
|
* Bead index mapping (60 beads total):
|
||||||
|
* 0 = Crucifix (bottom center, stem)
|
||||||
|
* 1 = large (Our Father, stem)
|
||||||
|
* 2–4 = small (3 Hail Marys, stem)
|
||||||
|
* 5 = large (Our Father, Decade 1)
|
||||||
|
* 6–15 = small (10 HMs, Decade 1)
|
||||||
|
* 16 = large (Our Father, Decade 2)
|
||||||
|
* 17–26 = small (Decade 2)
|
||||||
|
* 27 = large (Our Father, Decade 3)
|
||||||
|
* 28–37 = small (Decade 3)
|
||||||
|
* 38 = large (Our Father, Decade 4)
|
||||||
|
* 39–48 = small (Decade 4)
|
||||||
|
* 49 = large (Our Father, Decade 5)
|
||||||
|
* 50–59 = small (Decade 5)
|
||||||
|
*
|
||||||
|
* Colors:
|
||||||
|
* Unprayed : #555555 (dark gray)
|
||||||
|
* Current : #FFD700 (gold, glow)
|
||||||
|
* Prayed : #990000 (deep red)
|
||||||
|
*/
|
||||||
|
|
||||||
|
var RosaryRing = (function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var NS = 'http://www.w3.org/2000/svg';
|
||||||
|
var MARGIN = 28; // px from edge
|
||||||
|
var R_SMALL = 6;
|
||||||
|
var R_LARGE = 10;
|
||||||
|
var R_CRUCIFIX = 12;
|
||||||
|
|
||||||
|
var COLOR_UNPRAYED = '#555555';
|
||||||
|
var COLOR_CURRENT = '#FFD700';
|
||||||
|
var COLOR_PRAYED = '#990000';
|
||||||
|
|
||||||
|
var svg = null;
|
||||||
|
var beadEls = []; // SVG <circle> elements, indexed 0-59
|
||||||
|
var polyline = null;
|
||||||
|
var beadPositions = []; // [{x, y}] for each bead
|
||||||
|
var _beadClickHandler = null; // registered by presenter.js
|
||||||
|
|
||||||
|
// Which bead indices are "large" (Our Father) beads
|
||||||
|
var LARGE_BEADS = new Set([1, 5, 16, 27, 38, 49]);
|
||||||
|
var CRUCIFIX_BEADS = new Set([0]);
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
// Calculate bead positions around the viewport edges
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
function calcPositions(W, H) {
|
||||||
|
var m = MARGIN;
|
||||||
|
|
||||||
|
// Total path length around the rectangle (at margin inset)
|
||||||
|
// Corners: (m,m), (W-m,m), (W-m,H-m), (m,H-m)
|
||||||
|
var innerW = W - 2 * m;
|
||||||
|
var innerH = H - 2 * m;
|
||||||
|
var perimeter = 2 * (innerW + innerH);
|
||||||
|
|
||||||
|
// The ring uses beads 0–59 (60 total).
|
||||||
|
// Bead 0 (crucifix) is at the bottom-center.
|
||||||
|
// We distribute beads clockwise starting from bottom-center,
|
||||||
|
// going RIGHT first along the bottom edge.
|
||||||
|
|
||||||
|
// Reference point for bead 0: bottom center of inset rectangle
|
||||||
|
var startX = W / 2;
|
||||||
|
var startY = H - m;
|
||||||
|
|
||||||
|
var positions = [];
|
||||||
|
|
||||||
|
for (var i = 0; i < 60; i++) {
|
||||||
|
// Fraction of the perimeter for this bead (clockwise from bottom-center)
|
||||||
|
var frac = i / 60;
|
||||||
|
var dist = frac * perimeter;
|
||||||
|
|
||||||
|
var pt = pointOnRect(dist, startX, startY, m, W, H, innerW, innerH, perimeter);
|
||||||
|
positions.push(pt);
|
||||||
|
}
|
||||||
|
|
||||||
|
return positions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given a distance along the perimeter (starting at bottom-center, clockwise),
|
||||||
|
* return the {x, y} point on the inset rectangle.
|
||||||
|
*/
|
||||||
|
function pointOnRect(dist, startX, startY, m, W, H, innerW, innerH, perimeter) {
|
||||||
|
var x, y;
|
||||||
|
|
||||||
|
// Segment lengths from bottom-center going clockwise:
|
||||||
|
// Segment A: bottom-center → bottom-right corner = W/2 - m
|
||||||
|
// Segment B: bottom-right corner → top-right = H - 2m
|
||||||
|
// Segment C: top-right → top-left = W - 2m
|
||||||
|
// Segment D: top-left → bottom-left = H - 2m
|
||||||
|
// Segment E: bottom-left → bottom-center = W/2 - m
|
||||||
|
var segA = W / 2 - m; // → bottom-right
|
||||||
|
var segB = H - 2 * m; // ↑ right edge
|
||||||
|
var segC = W - 2 * m; // ← top edge
|
||||||
|
var segD = H - 2 * m; // ↓ left edge
|
||||||
|
var segE = W / 2 - m; // → back to bottom-center
|
||||||
|
|
||||||
|
dist = ((dist % perimeter) + perimeter) % perimeter; // normalize
|
||||||
|
|
||||||
|
if (dist <= segA) {
|
||||||
|
// Bottom edge: left of center → right (going right from bottom-center)
|
||||||
|
x = startX + dist;
|
||||||
|
y = H - m;
|
||||||
|
} else if (dist <= segA + segB) {
|
||||||
|
// Right edge: bottom → top
|
||||||
|
x = W - m;
|
||||||
|
y = (H - m) - (dist - segA);
|
||||||
|
} else if (dist <= segA + segB + segC) {
|
||||||
|
// Top edge: right → left
|
||||||
|
x = (W - m) - (dist - segA - segB);
|
||||||
|
y = m;
|
||||||
|
} else if (dist <= segA + segB + segC + segD) {
|
||||||
|
// Left edge: top → bottom
|
||||||
|
x = m;
|
||||||
|
y = m + (dist - segA - segB - segC);
|
||||||
|
} else {
|
||||||
|
// Bottom edge: left → bottom-center
|
||||||
|
x = m + (dist - segA - segB - segC - segD);
|
||||||
|
y = H - m;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { x: x, y: y };
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
// Build or rebuild the SVG
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
function build() {
|
||||||
|
var container = document.getElementById('rosary-overlay');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
// Remove existing SVG if any
|
||||||
|
container.innerHTML = '';
|
||||||
|
|
||||||
|
var W = window.innerWidth;
|
||||||
|
var H = window.innerHeight;
|
||||||
|
|
||||||
|
svg = document.createElementNS(NS, 'svg');
|
||||||
|
svg.setAttribute('viewBox', '0 0 ' + W + ' ' + H);
|
||||||
|
svg.setAttribute('xmlns', NS);
|
||||||
|
svg.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;pointer-events:none';
|
||||||
|
|
||||||
|
// Define glow filter
|
||||||
|
var defs = document.createElementNS(NS, 'defs');
|
||||||
|
var filter = document.createElementNS(NS, 'filter');
|
||||||
|
filter.setAttribute('id', 'glow');
|
||||||
|
filter.setAttribute('x', '-50%');
|
||||||
|
filter.setAttribute('y', '-50%');
|
||||||
|
filter.setAttribute('width', '200%');
|
||||||
|
filter.setAttribute('height', '200%');
|
||||||
|
|
||||||
|
var feGaussian = document.createElementNS(NS, 'feGaussianBlur');
|
||||||
|
feGaussian.setAttribute('stdDeviation', '3.5');
|
||||||
|
feGaussian.setAttribute('result', 'coloredBlur');
|
||||||
|
|
||||||
|
var feMerge = document.createElementNS(NS, 'feMerge');
|
||||||
|
var feMergeNode1 = document.createElementNS(NS, 'feMergeNode');
|
||||||
|
feMergeNode1.setAttribute('in', 'coloredBlur');
|
||||||
|
var feMergeNode2 = document.createElementNS(NS, 'feMergeNode');
|
||||||
|
feMergeNode2.setAttribute('in', 'SourceGraphic');
|
||||||
|
|
||||||
|
feMerge.appendChild(feMergeNode1);
|
||||||
|
feMerge.appendChild(feMergeNode2);
|
||||||
|
filter.appendChild(feGaussian);
|
||||||
|
filter.appendChild(feMerge);
|
||||||
|
defs.appendChild(filter);
|
||||||
|
svg.appendChild(defs);
|
||||||
|
|
||||||
|
beadPositions = calcPositions(W, H);
|
||||||
|
|
||||||
|
// Draw chain (polyline)
|
||||||
|
var pts = beadPositions.map(function (p) { return p.x + ',' + p.y; });
|
||||||
|
// Close the loop
|
||||||
|
pts.push(pts[0]);
|
||||||
|
|
||||||
|
polyline = document.createElementNS(NS, 'polyline');
|
||||||
|
polyline.setAttribute('points', pts.join(' '));
|
||||||
|
polyline.setAttribute('fill', 'none');
|
||||||
|
polyline.setAttribute('stroke', '#333');
|
||||||
|
polyline.setAttribute('stroke-width', '1.5');
|
||||||
|
polyline.setAttribute('opacity', '0.7');
|
||||||
|
svg.appendChild(polyline);
|
||||||
|
|
||||||
|
// Draw beads
|
||||||
|
beadEls = [];
|
||||||
|
for (var i = 0; i < 60; i++) {
|
||||||
|
var pos = beadPositions[i];
|
||||||
|
var isCrucifix = CRUCIFIX_BEADS.has(i);
|
||||||
|
var isLarge = LARGE_BEADS.has(i);
|
||||||
|
|
||||||
|
if (isCrucifix) {
|
||||||
|
// Draw a simple cross symbol
|
||||||
|
var g = document.createElementNS(NS, 'g');
|
||||||
|
g.setAttribute('transform', 'translate(' + pos.x + ',' + pos.y + ')');
|
||||||
|
// Beads are clickable — enable pointer events on this element
|
||||||
|
g.style.pointerEvents = 'all';
|
||||||
|
g.style.cursor = 'pointer';
|
||||||
|
|
||||||
|
var vert = document.createElementNS(NS, 'line');
|
||||||
|
vert.setAttribute('x1', '0'); vert.setAttribute('y1', '-14');
|
||||||
|
vert.setAttribute('x2', '0'); vert.setAttribute('y2', '14');
|
||||||
|
vert.setAttribute('stroke', COLOR_UNPRAYED);
|
||||||
|
vert.setAttribute('stroke-width', '3');
|
||||||
|
vert.setAttribute('stroke-linecap', 'round');
|
||||||
|
|
||||||
|
var horiz = document.createElementNS(NS, 'line');
|
||||||
|
horiz.setAttribute('x1', '-8'); horiz.setAttribute('y1', '-4');
|
||||||
|
horiz.setAttribute('x2', '8'); horiz.setAttribute('y2', '-4');
|
||||||
|
horiz.setAttribute('stroke', COLOR_UNPRAYED);
|
||||||
|
horiz.setAttribute('stroke-width', '3');
|
||||||
|
horiz.setAttribute('stroke-linecap', 'round');
|
||||||
|
|
||||||
|
// Invisible hit-area circle for color tracking and clicks
|
||||||
|
var hitCircle = document.createElementNS(NS, 'circle');
|
||||||
|
hitCircle.setAttribute('cx', '0'); hitCircle.setAttribute('cy', '0');
|
||||||
|
hitCircle.setAttribute('r', R_CRUCIFIX);
|
||||||
|
hitCircle.setAttribute('fill', 'transparent');
|
||||||
|
|
||||||
|
g.appendChild(vert);
|
||||||
|
g.appendChild(horiz);
|
||||||
|
g.appendChild(hitCircle);
|
||||||
|
g.dataset.beadIndex = i;
|
||||||
|
g._vertLine = vert;
|
||||||
|
g._horizLine = horiz;
|
||||||
|
|
||||||
|
// Click: emit bead index to registered handler
|
||||||
|
(function (beadIdx) {
|
||||||
|
g.addEventListener('click', function () {
|
||||||
|
if (_beadClickHandler) _beadClickHandler(beadIdx);
|
||||||
|
});
|
||||||
|
})(i);
|
||||||
|
|
||||||
|
svg.appendChild(g);
|
||||||
|
beadEls.push(g);
|
||||||
|
|
||||||
|
} else {
|
||||||
|
var r = isLarge ? R_LARGE : R_SMALL;
|
||||||
|
var circle = document.createElementNS(NS, 'circle');
|
||||||
|
circle.setAttribute('cx', pos.x);
|
||||||
|
circle.setAttribute('cy', pos.y);
|
||||||
|
circle.setAttribute('r', r);
|
||||||
|
circle.setAttribute('fill', COLOR_UNPRAYED);
|
||||||
|
circle.dataset.beadIndex = i;
|
||||||
|
// Beads are clickable — enable pointer events on this element
|
||||||
|
circle.style.pointerEvents = 'all';
|
||||||
|
circle.style.cursor = 'pointer';
|
||||||
|
|
||||||
|
// Click: emit bead index to registered handler
|
||||||
|
(function (beadIdx) {
|
||||||
|
circle.addEventListener('click', function () {
|
||||||
|
if (_beadClickHandler) _beadClickHandler(beadIdx);
|
||||||
|
});
|
||||||
|
})(i);
|
||||||
|
|
||||||
|
svg.appendChild(circle);
|
||||||
|
beadEls.push(circle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
container.appendChild(svg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
// applyColors — internal: paint all beads for the given state.
|
||||||
|
// currentBeadIndex : bead being prayed NOW (gold glow), or null
|
||||||
|
// lastPrayedBeadIndex: highest bead index prayed so far (all <= this are red)
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
function applyColors(currentBeadIndex, lastPrayedBeadIndex) {
|
||||||
|
if (!svg) return;
|
||||||
|
|
||||||
|
var last = (lastPrayedBeadIndex !== null && lastPrayedBeadIndex !== undefined)
|
||||||
|
? lastPrayedBeadIndex : -1;
|
||||||
|
|
||||||
|
// Once the final bead of decade 5 (index 59) has been prayed and there is
|
||||||
|
// no active bead, the cross turns gold and stays gold for the rest of the
|
||||||
|
// presentation (litanies, novena prayer, closing slide).
|
||||||
|
var allDecadesDone = (last >= 59) &&
|
||||||
|
(currentBeadIndex === null || currentBeadIndex === undefined);
|
||||||
|
|
||||||
|
beadEls.forEach(function (el, i) {
|
||||||
|
var isCrucifix = CRUCIFIX_BEADS.has(i);
|
||||||
|
var color;
|
||||||
|
var isActive = false;
|
||||||
|
|
||||||
|
if (currentBeadIndex !== null && currentBeadIndex !== undefined) {
|
||||||
|
// On a bead slide: gold = current, red = prayed, gray = future
|
||||||
|
if (i === currentBeadIndex) {
|
||||||
|
color = COLOR_CURRENT;
|
||||||
|
isActive = true;
|
||||||
|
} else if (i < currentBeadIndex) {
|
||||||
|
color = COLOR_PRAYED;
|
||||||
|
} else {
|
||||||
|
color = COLOR_UNPRAYED;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Between-bead slide (Glory Be, Fatima, litany, etc.)
|
||||||
|
if (isCrucifix && allDecadesDone) {
|
||||||
|
// All 5 decades complete — crucifix glows gold
|
||||||
|
color = COLOR_CURRENT;
|
||||||
|
isActive = true;
|
||||||
|
} else {
|
||||||
|
color = (i <= last) ? COLOR_PRAYED : COLOR_UNPRAYED;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCrucifix) {
|
||||||
|
el._vertLine.setAttribute('stroke', color);
|
||||||
|
el._horizLine.setAttribute('stroke', color);
|
||||||
|
if (isActive) { el.setAttribute('filter', 'url(#glow)'); }
|
||||||
|
else { el.removeAttribute('filter'); }
|
||||||
|
} else {
|
||||||
|
el.setAttribute('fill', color);
|
||||||
|
if (isActive) { el.setAttribute('filter', 'url(#glow)'); }
|
||||||
|
else { el.removeAttribute('filter'); }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
// update — public API. Saves state so a resize rebuild can restore it.
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
var _lastCurrent = null;
|
||||||
|
var _lastPrayed = null;
|
||||||
|
|
||||||
|
function update(currentBeadIndex, lastPrayedBeadIndex) {
|
||||||
|
_lastCurrent = currentBeadIndex;
|
||||||
|
_lastPrayed = lastPrayedBeadIndex;
|
||||||
|
applyColors(currentBeadIndex, lastPrayedBeadIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
// Initialize
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
function init() {
|
||||||
|
build();
|
||||||
|
window.addEventListener('resize', function () {
|
||||||
|
build();
|
||||||
|
applyColors(_lastCurrent, _lastPrayed); // restore state after SVG rebuild
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run on DOM ready
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', init);
|
||||||
|
} else {
|
||||||
|
init();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onBeadClick(fn) {
|
||||||
|
_beadClickHandler = fn;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { update: update, onBeadClick: onBeadClick };
|
||||||
|
})();
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
/**
|
||||||
|
* assets/js/setup.js
|
||||||
|
* Setup form interactivity: conditional field visibility + form submission.
|
||||||
|
*/
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const form = document.getElementById('session-form');
|
||||||
|
const occasionSel = document.getElementById('occasion');
|
||||||
|
const photoInput = document.getElementById('photo');
|
||||||
|
const uploadStatus = document.getElementById('upload-status');
|
||||||
|
const submitBtn = document.getElementById('submit-btn');
|
||||||
|
const msgBox = document.getElementById('form-message');
|
||||||
|
const nameHelp = document.getElementById('name-help');
|
||||||
|
|
||||||
|
// Are we editing an existing session?
|
||||||
|
const isEditMode = !!form.querySelector('[name="id"]');
|
||||||
|
|
||||||
|
// Fields to show/hide by occasion (field IDs without the "field-" prefix)
|
||||||
|
const OCCASION_FIELDS = {
|
||||||
|
novena_deceased: ['novena_day', 'novena_mystery_mode', 'subject_name', 'subject_pronoun', 'subject_dates'],
|
||||||
|
divine_mercy_novena:['novena_day'],
|
||||||
|
memorial: ['subject_name', 'subject_pronoun', 'subject_dates'],
|
||||||
|
general_rosary: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const mysteryStandardWrap = document.getElementById('field-mystery_set_standard');
|
||||||
|
|
||||||
|
function toggleFields() {
|
||||||
|
const occasion = occasionSel.value;
|
||||||
|
const show = OCCASION_FIELDS[occasion] || [];
|
||||||
|
const isNovenaDeceased = (occasion === 'novena_deceased');
|
||||||
|
const isDivineMercy = (occasion === 'divine_mercy_novena');
|
||||||
|
const isAnyNovena = isNovenaDeceased || isDivineMercy;
|
||||||
|
|
||||||
|
// Standard mystery dropdown: hide for both novena types
|
||||||
|
if (mysteryStandardWrap) {
|
||||||
|
mysteryStandardWrap.style.display = isAnyNovena ? 'none' : '';
|
||||||
|
const sel = document.getElementById('mystery_set');
|
||||||
|
if (sel) {
|
||||||
|
if (isAnyNovena) {
|
||||||
|
sel.removeAttribute('required');
|
||||||
|
if (!sel.value) sel.value = 'sorrowful';
|
||||||
|
} else {
|
||||||
|
sel.setAttribute('required', '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show/hide conditional fields
|
||||||
|
document.querySelectorAll('.conditional-field').forEach(el => {
|
||||||
|
const fieldName = el.id.replace('field-', '');
|
||||||
|
// novena_mystery_mode only shows for novena_deceased, not divine_mercy_novena
|
||||||
|
if (fieldName === 'novena_mystery_mode' && !isNovenaDeceased) {
|
||||||
|
el.style.display = 'none';
|
||||||
|
el.querySelectorAll('input, select').forEach(input => input.removeAttribute('required'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const visible = show.includes(fieldName);
|
||||||
|
el.style.display = visible ? '' : 'none';
|
||||||
|
el.querySelectorAll('input, select').forEach(input => {
|
||||||
|
if (!visible) input.removeAttribute('required');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update submit button label
|
||||||
|
if (submitBtn) {
|
||||||
|
if (isNovenaDeceased) {
|
||||||
|
submitBtn.textContent = submitBtn.dataset.labelNovenaDeceased;
|
||||||
|
} else if (isDivineMercy) {
|
||||||
|
submitBtn.textContent = submitBtn.dataset.labelDivineMercy;
|
||||||
|
} else {
|
||||||
|
submitBtn.textContent = submitBtn.dataset.labelDefault;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update session name help text
|
||||||
|
if (nameHelp) {
|
||||||
|
nameHelp.style.display = isAnyNovena && !isEditMode ? '' : 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
occasionSel.addEventListener('change', toggleFields);
|
||||||
|
toggleFields(); // run on page load (handles edit mode pre-selection)
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Photo upload on file selection
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
var photoUploading = false;
|
||||||
|
|
||||||
|
if (photoInput) {
|
||||||
|
photoInput.addEventListener('change', async function () {
|
||||||
|
if (!this.files || !this.files[0]) return;
|
||||||
|
|
||||||
|
const file = this.files[0];
|
||||||
|
uploadStatus.style.display = '';
|
||||||
|
uploadStatus.className = 'upload-status uploading';
|
||||||
|
uploadStatus.textContent = 'Uploading\u2026';
|
||||||
|
|
||||||
|
// Disable submit while upload is in progress
|
||||||
|
photoUploading = true;
|
||||||
|
if (submitBtn) submitBtn.disabled = true;
|
||||||
|
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('photo', file);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(BASE_URL + '/api/upload_photo.php', { method: 'POST', body: fd });
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (data.error) {
|
||||||
|
uploadStatus.className = 'upload-status error';
|
||||||
|
uploadStatus.textContent = 'Upload failed: ' + data.error;
|
||||||
|
} else {
|
||||||
|
document.getElementById('photo_path').value = data.path;
|
||||||
|
// Clear the file input so the file is not re-sent with the main form
|
||||||
|
photoInput.value = '';
|
||||||
|
uploadStatus.className = 'upload-status success';
|
||||||
|
uploadStatus.textContent = 'Photo uploaded successfully.';
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
uploadStatus.className = 'upload-status error';
|
||||||
|
uploadStatus.textContent = 'Network error during upload.';
|
||||||
|
} finally {
|
||||||
|
photoUploading = false;
|
||||||
|
if (submitBtn) submitBtn.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Form submission via fetch
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
form.addEventListener('submit', async function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!form.checkValidity()) {
|
||||||
|
form.reportValidity();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
submitBtn.textContent = 'Saving\u2026';
|
||||||
|
showMessage('', '');
|
||||||
|
|
||||||
|
const fd = new FormData(form);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(BASE_URL + '/api/save_session.php', { method: 'POST', body: fd });
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (data.error) {
|
||||||
|
showMessage('error', data.error);
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
const occ = occasionSel.value;
|
||||||
|
submitBtn.textContent = isEditMode
|
||||||
|
? submitBtn.dataset.labelDefault
|
||||||
|
: (occ === 'novena_deceased' ? submitBtn.dataset.labelNovenaDeceased
|
||||||
|
: occ === 'divine_mercy_novena' ? submitBtn.dataset.labelDivineMercy
|
||||||
|
: submitBtn.dataset.labelDefault);
|
||||||
|
} else if (data.novena) {
|
||||||
|
// All 9 days created — go to admin dashboard
|
||||||
|
window.location.href = BASE_URL + '/admin/?novena_created=' + data.ids.length;
|
||||||
|
} else {
|
||||||
|
// Single session — go to presentation
|
||||||
|
window.location.href = BASE_URL + '/present.php?id=' + data.id;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showMessage('error', 'Network error. Please try again.');
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.textContent = submitBtn.dataset.labelDefault;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function showMessage(type, text) {
|
||||||
|
if (!text) {
|
||||||
|
msgBox.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
msgBox.style.display = '';
|
||||||
|
msgBox.className = 'alert alert-' + type;
|
||||||
|
msgBox.textContent = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
})();
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
<?php
|
||||||
|
// Copy this file to config/db.php and fill in your values.
|
||||||
|
// Never commit config/db.php — it is listed in .gitignore.
|
||||||
|
|
||||||
|
define('DB_HOST', 'localhost');
|
||||||
|
define('DB_NAME', 'your_db_name');
|
||||||
|
define('DB_USER', 'your_db_user');
|
||||||
|
define('DB_PASS', 'your_db_password');
|
||||||
|
define('DB_CHARSET', 'utf8mb4');
|
||||||
|
|
||||||
|
// App constants
|
||||||
|
define('UPLOADS_DIR', __DIR__ . '/../uploads/');
|
||||||
|
define('UPLOADS_URL', '/uploads/');
|
||||||
|
define('APP_NAME', 'Rosary Presenter');
|
||||||
|
|
||||||
|
// Base URL path — leave empty for domain root, set to '/subdir' for subdirectory deployment
|
||||||
|
// Example: define('BASE_URL', '/rosary');
|
||||||
|
define('BASE_URL', '');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return a PDO connection, creating it on first call.
|
||||||
|
*/
|
||||||
|
function get_pdo(): PDO {
|
||||||
|
static $pdo = null;
|
||||||
|
if ($pdo === null) {
|
||||||
|
$dsn = 'mysql:host=' . DB_HOST . ';dbname=' . DB_NAME . ';charset=' . DB_CHARSET;
|
||||||
|
$options = [
|
||||||
|
PDO::ATTR_ERRMODE => 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;
|
||||||
|
}
|
||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
require_once __DIR__ . '/config/db.php';
|
||||||
|
|
||||||
|
$token = trim($_GET['token'] ?? '');
|
||||||
|
|
||||||
|
if ($token === '') {
|
||||||
|
header('Location: ' . BASE_URL . '/login');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo = get_pdo();
|
||||||
|
$stmt = $pdo->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;
|
||||||
+1213
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,7 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||||
|
<rect width="32" height="32" rx="5" fill="#1e3a5f"/>
|
||||||
|
<!-- vertical bar -->
|
||||||
|
<rect x="14" y="4" width="4" height="24" rx="1" fill="#b8860b"/>
|
||||||
|
<!-- horizontal bar -->
|
||||||
|
<rect x="6" y="10" width="20" height="4" rx="1" fill="#b8860b"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 307 B |
@@ -0,0 +1,85 @@
|
|||||||
|
<?php
|
||||||
|
require_once __DIR__ . '/config/db.php';
|
||||||
|
require_once __DIR__ . '/includes/mailer.php';
|
||||||
|
|
||||||
|
$sent = false;
|
||||||
|
$site_name = get_setting('site_name', APP_NAME);
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
$email = trim($_POST['email'] ?? '');
|
||||||
|
|
||||||
|
if (filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||||
|
$pdo = get_pdo();
|
||||||
|
$stmt = $pdo->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 = "
|
||||||
|
<h2 style='margin-top:0;color:#1e3a5f'>Reset your password</h2>
|
||||||
|
<p>Hello, <strong>" . htmlspecialchars($name) . "</strong>!</p>
|
||||||
|
<p>We received a request to reset your password for your {$site_name} account.</p>
|
||||||
|
<p style='text-align:center;margin:28px 0'>
|
||||||
|
<a href='" . htmlspecialchars($link) . "' style='display:inline-block;background:#1e3a5f;color:#fff;padding:12px 28px;border-radius:6px;text-decoration:none;font-weight:600'>Reset Password</a>
|
||||||
|
</p>
|
||||||
|
<p style='color:#6b7280;font-size:13px'>This link expires in 1 hour.</p>
|
||||||
|
<p style='color:#6b7280;font-size:13px'>Or copy this link: " . htmlspecialchars($link) . "</p>
|
||||||
|
<p style='color:#6b7280;font-size:13px'>If you did not request a password reset, ignore this email.</p>
|
||||||
|
";
|
||||||
|
$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;
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<link rel="icon" type="image/svg+xml" href="<?= BASE_URL ?>/favicon.svg">
|
||||||
|
<title>Forgot Password — <?= htmlspecialchars($site_name) ?></title>
|
||||||
|
<link rel="stylesheet" href="<?= BASE_URL ?>/assets/css/setup.css">
|
||||||
|
</head>
|
||||||
|
<body class="login-page">
|
||||||
|
<div class="login-box">
|
||||||
|
<h1>✝ <?= htmlspecialchars($site_name) ?></h1>
|
||||||
|
<h2>Forgot Password</h2>
|
||||||
|
|
||||||
|
<?php if ($sent): ?>
|
||||||
|
<div class="alert alert-success">
|
||||||
|
If that email address exists in our system, you'll receive a password reset link shortly. Check your inbox.
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:20px;text-align:center">
|
||||||
|
<a href="<?= BASE_URL ?>/login" class="btn btn-ghost">Back to Login</a>
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<p style="color:#6b7280;margin-bottom:20px;font-size:14px">
|
||||||
|
Enter your email address and we'll send you a link to reset your password.
|
||||||
|
</p>
|
||||||
|
<form method="post" action="<?= BASE_URL ?>/forgot-password">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="email">Email Address</label>
|
||||||
|
<input type="email" id="email" name="email" autofocus required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary btn-full">Send Reset Link</button>
|
||||||
|
</form>
|
||||||
|
<div style="margin-top:20px;text-align:center;font-size:14px">
|
||||||
|
<a href="<?= BASE_URL ?>/login" style="color:#1e3a5f">Back to Login</a>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* includes/auth.php — multi-user role-based authentication.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function _auth_start(): void {
|
||||||
|
if (session_status() === PHP_SESSION_NONE) session_start();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Redirect to login if not authenticated. */
|
||||||
|
function require_auth(): void {
|
||||||
|
_auth_start();
|
||||||
|
if (empty($_SESSION['user_id'])) {
|
||||||
|
header('Location: ' . BASE_URL . '/login');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Redirect/abort if user doesn't have the minimum role. */
|
||||||
|
function require_role(string $min_role): void {
|
||||||
|
require_auth();
|
||||||
|
if (!has_role($min_role)) {
|
||||||
|
http_response_code(403);
|
||||||
|
echo '<!DOCTYPE html><html><body style="font-family:system-ui;max-width:500px;margin:60px auto;text-align:center">'
|
||||||
|
. '<h1 style="color:#dc2626">Access Denied</h1>'
|
||||||
|
. '<p>You do not have permission to view this page.</p>'
|
||||||
|
. '<a href="' . BASE_URL . '/admin/">← Dashboard</a></body></html>';
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* includes/build_slides.php
|
||||||
|
*
|
||||||
|
* build_slides(array $session): array
|
||||||
|
*
|
||||||
|
* Assembles the full slide sequence for a rosary session.
|
||||||
|
* Applies variable substitution for {name}, {pronoun}, {pronoun_obj}, {pronoun_poss}.
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../data/prayers.php';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the slide array for one decade of the Chaplet of Divine Mercy.
|
||||||
|
*
|
||||||
|
* Large bead : "Eternal Father…"
|
||||||
|
* Small beads: "For the sake of His sorrowful Passion…" × 10
|
||||||
|
* No mystery announcement; no Glory Be between decades.
|
||||||
|
*
|
||||||
|
* @param int $decade_num 1–5
|
||||||
|
* @param int $of_bead_index bead_index for the large (Our Father) bead
|
||||||
|
* @param int $hm_bead_start bead_index for the first small bead of this decade
|
||||||
|
*/
|
||||||
|
function build_chaplet_decade_slides(int $decade_num, int $of_bead_index, int $hm_bead_start): array {
|
||||||
|
$slides = [];
|
||||||
|
|
||||||
|
$slides[] = [
|
||||||
|
'id' => '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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* includes/donate.php — Renders the public donate strip if enabled.
|
||||||
|
* Include on any public page: require_once __DIR__ . '/includes/donate.php';
|
||||||
|
* Then call: render_donate_strip();
|
||||||
|
*/
|
||||||
|
|
||||||
|
function render_donate_strip(): void {
|
||||||
|
if (!get_setting('donate_enabled', '0')) return;
|
||||||
|
|
||||||
|
$type = get_setting('donate_type', 'custom');
|
||||||
|
$handle = trim(get_setting('donate_handle', ''));
|
||||||
|
$label = trim(get_setting('donate_label', ''));
|
||||||
|
|
||||||
|
if ($handle === '') return;
|
||||||
|
|
||||||
|
// Build URL and default label by type
|
||||||
|
switch ($type) {
|
||||||
|
case 'paypal':
|
||||||
|
$url = 'https://paypal.me/' . rawurlencode(ltrim($handle, '@'));
|
||||||
|
$icon = '💛'; // 💛
|
||||||
|
$label = $label ?: 'Support via PayPal';
|
||||||
|
break;
|
||||||
|
case 'venmo':
|
||||||
|
$url = 'https://venmo.com/u/' . rawurlencode(ltrim($handle, '@'));
|
||||||
|
$icon = '💸'; // 💸
|
||||||
|
$label = $label ?: '@' . ltrim($handle, '@') . ' on Venmo';
|
||||||
|
break;
|
||||||
|
case 'buymeacoffee':
|
||||||
|
$url = 'https://buymeacoffee.com/' . rawurlencode(ltrim($handle, '@'));
|
||||||
|
$icon = '☕'; // ☕
|
||||||
|
$label = $label ?: 'Buy Me a Coffee';
|
||||||
|
break;
|
||||||
|
default: // custom
|
||||||
|
$url = $handle; // full URL stored in handle field for custom
|
||||||
|
$icon = '❤'; // ❤
|
||||||
|
$label = $label ?: 'Support This Ministry';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
<div class="donate-strip">
|
||||||
|
<span class="donate-strip-text">Help keep this site running</span>
|
||||||
|
<a href="<?= htmlspecialchars($url) ?>" target="_blank" rel="noopener" class="donate-strip-link">
|
||||||
|
<?= $icon ?> <?= htmlspecialchars($label) ?>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* includes/mailer.php — lightweight SMTP mailer using stream_socket_client().
|
||||||
|
* Falls back to PHP mail() when smtp_host is not configured.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send an email.
|
||||||
|
*
|
||||||
|
* @param string $to_email Recipient email address
|
||||||
|
* @param string $to_name Recipient display name
|
||||||
|
* @param string $subject Email subject
|
||||||
|
* @param string $html HTML body
|
||||||
|
* @param string $text Plain-text body (auto-generated from HTML if empty)
|
||||||
|
* @return bool True on success, false on failure
|
||||||
|
*/
|
||||||
|
function send_email(string $to_email, string $to_name, string $subject, string $html, string $text = ''): bool {
|
||||||
|
$smtp_host = get_setting('smtp_host');
|
||||||
|
$smtp_port = (int) 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');
|
||||||
|
|
||||||
|
if ($text === '') {
|
||||||
|
$text = strip_tags(preg_replace('/<br\s*\/?>/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 <<<HTML
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>{$title}</title>
|
||||||
|
</head>
|
||||||
|
<body style="margin:0;padding:0;background:#f4f4f5;font-family:system-ui,-apple-system,sans-serif">
|
||||||
|
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f4f4f5;padding:40px 20px">
|
||||||
|
<tr><td align="center">
|
||||||
|
<table width="600" cellpadding="0" cellspacing="0" style="background:#ffffff;border-radius:8px;overflow:hidden;max-width:600px;width:100%">
|
||||||
|
<tr>
|
||||||
|
<td style="background:#1e3a5f;padding:24px 32px;text-align:center">
|
||||||
|
<h1 style="margin:0;color:#ffffff;font-size:22px;font-weight:600">✝ {$site_name}</h1>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:32px">
|
||||||
|
{$body_html}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="background:#f4f4f5;padding:20px 32px;text-align:center;color:#6b7280;font-size:13px">
|
||||||
|
© <?= date('Y') ?> {$site_name}. This email was sent automatically.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,430 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* index.php — Public home page. Shows all public rosary sessions.
|
||||||
|
* No auth required.
|
||||||
|
*
|
||||||
|
* MIGRATION (run once on existing installs):
|
||||||
|
* ALTER TABLE sessions ADD COLUMN is_pinned TINYINT(1) NOT NULL DEFAULT 0;
|
||||||
|
* ALTER TABLE novena_groups ADD COLUMN is_pinned TINYINT(1) NOT NULL DEFAULT 0;
|
||||||
|
*/
|
||||||
|
require_once __DIR__ . '/config/db.php';
|
||||||
|
require_once __DIR__ . '/includes/auth.php';
|
||||||
|
require_once __DIR__ . '/includes/donate.php';
|
||||||
|
|
||||||
|
_auth_start();
|
||||||
|
$pdo = get_pdo();
|
||||||
|
$site_name = get_setting('site_name', APP_NAME);
|
||||||
|
$logged_in = !empty($_SESSION['user_id']);
|
||||||
|
$username = $_SESSION['username'] ?? '';
|
||||||
|
$is_admin = $logged_in && has_role('admin');
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Queries — pinned and regular items are fetched separately
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Pinned sessions
|
||||||
|
$pinned_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 = 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'];
|
||||||
|
?>
|
||||||
|
<div class="<?= $card_class ?>"
|
||||||
|
data-name="<?= $d_name ?>"
|
||||||
|
data-subject="<?= $d_subject ?>"
|
||||||
|
data-username="<?= $d_uname ?>"
|
||||||
|
data-display="<?= $d_display ?>">
|
||||||
|
|
||||||
|
<?php if ($is_admin): ?>
|
||||||
|
<button class="pin-btn <?= $is_pinned ? 'is-pinned' : '' ?>"
|
||||||
|
data-type="<?= $type_str ?>"
|
||||||
|
data-id="<?= $row_id ?>"
|
||||||
|
title="<?= $is_pinned ? 'Unpin from Featured' : 'Pin to Featured' ?>">
|
||||||
|
<?= $is_pinned ? '📌' : '📍' ?>
|
||||||
|
</button>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if (!empty($row['photo_path'])): ?>
|
||||||
|
<img class="rosary-card-photo"
|
||||||
|
src="<?= htmlspecialchars('/' . ltrim($row['photo_path'], '/')) ?>"
|
||||||
|
alt="">
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="rosary-card-photo-placeholder">✝</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<div class="rosary-card-body">
|
||||||
|
<div class="rosary-card-title"><?= htmlspecialchars($row['name']) ?></div>
|
||||||
|
<div class="rosary-card-meta">
|
||||||
|
<?php if ($is_novena): ?>
|
||||||
|
<?php if ($is_dm): ?>
|
||||||
|
<span class="badge-divine-mercy">Divine Mercy</span>
|
||||||
|
<?php else: ?>
|
||||||
|
<span class="badge-novena">9-Day Novena</span>
|
||||||
|
<?php if ($row['subject_name']): ?>
|
||||||
|
<?= htmlspecialchars($row['subject_name']) ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php else: ?>
|
||||||
|
<?= htmlspecialchars($occasion_labels[$row['occasion']] ?? $row['occasion']) ?> •
|
||||||
|
<?= htmlspecialchars($mystery_labels[$row['mystery_set']] ?? $row['mystery_set']) ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<div class="rosary-card-footer">
|
||||||
|
<span class="rosary-card-by">By <?= htmlspecialchars($disp_name) ?></span>
|
||||||
|
<a href="<?= htmlspecialchars($url) ?>" class="rosary-card-link"><?= $link_text ?></a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<link rel="icon" type="image/svg+xml" href="<?= BASE_URL ?>/favicon.svg">
|
||||||
|
<title><?= htmlspecialchars($site_name) ?></title>
|
||||||
|
<link rel="stylesheet" href="<?= BASE_URL ?>/assets/css/public.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<nav class="pub-nav">
|
||||||
|
<a href="<?= BASE_URL ?>/" class="pub-nav-brand">✝ <span><?= htmlspecialchars($site_name) ?></span></a>
|
||||||
|
<div class="pub-nav-links">
|
||||||
|
<?php if ($logged_in): ?>
|
||||||
|
<a href="<?= BASE_URL ?>/admin/">Dashboard</a>
|
||||||
|
<a href="<?= BASE_URL ?>/logout">Logout</a>
|
||||||
|
<?php else: ?>
|
||||||
|
<a href="<?= BASE_URL ?>/login">Sign In</a>
|
||||||
|
<a href="<?= BASE_URL ?>/register" class="btn-nav">Get Started</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="pub-hero">
|
||||||
|
<h1>✝ Pray the Rosary Together</h1>
|
||||||
|
<p>A shared presentation for families and communities praying the Holy Rosary.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search bar -->
|
||||||
|
<div class="search-wrap">
|
||||||
|
<div class="home-search-row">
|
||||||
|
<span class="home-search-icon">🔍</span>
|
||||||
|
<input type="search" id="home-search"
|
||||||
|
class="home-search-input"
|
||||||
|
placeholder="Search by rosary name, honoree, or username…"
|
||||||
|
autocomplete="off"
|
||||||
|
aria-label="Search rosaries">
|
||||||
|
<button class="home-search-clear" id="search-clear" aria-label="Clear search">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="search-user-results" id="search-user-results" aria-live="polite"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Featured / Pinned section ── -->
|
||||||
|
<?php if (!empty($pinned)): ?>
|
||||||
|
<div class="pinned-section" id="pinned-section">
|
||||||
|
<div class="pub-section">
|
||||||
|
<h2>📌 Featured</h2>
|
||||||
|
<div class="card-grid" id="pinned-grid">
|
||||||
|
<?php foreach ($pinned as $row): render_card($row, $is_admin, $mystery_labels, $occasion_labels); endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<p class="search-no-results" id="pinned-no-results">No featured rosaries match your search.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<!-- ── All public rosaries ── -->
|
||||||
|
<div class="pub-section" id="regular-section">
|
||||||
|
<h2>Public Rosaries</h2>
|
||||||
|
|
||||||
|
<?php if (empty($regular) && empty($pinned)): ?>
|
||||||
|
<div class="pub-empty">
|
||||||
|
<span class="cross">✝</span>
|
||||||
|
<p>No public rosary sessions yet. <a href="<?= BASE_URL ?>/register">Create an account</a> to get started.</p>
|
||||||
|
</div>
|
||||||
|
<?php elseif (empty($regular)): ?>
|
||||||
|
<p class="search-no-results" style="display:block;padding:0 0 24px;text-align:left;color:var(--muted)">
|
||||||
|
All rosaries are currently featured above.
|
||||||
|
</p>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="card-grid" id="regular-grid">
|
||||||
|
<?php foreach ($regular as $row): render_card($row, $is_admin, $mystery_labels, $occasion_labels); endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<p class="search-no-results" id="regular-no-results">No rosaries match your search.</p>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php render_donate_strip(); ?>
|
||||||
|
|
||||||
|
<footer class="pub-footer">
|
||||||
|
© <?= date('Y') ?> <?= htmlspecialchars($site_name) ?>
|
||||||
|
<?php if (!$logged_in): ?>
|
||||||
|
• <a href="<?= BASE_URL ?>/register" style="color:inherit">Create Account</a>
|
||||||
|
• <a href="<?= BASE_URL ?>/login" style="color:inherit">Sign In</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
var BASE_URL = <?= json_encode(BASE_URL) ?>;
|
||||||
|
var IS_ADMIN = <?= $is_admin ? 'true' : 'false' ?>;
|
||||||
|
var PUBLIC_USERS = <?= json_encode(array_values($public_users_rows), JSON_HEX_TAG | JSON_HEX_AMP) ?>;
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Search
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
var searchInput = document.getElementById('home-search');
|
||||||
|
var clearBtn = document.getElementById('search-clear');
|
||||||
|
var userResultsBox = document.getElementById('search-user-results');
|
||||||
|
var pinnedSection = document.getElementById('pinned-section');
|
||||||
|
var pinnedGrid = document.getElementById('pinned-grid');
|
||||||
|
var regularGrid = document.getElementById('regular-grid');
|
||||||
|
var pinnedNoRes = document.getElementById('pinned-no-results');
|
||||||
|
var regularNoRes = document.getElementById('regular-no-results');
|
||||||
|
|
||||||
|
// All cards across both grids
|
||||||
|
var allCards = document.querySelectorAll('.rosary-card[data-name]');
|
||||||
|
|
||||||
|
// Build a map of username → display_name from PUBLIC_USERS
|
||||||
|
// (includes users who may not appear in any visible card, so full search covers them)
|
||||||
|
var userMap = {};
|
||||||
|
PUBLIC_USERS.forEach(function (u) { userMap[u.username] = u.display_name; });
|
||||||
|
|
||||||
|
var debounceTimer = null;
|
||||||
|
|
||||||
|
if (searchInput) {
|
||||||
|
searchInput.addEventListener('input', function () {
|
||||||
|
clearTimeout(debounceTimer);
|
||||||
|
debounceTimer = setTimeout(function () { applySearch(searchInput.value.trim()); }, 120);
|
||||||
|
clearBtn.classList.toggle('visible', searchInput.value.length > 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clearBtn) {
|
||||||
|
clearBtn.addEventListener('click', function () {
|
||||||
|
searchInput.value = '';
|
||||||
|
clearBtn.classList.remove('visible');
|
||||||
|
applySearch('');
|
||||||
|
searchInput.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySearch(raw) {
|
||||||
|
var q = raw.toLowerCase();
|
||||||
|
|
||||||
|
if (!q) {
|
||||||
|
// Reset: show everything
|
||||||
|
allCards.forEach(function (c) { c.style.display = ''; });
|
||||||
|
if (userResultsBox) userResultsBox.innerHTML = '';
|
||||||
|
toggleSectionVisibility(true, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var pinnedVisible = 0;
|
||||||
|
var regularVisible = 0;
|
||||||
|
|
||||||
|
allCards.forEach(function (card) {
|
||||||
|
var inPinned = pinnedGrid && pinnedGrid.contains(card);
|
||||||
|
var match =
|
||||||
|
(card.dataset.name || '').toLowerCase().includes(q) ||
|
||||||
|
(card.dataset.subject || '').toLowerCase().includes(q) ||
|
||||||
|
(card.dataset.username|| '').toLowerCase().includes(q) ||
|
||||||
|
(card.dataset.display || '').toLowerCase().includes(q);
|
||||||
|
|
||||||
|
card.style.display = match ? '' : 'none';
|
||||||
|
if (match) {
|
||||||
|
if (inPinned) pinnedVisible++;
|
||||||
|
else regularVisible++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
toggleSectionVisibility(pinnedVisible > 0, regularVisible > 0);
|
||||||
|
renderUserLinks(q);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSectionVisibility(showPinned, showRegular) {
|
||||||
|
if (pinnedSection) {
|
||||||
|
pinnedSection.style.display = showPinned ? '' : 'none';
|
||||||
|
if (pinnedNoRes) pinnedNoRes.style.display = showPinned ? 'none' : 'block';
|
||||||
|
}
|
||||||
|
if (regularNoRes) {
|
||||||
|
regularNoRes.style.display = (regularGrid && !showRegular) ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderUserLinks(q) {
|
||||||
|
if (!userResultsBox) return;
|
||||||
|
userResultsBox.innerHTML = '';
|
||||||
|
if (!q) return;
|
||||||
|
|
||||||
|
var matches = Object.keys(userMap).filter(function (uname) {
|
||||||
|
return uname.toLowerCase().includes(q) ||
|
||||||
|
userMap[uname].toLowerCase().includes(q);
|
||||||
|
});
|
||||||
|
|
||||||
|
matches.forEach(function (uname) {
|
||||||
|
var a = document.createElement('a');
|
||||||
|
a.href = BASE_URL + '/' + encodeURIComponent(uname);
|
||||||
|
a.className = 'search-user-pill';
|
||||||
|
a.innerHTML = '👤 ' + escHtml(userMap[uname]) + '\'s rosaries →';
|
||||||
|
userResultsBox.appendChild(a);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function escHtml(str) {
|
||||||
|
return str.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Pin / Unpin toggle (admin only)
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
if (IS_ADMIN) {
|
||||||
|
document.querySelectorAll('.pin-btn').forEach(function (btn) {
|
||||||
|
btn.addEventListener('click', function (e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
var type = btn.dataset.type;
|
||||||
|
var id = btn.dataset.id;
|
||||||
|
btn.disabled = true;
|
||||||
|
|
||||||
|
var fd = new FormData();
|
||||||
|
fd.append('type', type);
|
||||||
|
fd.append('id', id);
|
||||||
|
|
||||||
|
fetch(BASE_URL + '/api/toggle_pin.php', { method: 'POST', body: fd })
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function () { window.location.reload(); })
|
||||||
|
.catch(function () {
|
||||||
|
btn.disabled = false;
|
||||||
|
alert('Could not toggle pin. Please try again.');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+218
@@ -0,0 +1,218 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* install.php — Full database installer.
|
||||||
|
* Creates all tables, seeds settings and superadmin account.
|
||||||
|
* Run once in browser, then DELETE this file.
|
||||||
|
*/
|
||||||
|
require_once __DIR__ . '/config/db.php';
|
||||||
|
|
||||||
|
$pdo = get_pdo();
|
||||||
|
$log = [];
|
||||||
|
$errors = [];
|
||||||
|
|
||||||
|
function inst_sql(PDO $pdo, string $label, string $sql, array &$log, array &$errors): void {
|
||||||
|
try {
|
||||||
|
$pdo->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);
|
||||||
|
?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>Install — <?= APP_NAME ?></title>
|
||||||
|
<style>
|
||||||
|
*{box-sizing:border-box}
|
||||||
|
body{font-family:system-ui,-apple-system,sans-serif;background:#f4f4f5;margin:0;padding:32px 16px}
|
||||||
|
.wrap{max-width:720px;margin:0 auto}
|
||||||
|
h1{font-size:26px;margin-bottom:4px}
|
||||||
|
.banner{border-radius:8px;padding:20px 24px;margin-bottom:24px;font-size:15px}
|
||||||
|
.banner.ok{background:#d1fae5;border:1px solid #6ee7b7;color:#065f46}
|
||||||
|
.banner.err{background:#fee2e2;border:1px solid #fca5a5;color:#991b1b}
|
||||||
|
.warn{background:#fef3c7;border:1px solid #fcd34d;color:#92400e;border-radius:8px;padding:16px 20px;margin-bottom:24px;font-weight:600}
|
||||||
|
.card{background:#fff;border-radius:8px;padding:24px;margin-bottom:20px;box-shadow:0 1px 3px rgba(0,0,0,.07)}
|
||||||
|
.cred{background:#1e3a5f;color:#e0f2fe;border-radius:6px;padding:16px 20px;font-family:monospace;font-size:15px;line-height:1.9}
|
||||||
|
table{width:100%;border-collapse:collapse;font-size:13px}
|
||||||
|
th,td{text-align:left;padding:6px 10px;border-bottom:1px solid #e5e7eb}
|
||||||
|
th{background:#f9fafb;font-weight:600}
|
||||||
|
.ok{color:#15803d}.err{color:#b91c1c}.skip{color:#d97706}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
<h1>✝ <?= APP_NAME ?> — Installer</h1>
|
||||||
|
|
||||||
|
<?php if ($overall_ok): ?>
|
||||||
|
<div class="banner ok">
|
||||||
|
<strong>Installation complete!</strong> All tables created and seeded successfully.
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="banner err">
|
||||||
|
<strong>Installation finished with errors.</strong>
|
||||||
|
Review the log below. Check your credentials in <code>config/db.php</code> and try again.
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<div class="warn">
|
||||||
|
⚠ DELETE <code>install.php</code> from your server immediately after reviewing this page.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2 style="margin-top:0">Superadmin Credentials</h2>
|
||||||
|
<div class="cred">
|
||||||
|
Username: supadmin<br>
|
||||||
|
Password: supadmin<br>
|
||||||
|
Role: superadmin (unlimited rosaries)
|
||||||
|
</div>
|
||||||
|
<p style="color:#b91c1c;font-weight:600;margin-top:12px">
|
||||||
|
Change the password immediately — go to
|
||||||
|
<a href="<?= BASE_URL ?>/admin/profile">/admin/profile</a> after logging in.<br>
|
||||||
|
Also update the email from <code>admin@example.com</code> to your real address.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2 style="margin-top:0">Installation Log</h2>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Status</th><th>Step</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($log as [$status, $msg]): ?>
|
||||||
|
<tr>
|
||||||
|
<td class="<?= $status ?>">
|
||||||
|
<?= $status === 'ok' ? '✓ OK' : ($status === 'skip' ? '— SKIP' : '✗ ERROR') ?>
|
||||||
|
</td>
|
||||||
|
<td><?= htmlspecialchars($msg) ?></td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if ($overall_ok): ?>
|
||||||
|
<div class="card">
|
||||||
|
<h2 style="margin-top:0">Next Steps</h2>
|
||||||
|
<ol style="line-height:2">
|
||||||
|
<li>Delete <code>install.php</code> from your server.</li>
|
||||||
|
<li>Go to <a href="<?= BASE_URL ?>/login">/login</a> — sign in with <strong>supadmin / supadmin</strong>.</li>
|
||||||
|
<li>Go to <a href="<?= BASE_URL ?>/admin/profile">/admin/profile</a> — change your password and email.</li>
|
||||||
|
<li>Go to <a href="<?= BASE_URL ?>/admin/settings">/admin/settings</a> — set your site URL and configure SMTP.</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
<?php
|
||||||
|
require_once __DIR__ . '/config/db.php';
|
||||||
|
require_once __DIR__ . '/includes/auth.php';
|
||||||
|
|
||||||
|
_auth_start();
|
||||||
|
|
||||||
|
// Already logged in
|
||||||
|
if (!empty($_SESSION['user_id'])) {
|
||||||
|
header('Location: ' . BASE_URL . '/admin/');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$error = '';
|
||||||
|
$username = '';
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
$username = trim($_POST['username'] ?? '');
|
||||||
|
$password = $_POST['password'] ?? '';
|
||||||
|
|
||||||
|
if ($username === '' || $password === '') {
|
||||||
|
$error = 'Please enter your username/email and password.';
|
||||||
|
} else {
|
||||||
|
$pdo = get_pdo();
|
||||||
|
// Allow login by username OR email
|
||||||
|
$stmt = $pdo->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.' : '';
|
||||||
|
?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<link rel="icon" type="image/svg+xml" href="<?= BASE_URL ?>/favicon.svg">
|
||||||
|
<title>Login — <?= htmlspecialchars(get_setting('site_name', APP_NAME)) ?></title>
|
||||||
|
<link rel="stylesheet" href="<?= BASE_URL ?>/assets/css/setup.css">
|
||||||
|
</head>
|
||||||
|
<body class="login-page">
|
||||||
|
<div class="login-box">
|
||||||
|
<h1>✝ <?= htmlspecialchars(get_setting('site_name', APP_NAME)) ?></h1>
|
||||||
|
<h2>Sign In</h2>
|
||||||
|
|
||||||
|
<?php if ($confirmed_msg): ?>
|
||||||
|
<div class="alert alert-success"><?= htmlspecialchars($confirmed_msg) ?></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if ($reset_msg): ?>
|
||||||
|
<div class="alert alert-success"><?= htmlspecialchars($reset_msg) ?></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if ($error): ?>
|
||||||
|
<div class="alert alert-error"><?= htmlspecialchars($error) ?></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<form method="post" action="<?= BASE_URL ?>/login">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="username">Username or Email</label>
|
||||||
|
<input type="text" id="username" name="username"
|
||||||
|
value="<?= htmlspecialchars($username) ?>"
|
||||||
|
autocomplete="username" autofocus required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password">Password</label>
|
||||||
|
<input type="password" id="password" name="password"
|
||||||
|
autocomplete="current-password" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary btn-full">Sign In</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div style="margin-top:20px;text-align:center;font-size:14px;color:#6b7280">
|
||||||
|
<a href="<?= BASE_URL ?>/forgot-password" style="color:#1e3a5f">Forgot password?</a>
|
||||||
|
•
|
||||||
|
<a href="<?= BASE_URL ?>/register" style="color:#1e3a5f">Create an account</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?php
|
||||||
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
|
session_start();
|
||||||
|
}
|
||||||
|
$_SESSION = [];
|
||||||
|
session_destroy();
|
||||||
|
require_once __DIR__ . '/config/db.php';
|
||||||
|
header('Location: ' . BASE_URL . '/login');
|
||||||
|
exit;
|
||||||
+117
@@ -0,0 +1,117 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* migrate_v2.php — One-time database migration.
|
||||||
|
* Run once in the browser, then DELETE this file from the server.
|
||||||
|
*
|
||||||
|
* What it does:
|
||||||
|
* 1. Creates the novena_groups table
|
||||||
|
* 2. Adds novena_group_id column to sessions
|
||||||
|
* 3. Groups any existing novena day sessions under novena_group records
|
||||||
|
*/
|
||||||
|
require_once __DIR__ . '/config/db.php';
|
||||||
|
|
||||||
|
$pdo = get_pdo();
|
||||||
|
$pdo->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.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
?><!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Migrate v2</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: system-ui, sans-serif; max-width: 640px; margin: 60px auto; padding: 0 24px; color: #1d2027; }
|
||||||
|
h1 { color: #2563eb; margin-bottom: 20px; }
|
||||||
|
ul { background: #f0fdf4; border: 1px solid #86efac; border-radius: 8px; padding: 16px 16px 16px 36px; margin-bottom: 20px; }
|
||||||
|
li { margin-bottom: 6px; }
|
||||||
|
.warn { background: #fef3c7; border: 1px solid #fbbf24; border-radius: 8px; padding: 16px; font-weight: 500; }
|
||||||
|
code { background: #f1f5f9; padding: 2px 6px; border-radius: 4px; font-size: 0.9em; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Migration v2 Complete</h1>
|
||||||
|
<ul>
|
||||||
|
<?php foreach ($log as $line): ?>
|
||||||
|
<li><?= htmlspecialchars($line) ?></li>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</ul>
|
||||||
|
<div class="warn">
|
||||||
|
⚠ Delete <code>migrate_v2.php</code> from your server now.
|
||||||
|
It is no longer needed and should not be left publicly accessible.
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+277
@@ -0,0 +1,277 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* migrate_v3.php — Run once then DELETE this file.
|
||||||
|
* Creates multi-user tables, adds columns, seeds data.
|
||||||
|
*/
|
||||||
|
require_once __DIR__ . '/config/db.php';
|
||||||
|
|
||||||
|
$pdo = get_pdo();
|
||||||
|
$log = [];
|
||||||
|
$errors = [];
|
||||||
|
|
||||||
|
function run_sql(PDO $pdo, string $label, string $sql, array &$log, array &$errors): void {
|
||||||
|
try {
|
||||||
|
$pdo->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);
|
||||||
|
?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>Migration v3 — <?= APP_NAME ?></title>
|
||||||
|
<style>
|
||||||
|
*{box-sizing:border-box}
|
||||||
|
body{font-family:system-ui,-apple-system,sans-serif;background:#f4f4f5;margin:0;padding:32px 16px}
|
||||||
|
.wrap{max-width:720px;margin:0 auto}
|
||||||
|
h1{font-size:26px;margin-bottom:4px}
|
||||||
|
.banner{border-radius:8px;padding:20px 24px;margin-bottom:24px;font-size:15px}
|
||||||
|
.banner.ok{background:#d1fae5;border:1px solid #6ee7b7;color:#065f46}
|
||||||
|
.banner.err{background:#fee2e2;border:1px solid #fca5a5;color:#991b1b}
|
||||||
|
.warn{background:#fef3c7;border:1px solid #fcd34d;color:#92400e;border-radius:8px;padding:16px 20px;margin-bottom:24px;font-weight:600}
|
||||||
|
.card{background:#fff;border-radius:8px;padding:24px;margin-bottom:20px;box-shadow:0 1px 3px rgba(0,0,0,.07)}
|
||||||
|
.cred{background:#1e3a5f;color:#e0f2fe;border-radius:6px;padding:16px 20px;font-family:monospace;font-size:15px;line-height:1.7}
|
||||||
|
table{width:100%;border-collapse:collapse;font-size:13px}
|
||||||
|
th,td{text-align:left;padding:6px 10px;border-bottom:1px solid #e5e7eb}
|
||||||
|
th{background:#f9fafb;font-weight:600}
|
||||||
|
.ok{color:#15803d}.err{color:#b91c1c}.skip{color:#d97706}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
<h1>✝ <?= APP_NAME ?> — Migration v3</h1>
|
||||||
|
|
||||||
|
<?php if ($overall_ok): ?>
|
||||||
|
<div class="banner ok">
|
||||||
|
<strong>Migration completed successfully.</strong> All steps passed (or were already applied).
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="banner err">
|
||||||
|
<strong>Migration finished with errors.</strong> Review the log below. Some steps may need manual attention.
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<div class="warn">
|
||||||
|
⚠ DELETE this file (<code>migrate_v3.php</code>) from your server immediately after reviewing this page.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2 style="margin-top:0">Superadmin Credentials</h2>
|
||||||
|
<div class="cred">
|
||||||
|
Username: supadmin<br>
|
||||||
|
Password: supadmin<br>
|
||||||
|
Role: superadmin
|
||||||
|
</div>
|
||||||
|
<p style="color:#b91c1c;font-weight:600;margin-top:12px">
|
||||||
|
CHANGE THE PASSWORD IMMEDIATELY — go to <a href="/admin/profile">/admin/profile</a> after logging in.<br>
|
||||||
|
Also update the email from <code>admin@example.com</code> to your real email.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2 style="margin-top:0">Migration Log</h2>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Status</th><th>Step</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($log as [$status, $msg]): ?>
|
||||||
|
<tr>
|
||||||
|
<td class="<?= $status ?>">
|
||||||
|
<?= $status === 'ok' ? '✓ OK' : ($status === 'skip' ? '— SKIP' : '✗ ERROR') ?>
|
||||||
|
</td>
|
||||||
|
<td><?= htmlspecialchars($msg) ?></td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2 style="margin-top:0">Next Steps</h2>
|
||||||
|
<ol style="line-height:1.9">
|
||||||
|
<li>Delete <code>migrate_v3.php</code> from your server.</li>
|
||||||
|
<li>Go to <a href="/login">/login</a> and sign in with <strong>supadmin / supadmin</strong>.</li>
|
||||||
|
<li>Go to <a href="/admin/profile">/admin/profile</a> and change your password and email.</li>
|
||||||
|
<li>Go to <a href="/admin/settings">/admin/settings</a> to configure SMTP and your site URL.</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?php
|
||||||
|
// Redirect to admin version
|
||||||
|
require_once __DIR__ . '/config/db.php';
|
||||||
|
$id = isset($_GET['id']) ? '?id=' . (int)$_GET['id'] : '';
|
||||||
|
header('Location: ' . BASE_URL . '/admin/novena_group.php' . $id);
|
||||||
|
exit;
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* novena_public.php — Public day-picker for a 9-day novena group.
|
||||||
|
* Linked from index.php when a visitor clicks a novena card.
|
||||||
|
*/
|
||||||
|
require_once __DIR__ . '/config/db.php';
|
||||||
|
require_once __DIR__ . '/includes/auth.php';
|
||||||
|
require_once __DIR__ . '/includes/donate.php';
|
||||||
|
|
||||||
|
_auth_start();
|
||||||
|
$pdo = get_pdo();
|
||||||
|
$site_name = get_setting('site_name', APP_NAME);
|
||||||
|
$logged_in = !empty($_SESSION['user_id']);
|
||||||
|
|
||||||
|
// Resolve group: ?group_id=X OR ?username=X&slug=Y
|
||||||
|
$group = null;
|
||||||
|
if (isset($_GET['group_id'])) {
|
||||||
|
$stmt = $pdo->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'], '/')) : '';
|
||||||
|
?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<link rel="icon" type="image/svg+xml" href="<?= BASE_URL ?>/favicon.svg">
|
||||||
|
<title><?= htmlspecialchars($group['name']) ?> — <?= htmlspecialchars($site_name) ?></title>
|
||||||
|
<link rel="stylesheet" href="<?= BASE_URL ?>/assets/css/public.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<nav class="pub-nav">
|
||||||
|
<a href="<?= BASE_URL ?>/" class="pub-nav-brand">✝ <span><?= htmlspecialchars($site_name) ?></span></a>
|
||||||
|
<div class="pub-nav-links">
|
||||||
|
<?php if ($logged_in): ?>
|
||||||
|
<a href="<?= BASE_URL ?>/admin/">Dashboard</a>
|
||||||
|
<a href="<?= BASE_URL ?>/logout">Logout</a>
|
||||||
|
<?php else: ?>
|
||||||
|
<a href="<?= BASE_URL ?>/login">Sign In</a>
|
||||||
|
<a href="<?= BASE_URL ?>/register" class="btn-nav">Get Started</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="novena-hero">
|
||||||
|
<?php if ($photo_src): ?>
|
||||||
|
<div class="novena-hero-photo-wrap">
|
||||||
|
<img class="novena-hero-photo" src="<?= htmlspecialchars($photo_src) ?>" alt="">
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="novena-hero-cross">✝</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<div class="novena-hero-text">
|
||||||
|
<p class="novena-hero-label"><?= $group['mystery_set'] === 'chaplet' ? 'Divine Mercy Novena' : '9-Day Novena' ?></p>
|
||||||
|
<h1 class="novena-hero-title"><?= htmlspecialchars($group['name']) ?></h1>
|
||||||
|
<?php if ($group['subject_name']): ?>
|
||||||
|
<p class="novena-hero-subject">For <?= htmlspecialchars($group['subject_name']) ?></p>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if ($group['subject_dates']): ?>
|
||||||
|
<p class="novena-hero-dates"><?= htmlspecialchars($group['subject_dates']) ?></p>
|
||||||
|
<?php endif; ?>
|
||||||
|
<p class="novena-hero-by">By <?= htmlspecialchars($disp_name) ?></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pub-section">
|
||||||
|
<h2>Select a Day</h2>
|
||||||
|
<div class="novena-days-grid">
|
||||||
|
<?php for ($d = 1; $d <= 9; $d++):
|
||||||
|
$ses = $days_by_num[$d] ?? null;
|
||||||
|
$mysteries = $ses ? ($mystery_labels[$ses['mystery_set']] ?? $ses['mystery_set']) : null;
|
||||||
|
$url = $ses ? (BASE_URL . '/present.php?id=' . (int)$ses['id']) : null;
|
||||||
|
?>
|
||||||
|
<div class="novena-day-card <?= $ses ? '' : 'novena-day-missing' ?>">
|
||||||
|
<div class="novena-day-number">Day <?= $d ?></div>
|
||||||
|
<?php if ($ses): ?>
|
||||||
|
<div class="novena-day-mysteries"><?= htmlspecialchars($mysteries) ?></div>
|
||||||
|
<a href="<?= htmlspecialchars($url) ?>" class="novena-day-link">Pray →</a>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="novena-day-mysteries">Not yet added</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<?php endfor; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php render_donate_strip(); ?>
|
||||||
|
|
||||||
|
<footer class="pub-footer">
|
||||||
|
<a href="<?= BASE_URL ?>/" style="color:inherit">← All Rosaries</a>
|
||||||
|
•
|
||||||
|
© <?= date('Y') ?> <?= htmlspecialchars($site_name) ?>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+231
@@ -0,0 +1,231 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* present.php — Public presentation view.
|
||||||
|
* No authentication required — URL is shared with participants.
|
||||||
|
* Supports pretty-URL access: /username/slug → ?username=X&slug=Y via .htaccess
|
||||||
|
*/
|
||||||
|
require_once __DIR__ . '/config/db.php';
|
||||||
|
require_once __DIR__ . '/includes/build_slides.php';
|
||||||
|
|
||||||
|
// Support pretty-URL access: /username/slug
|
||||||
|
if (!isset($_GET['id']) && isset($_GET['username'], $_GET['slug'])) {
|
||||||
|
$pdo = get_pdo();
|
||||||
|
|
||||||
|
$user_stmt = $pdo->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);
|
||||||
|
?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<link rel="icon" type="image/svg+xml" href="<?= BASE_URL ?>/favicon.svg">
|
||||||
|
<title><?= htmlspecialchars($session['name']) ?> — <?= htmlspecialchars($site_name) ?></title>
|
||||||
|
<link rel="stylesheet" href="<?= BASE_URL ?>/assets/css/present.css?v=8">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- Rosary bead overlay (SVG inserted by rosary.js) -->
|
||||||
|
<div id="rosary-overlay"></div>
|
||||||
|
|
||||||
|
<!-- Presentation area -->
|
||||||
|
<div id="presenter">
|
||||||
|
|
||||||
|
<!-- Slide content -->
|
||||||
|
<div id="slide-content">
|
||||||
|
<!-- Cover -->
|
||||||
|
<div id="cover-slide" class="cover-slide" style="display:none">
|
||||||
|
<div id="cover-photo-wrap">
|
||||||
|
<img id="cover-photo" src="" alt="">
|
||||||
|
</div>
|
||||||
|
<h1 id="cover-title"></h1>
|
||||||
|
<div id="cover-body"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Prayer slide -->
|
||||||
|
<div id="prayer-slide" style="display:none">
|
||||||
|
<p class="repeat-badge" style="display:none"></p>
|
||||||
|
<h2 id="slide-title"></h2>
|
||||||
|
<div id="slide-text">
|
||||||
|
<div id="leader-wrap">
|
||||||
|
<span class="label leader-label">Leader:</span>
|
||||||
|
<p id="leader-text"></p>
|
||||||
|
</div>
|
||||||
|
<div id="all-wrap">
|
||||||
|
<span class="label all-label">All:</span>
|
||||||
|
<p id="all-text"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mystery slide -->
|
||||||
|
<div id="mystery-slide" style="display:none">
|
||||||
|
<div class="mystery-number" id="mystery-number"></div>
|
||||||
|
<h2 id="mystery-title"></h2>
|
||||||
|
<p id="mystery-fruit"></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Litany slide -->
|
||||||
|
<div id="litany-slide" style="display:none">
|
||||||
|
<p class="repeat-badge" style="display:none"></p>
|
||||||
|
<h2 id="litany-title"></h2>
|
||||||
|
<div id="litany-text">
|
||||||
|
<div id="litany-leader-wrap">
|
||||||
|
<span class="label leader-label">Leader:</span>
|
||||||
|
<p id="litany-leader"></p>
|
||||||
|
</div>
|
||||||
|
<div id="litany-all-wrap">
|
||||||
|
<span class="label all-label">All:</span>
|
||||||
|
<p id="litany-all"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Closing slide -->
|
||||||
|
<div id="closing-slide" style="display:none">
|
||||||
|
<div id="closing-photo-wrap" style="display:none">
|
||||||
|
<img id="closing-photo" src="" alt="">
|
||||||
|
</div>
|
||||||
|
<div class="closing-cross">✝</div>
|
||||||
|
<h2 id="closing-title"></h2>
|
||||||
|
<p id="closing-subtitle"></p>
|
||||||
|
<p id="closing-body"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Navigation -->
|
||||||
|
<nav id="presenter-nav">
|
||||||
|
<button id="btn-exit" class="nav-btn nav-exit" aria-label="Exit presentation" title="Exit — your place is saved">⌂</button>
|
||||||
|
<?php if ($has_audio): ?>
|
||||||
|
<button id="btn-audio" class="nav-btn nav-audio" aria-label="Toggle audio" title="Toggle audio">🔈</button>
|
||||||
|
<?php endif; ?>
|
||||||
|
<div class="nav-sep"></div>
|
||||||
|
<button id="btn-prev" class="nav-btn" aria-label="Previous slide">◀</button>
|
||||||
|
<span id="slide-counter"></span>
|
||||||
|
<button id="btn-next" class="nav-btn" aria-label="Next slide">▶</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Keyboard hint -->
|
||||||
|
<div id="key-hint">← → arrows to navigate | F = fullscreen</div>
|
||||||
|
|
||||||
|
<!-- Resume toast (auto-fades in when a saved position is restored) -->
|
||||||
|
<div id="resume-toast"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Session data for JS -->
|
||||||
|
<script>
|
||||||
|
var SLIDES = <?= $slides_json ?>;
|
||||||
|
var SESSION_NAME = <?= json_encode($session['name']) ?>;
|
||||||
|
var MYSTERY_LABEL = <?= json_encode($mystery_label) ?>;
|
||||||
|
var UPLOADS_BASE = <?= json_encode(UPLOADS_URL) ?>;
|
||||||
|
var SESSION_ID = <?= json_encode((int)$session['id']) ?>;
|
||||||
|
var BACK_URL = <?= json_encode($back_url) ?>;
|
||||||
|
var AUDIO_MANIFEST = <?= json_encode($audio_manifest, JSON_HEX_TAG | JSON_HEX_AMP) ?>;
|
||||||
|
var AUDIO_BASE_URL = <?= json_encode(UPLOADS_URL . 'audio/') ?>;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script src="<?= BASE_URL ?>/assets/js/rosary.js?v=9"></script>
|
||||||
|
<script src="<?= BASE_URL ?>/assets/js/presenter.js?v=11"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+160
@@ -0,0 +1,160 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* profile.php — Public user profile page.
|
||||||
|
* Route: /username → ?username=X via .htaccess
|
||||||
|
*/
|
||||||
|
require_once __DIR__ . '/config/db.php';
|
||||||
|
require_once __DIR__ . '/includes/auth.php';
|
||||||
|
|
||||||
|
_auth_start();
|
||||||
|
$pdo = get_pdo();
|
||||||
|
$site_name = get_setting('site_name', APP_NAME);
|
||||||
|
$logged_in = !empty($_SESSION['user_id']);
|
||||||
|
|
||||||
|
$req_username = trim($_GET['username'] ?? '');
|
||||||
|
|
||||||
|
if ($req_username === '') {
|
||||||
|
header('Location: ' . BASE_URL . '/');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load user
|
||||||
|
$user_stmt = $pdo->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 '<!DOCTYPE html><html><body style="font-family:system-ui;max-width:500px;margin:60px auto;text-align:center"><h1>404 — User not found</h1><a href="' . BASE_URL . '/">Home</a></body></html>';
|
||||||
|
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',
|
||||||
|
];
|
||||||
|
?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<link rel="icon" type="image/svg+xml" href="<?= BASE_URL ?>/favicon.svg">
|
||||||
|
<title><?= htmlspecialchars($disp_name) ?> — <?= htmlspecialchars($site_name) ?></title>
|
||||||
|
<link rel="stylesheet" href="<?= BASE_URL ?>/assets/css/public.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<nav class="pub-nav">
|
||||||
|
<a href="<?= BASE_URL ?>/" class="pub-nav-brand">✝ <span><?= htmlspecialchars($site_name) ?></span></a>
|
||||||
|
<div class="pub-nav-links">
|
||||||
|
<?php if ($logged_in): ?>
|
||||||
|
<a href="<?= BASE_URL ?>/admin/">Dashboard</a>
|
||||||
|
<a href="<?= BASE_URL ?>/logout">Logout</a>
|
||||||
|
<?php else: ?>
|
||||||
|
<a href="<?= BASE_URL ?>/login">Sign In</a>
|
||||||
|
<a href="<?= BASE_URL ?>/register" class="btn-nav">Get Started</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="profile-header">
|
||||||
|
<div class="profile-header-inner">
|
||||||
|
<div class="profile-avatar"><?= htmlspecialchars($initials) ?></div>
|
||||||
|
<div class="profile-info">
|
||||||
|
<h1><?= htmlspecialchars($disp_name) ?></h1>
|
||||||
|
<p>@<?= htmlspecialchars($profile_user['username']) ?> • <?= count($all) ?> public rosary<?= count($all) !== 1 ? 's' : '' ?></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pub-section">
|
||||||
|
<h2>Public Rosaries</h2>
|
||||||
|
|
||||||
|
<?php if (empty($all)): ?>
|
||||||
|
<div class="pub-empty">
|
||||||
|
<span class="cross">✝</span>
|
||||||
|
<p><?= htmlspecialchars($disp_name) ?> has no public rosary sessions yet.</p>
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="card-grid">
|
||||||
|
<?php foreach ($all as $row):
|
||||||
|
$slug = $row['slug'] ?? '';
|
||||||
|
$url = $slug ? (BASE_URL . '/' . rawurlencode($profile_user['username']) . '/' . rawurlencode($slug)) : '#';
|
||||||
|
?>
|
||||||
|
<a href="<?= htmlspecialchars($url) ?>" class="rosary-card">
|
||||||
|
<?php if (!empty($row['photo_path'])): ?>
|
||||||
|
<img class="rosary-card-photo"
|
||||||
|
src="<?= htmlspecialchars('/' . ltrim($row['photo_path'], '/')) ?>"
|
||||||
|
alt="">
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="rosary-card-photo-placeholder">✝</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<div class="rosary-card-body">
|
||||||
|
<div class="rosary-card-title"><?= htmlspecialchars($row['name']) ?></div>
|
||||||
|
<div class="rosary-card-meta">
|
||||||
|
<?php if ($row['_type'] === 'novena'): ?>
|
||||||
|
<span class="badge-novena">9-Day Novena</span>
|
||||||
|
<?php if ($row['subject_name']): ?>
|
||||||
|
<?= htmlspecialchars($row['subject_name']) ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php else: ?>
|
||||||
|
<?= htmlspecialchars($occasion_labels[$row['occasion']] ?? $row['occasion']) ?> •
|
||||||
|
<?= htmlspecialchars($mystery_labels[$row['mystery_set']] ?? $row['mystery_set']) ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="pub-footer">
|
||||||
|
© <?= date('Y') ?> <?= htmlspecialchars($site_name) ?>
|
||||||
|
• <a href="<?= BASE_URL ?>/" style="color:inherit">Home</a>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+1132
File diff suppressed because it is too large
Load Diff
+177
@@ -0,0 +1,177 @@
|
|||||||
|
<?php
|
||||||
|
require_once __DIR__ . '/config/db.php';
|
||||||
|
require_once __DIR__ . '/includes/auth.php';
|
||||||
|
require_once __DIR__ . '/includes/mailer.php';
|
||||||
|
|
||||||
|
_auth_start();
|
||||||
|
|
||||||
|
// Already logged in
|
||||||
|
if (!empty($_SESSION['user_id'])) {
|
||||||
|
header('Location: ' . BASE_URL . '/admin/');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$errors = [];
|
||||||
|
$success = false;
|
||||||
|
$fields = ['username' => '', '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 = "
|
||||||
|
<h2 style='margin-top:0;color:#1e3a5f'>Confirm your email</h2>
|
||||||
|
<p>Hello, <strong>" . htmlspecialchars($display_name ?: $username) . "</strong>!</p>
|
||||||
|
<p>Thank you for registering with {$site_name}. Click the button below to confirm your email address:</p>
|
||||||
|
<p style='text-align:center;margin:28px 0'>
|
||||||
|
<a href='" . htmlspecialchars($link) . "' style='display:inline-block;background:#1e3a5f;color:#fff;padding:12px 28px;border-radius:6px;text-decoration:none;font-weight:600'>Confirm Email</a>
|
||||||
|
</p>
|
||||||
|
<p style='color:#6b7280;font-size:13px'>Or copy this link: " . htmlspecialchars($link) . "</p>
|
||||||
|
<p style='color:#6b7280;font-size:13px'>If you did not register, ignore this email.</p>
|
||||||
|
";
|
||||||
|
$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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<link rel="icon" type="image/svg+xml" href="<?= BASE_URL ?>/favicon.svg">
|
||||||
|
<title>Register — <?= htmlspecialchars(get_setting('site_name', APP_NAME)) ?></title>
|
||||||
|
<link rel="stylesheet" href="<?= BASE_URL ?>/assets/css/setup.css">
|
||||||
|
</head>
|
||||||
|
<body class="login-page">
|
||||||
|
<div class="login-box" style="max-width:460px">
|
||||||
|
<h1>✝ <?= htmlspecialchars(get_setting('site_name', APP_NAME)) ?></h1>
|
||||||
|
<h2>Create Account</h2>
|
||||||
|
|
||||||
|
<?php if ($success): ?>
|
||||||
|
<?php if ($auto_confirmed ?? false): ?>
|
||||||
|
<div class="alert alert-success">
|
||||||
|
Account created! <a href="<?= BASE_URL ?>/login">Sign in now</a>.
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="alert alert-success">
|
||||||
|
Account created! Please check your email to confirm your address before logging in.
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php else: ?>
|
||||||
|
|
||||||
|
<?php if (!empty($errors)): ?>
|
||||||
|
<div class="alert alert-error">
|
||||||
|
<?php foreach ($errors as $err): ?>
|
||||||
|
<div><?= htmlspecialchars($err) ?></div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<form method="post" action="<?= BASE_URL ?>/register">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="username">Username <span class="required">*</span></label>
|
||||||
|
<input type="text" id="username" name="username"
|
||||||
|
value="<?= htmlspecialchars($fields['username']) ?>"
|
||||||
|
pattern="[a-zA-Z0-9_]{3,30}" title="3-30 letters, numbers, or underscores"
|
||||||
|
autocomplete="username" autofocus required>
|
||||||
|
<p class="help-text">3-30 characters. Letters, numbers, underscores only.</p>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="display_name">Display Name</label>
|
||||||
|
<input type="text" id="display_name" name="display_name"
|
||||||
|
value="<?= htmlspecialchars($fields['display_name']) ?>"
|
||||||
|
maxlength="100" autocomplete="name">
|
||||||
|
<p class="help-text">Optional. Shown publicly.</p>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="email">Email <span class="required">*</span></label>
|
||||||
|
<input type="email" id="email" name="email"
|
||||||
|
value="<?= htmlspecialchars($fields['email']) ?>"
|
||||||
|
autocomplete="email" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password">Password <span class="required">*</span></label>
|
||||||
|
<input type="password" id="password" name="password"
|
||||||
|
minlength="8" autocomplete="new-password" required>
|
||||||
|
<p class="help-text">At least 8 characters.</p>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password_confirm">Confirm Password <span class="required">*</span></label>
|
||||||
|
<input type="password" id="password_confirm" name="password_confirm"
|
||||||
|
minlength="8" autocomplete="new-password" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary btn-full">Create Account</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div style="margin-top:20px;text-align:center;font-size:14px;color:#6b7280">
|
||||||
|
Already have an account? <a href="<?= BASE_URL ?>/login" style="color:#1e3a5f">Sign in</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
<?php
|
||||||
|
require_once __DIR__ . '/config/db.php';
|
||||||
|
|
||||||
|
$site_name = get_setting('site_name', APP_NAME);
|
||||||
|
$token = trim($_GET['token'] ?? '');
|
||||||
|
$errors = [];
|
||||||
|
$success = false;
|
||||||
|
|
||||||
|
if ($token === '') {
|
||||||
|
header('Location: ' . BASE_URL . '/forgot-password');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo = get_pdo();
|
||||||
|
$stmt = $pdo->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<link rel="icon" type="image/svg+xml" href="<?= BASE_URL ?>/favicon.svg">
|
||||||
|
<title>Reset Password — <?= htmlspecialchars($site_name) ?></title>
|
||||||
|
<link rel="stylesheet" href="<?= BASE_URL ?>/assets/css/setup.css">
|
||||||
|
</head>
|
||||||
|
<body class="login-page">
|
||||||
|
<div class="login-box">
|
||||||
|
<h1>✝ <?= htmlspecialchars($site_name) ?></h1>
|
||||||
|
<h2>Reset Password</h2>
|
||||||
|
|
||||||
|
<?php if (isset($token_invalid)): ?>
|
||||||
|
<div class="alert alert-error">
|
||||||
|
This password reset link is invalid or has expired.
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:16px;text-align:center">
|
||||||
|
<a href="<?= BASE_URL ?>/forgot-password" class="btn btn-primary">Request a new link</a>
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
|
||||||
|
<?php if (!empty($errors)): ?>
|
||||||
|
<div class="alert alert-error">
|
||||||
|
<?php foreach ($errors as $err): ?>
|
||||||
|
<div><?= htmlspecialchars($err) ?></div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<form method="post" action="<?= BASE_URL ?>/reset-password?token=<?= urlencode($token) ?>">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password">New Password <span class="required">*</span></label>
|
||||||
|
<input type="password" id="password" name="password"
|
||||||
|
minlength="8" autocomplete="new-password" autofocus required>
|
||||||
|
<p class="help-text">At least 8 characters.</p>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password_confirm">Confirm New Password <span class="required">*</span></label>
|
||||||
|
<input type="password" id="password_confirm" name="password_confirm"
|
||||||
|
minlength="8" autocomplete="new-password" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary btn-full">Set New Password</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?php
|
||||||
|
// Redirect to admin version
|
||||||
|
require_once __DIR__ . '/config/db.php';
|
||||||
|
$id = isset($_GET['id']) ? '?id=' . (int)$_GET['id'] : '';
|
||||||
|
header('Location: ' . BASE_URL . '/admin/setup.php' . $id);
|
||||||
|
exit;
|
||||||
Reference in New Issue
Block a user