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
+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>