Initial commit — Rosary Presenter App

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

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

+85
View File
@@ -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>&#x271D; <?= 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>
+68
View File
@@ -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/">&#8592; 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;
}
+261
View File
@@ -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 15
* @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 24: small (3 Hail Marys / Creed for chaplet, stem)
// Bead 5: large (Our Father, Decade 1)
// Beads 615: small (Decade 1, 10 HMs / For the sake…)
// Bead 16: large (Our Father, Decade 2)
// Beads 1726: small (Decade 2)
// Bead 27: large (Our Father, Decade 3)
// Beads 2837: small (Decade 3)
// Bead 38: large (Our Father, Decade 4)
// Beads 3948: small (Decade 4)
// Bead 49: large (Our Father, Decade 5)
// Beads 5059: 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;
}
+48
View File
@@ -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 = '&#x1F49B;'; // 💛
$label = $label ?: 'Support via PayPal';
break;
case 'venmo':
$url = 'https://venmo.com/u/' . rawurlencode(ltrim($handle, '@'));
$icon = '&#x1F4B8;'; // 💸
$label = $label ?: '@' . ltrim($handle, '@') . ' on Venmo';
break;
case 'buymeacoffee':
$url = 'https://buymeacoffee.com/' . rawurlencode(ltrim($handle, '@'));
$icon = '&#x2615;'; // ☕
$label = $label ?: 'Buy Me a Coffee';
break;
default: // custom
$url = $handle; // full URL stored in handle field for custom
$icon = '&#x2764;'; // ❤
$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
}
+207
View File
@@ -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">&#x271D; {$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">
&copy; <?= 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;
}
+430
View File
@@ -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 &rarr;' : 'Pray &rarr;';
// 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">&#x271D;</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']): ?>
&nbsp;<?= htmlspecialchars($row['subject_name']) ?>
<?php endif; ?>
<?php endif; ?>
<?php else: ?>
<?= htmlspecialchars($occasion_labels[$row['occasion']] ?? $row['occasion']) ?> &bull;
<?= 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">&#x271D; <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>&#x271D; 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">&#128269;</span>
<input type="search" id="home-search"
class="home-search-input"
placeholder="Search by rosary name, honoree, or username&hellip;"
autocomplete="off"
aria-label="Search rosaries">
<button class="home-search-clear" id="search-clear" aria-label="Clear search">&#10005;</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>&#128204; 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">&#x271D;</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">
&copy; <?= date('Y') ?> <?= htmlspecialchars($site_name) ?>
<?php if (!$logged_in): ?>
&bull; <a href="<?= BASE_URL ?>/register" style="color:inherit">Create Account</a>
&bull; <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 = '&#128100; ' + escHtml(userMap[uname]) + '\'s rosaries &rarr;';
userResultsBox.appendChild(a);
});
}
function escHtml(str) {
return str.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// ------------------------------------------------------------------
// 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
View File
@@ -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>&#x271D; <?= 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">
&#9888; 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:&nbsp;&nbsp;&nbsp;&nbsp;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' ? '&#x2713; OK' : ($status === 'skip' ? '&#8212; SKIP' : '&#x2717; 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>
+96
View File
@@ -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>&#x271D; <?= 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>
&nbsp;&bull;&nbsp;
<a href="<?= BASE_URL ?>/register" style="color:#1e3a5f">Create an account</a>
</div>
</div>
</body>
</html>
+9
View File
@@ -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
View File
@@ -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">
&#x26A0; 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
View File
@@ -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>&#x271D; <?= 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">
&#9888; 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' ? '&#x2713; OK' : ($status === 'skip' ? '&#8212; SKIP' : '&#x2717; 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>
+6
View File
@@ -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;
+145
View File
@@ -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">&#x271D; <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">&#x271D;</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 &#x2192;</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">&larr; All Rosaries</a>
&nbsp;&bull;&nbsp;
&copy; <?= date('Y') ?> <?= htmlspecialchars($site_name) ?>
</footer>
</body>
</html>
+231
View File
@@ -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">&#x271D;</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">&#8962;</button>
<?php if ($has_audio): ?>
<button id="btn-audio" class="nav-btn nav-audio" aria-label="Toggle audio" title="Toggle audio">&#128264;</button>
<?php endif; ?>
<div class="nav-sep"></div>
<button id="btn-prev" class="nav-btn" aria-label="Previous slide">&#9664;</button>
<span id="slide-counter"></span>
<button id="btn-next" class="nav-btn" aria-label="Next slide">&#9654;</button>
</nav>
<!-- Keyboard hint -->
<div id="key-hint">&#8592; &#8594; arrows to navigate &nbsp;|&nbsp; 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
View File
@@ -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">&#x271D; <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']) ?> &bull; <?= 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">&#x271D;</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">&#x271D;</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']): ?>
&nbsp;<?= htmlspecialchars($row['subject_name']) ?>
<?php endif; ?>
<?php else: ?>
<?= htmlspecialchars($occasion_labels[$row['occasion']] ?? $row['occasion']) ?> &bull;
<?= htmlspecialchars($mystery_labels[$row['mystery_set']] ?? $row['mystery_set']) ?>
<?php endif; ?>
</div>
</div>
</a>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
<footer class="pub-footer">
&copy; <?= date('Y') ?> <?= htmlspecialchars($site_name) ?>
&bull; <a href="<?= BASE_URL ?>/" style="color:inherit">Home</a>
</footer>
</body>
</html>
+1132
View File
File diff suppressed because it is too large Load Diff
+177
View File
@@ -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>&#x271D; <?= 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>
+93
View File
@@ -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>&#x271D; <?= 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>
+6
View File
@@ -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;
View File