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:
+386
@@ -0,0 +1,386 @@
|
||||
<?php
|
||||
/**
|
||||
* admin/audio.php — Manage pre-recorded prayer audio files.
|
||||
* Admin only. Lists all known prayer keys grouped by category.
|
||||
* Each row shows upload status and allows upload / delete.
|
||||
*/
|
||||
require_once __DIR__ . '/../config/db.php';
|
||||
require_once __DIR__ . '/../includes/auth.php';
|
||||
|
||||
require_auth();
|
||||
if (!has_role('admin')) {
|
||||
header('Location: ' . BASE_URL . '/admin/');
|
||||
exit;
|
||||
}
|
||||
|
||||
$user = current_user();
|
||||
$site_name = get_setting('site_name', APP_NAME);
|
||||
|
||||
// Scan uploads/audio/ to build a map of key → extension
|
||||
$audio_dir = UPLOADS_DIR . 'audio/';
|
||||
$uploaded_files = [];
|
||||
if (is_dir($audio_dir)) {
|
||||
foreach (glob($audio_dir . '*.*') ?: [] as $f) {
|
||||
$base = basename($f);
|
||||
$dot = strrpos($base, '.');
|
||||
if ($dot !== false) {
|
||||
$k = substr($base, 0, $dot);
|
||||
$e = strtolower(substr($base, $dot + 1));
|
||||
if (preg_match('/^[a-z0-9_]+$/', $k)) {
|
||||
$uploaded_files[$k] = $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// All known prayer audio keys grouped by category
|
||||
$AUDIO_KEYS = [
|
||||
'Common Prayers' => [
|
||||
'sign_of_cross' => 'Sign of the Cross',
|
||||
'apostles_creed' => 'Apostles\' Creed',
|
||||
'our_father' => 'Our Father (all)',
|
||||
'hail_mary' => 'Hail Mary (all)',
|
||||
'glory_be' => 'Glory Be (all)',
|
||||
'fatima_prayer' => 'Fatima Prayer',
|
||||
'hail_holy_queen' => 'Hail Holy Queen',
|
||||
'rosary_closing_prayer' => 'Rosary Closing Prayer',
|
||||
'closing' => 'Closing Slide',
|
||||
],
|
||||
'Sorrowful Mysteries' => [
|
||||
'mystery_sorrowful_1' => '1st Mystery — Agony in the Garden',
|
||||
'mystery_sorrowful_2' => '2nd Mystery — Scourging at the Pillar',
|
||||
'mystery_sorrowful_3' => '3rd Mystery — Crowning with Thorns',
|
||||
'mystery_sorrowful_4' => '4th Mystery — Carrying of the Cross',
|
||||
'mystery_sorrowful_5' => '5th Mystery — Crucifixion and Death',
|
||||
],
|
||||
'Joyful Mysteries' => [
|
||||
'mystery_joyful_1' => '1st Mystery — The Annunciation',
|
||||
'mystery_joyful_2' => '2nd Mystery — The Visitation',
|
||||
'mystery_joyful_3' => '3rd Mystery — The Nativity',
|
||||
'mystery_joyful_4' => '4th Mystery — The Presentation',
|
||||
'mystery_joyful_5' => '5th Mystery — Finding in the Temple',
|
||||
],
|
||||
'Glorious Mysteries' => [
|
||||
'mystery_glorious_1' => '1st Mystery — The Resurrection',
|
||||
'mystery_glorious_2' => '2nd Mystery — The Ascension',
|
||||
'mystery_glorious_3' => '3rd Mystery — Descent of the Holy Spirit',
|
||||
'mystery_glorious_4' => '4th Mystery — The Assumption',
|
||||
'mystery_glorious_5' => '5th Mystery — Coronation of Mary',
|
||||
],
|
||||
'Luminous Mysteries' => [
|
||||
'mystery_luminous_1' => '1st Mystery — Baptism of Jesus',
|
||||
'mystery_luminous_2' => '2nd Mystery — Wedding at Cana',
|
||||
'mystery_luminous_3' => '3rd Mystery — Proclamation of the Kingdom',
|
||||
'mystery_luminous_4' => '4th Mystery — The Transfiguration',
|
||||
'mystery_luminous_5' => '5th Mystery — Institution of the Eucharist',
|
||||
],
|
||||
'Novena for Deceased' => [
|
||||
'novena_day_1' => 'Day 1 — Novena Prayer',
|
||||
'novena_day_2' => 'Day 2 — Novena Prayer',
|
||||
'novena_day_3' => 'Day 3 — Novena Prayer',
|
||||
'novena_day_4' => 'Day 4 — Novena Prayer',
|
||||
'novena_day_5' => 'Day 5 — Novena Prayer',
|
||||
'novena_day_6' => 'Day 6 — Novena Prayer',
|
||||
'novena_day_7' => 'Day 7 — Novena Prayer',
|
||||
'novena_day_8' => 'Day 8 — Novena Prayer',
|
||||
'novena_day_9' => 'Day 9 — Novena Prayer',
|
||||
'litany_passion_intro' => 'Litany of Passion — Intro',
|
||||
'litany_passion_2' => 'Litany of Passion — Entry 2',
|
||||
'litany_passion_3' => 'Litany of Passion — Entry 3',
|
||||
'litany_passion_4' => 'Litany of Passion — Entry 4',
|
||||
'litany_passion_5' => 'Litany of Passion — Entry 5',
|
||||
'litany_passion_6' => 'Litany of Passion — Entry 6',
|
||||
'litany_passion_7' => 'Litany of Passion — Entry 7',
|
||||
'litany_passion_8' => 'Litany of Passion — Entry 8',
|
||||
'litany_passion_9' => 'Litany of Passion — Entry 9',
|
||||
'litany_passion_10' => 'Litany of Passion — Entry 10',
|
||||
'litany_passion_11' => 'Litany of Passion — Entry 11',
|
||||
'litany_departed_kyrie' => 'Litany for Departed — Kyrie',
|
||||
'litany_departed_christe' => 'Litany for Departed — Christe',
|
||||
'litany_departed_lord' => 'Litany for Departed — Lord',
|
||||
'litany_departed_mary' => 'Litany for Departed — Mary',
|
||||
'litany_departed_michael' => 'Litany for Departed — Michael',
|
||||
'litany_departed_angels' => 'Litany for Departed — Angels',
|
||||
'litany_departed_john' => 'Litany for Departed — John the Baptist',
|
||||
'litany_departed_joseph' => 'Litany for Departed — Joseph',
|
||||
'litany_departed_peter_paul' => 'Litany for Departed — Peter & Paul',
|
||||
'litany_departed_all_saints' => 'Litany for Departed — All Saints',
|
||||
'litany_departed_deliver_death' => 'Litany for Departed — Deliver from Death',
|
||||
'litany_departed_deliver_sin' => 'Litany for Departed — Deliver from Sin',
|
||||
'litany_departed_deliver_judgment' => 'Litany for Departed — Deliver from Judgment',
|
||||
'litany_departed_agnus_1' => 'Litany for Departed — Agnus Dei 1',
|
||||
'litany_departed_agnus_2' => 'Litany for Departed — Agnus Dei 2',
|
||||
'litany_departed_agnus_3' => 'Litany for Departed — Agnus Dei 3',
|
||||
'litany_departed_eternal_rest_1' => 'Eternal Rest (part 1)',
|
||||
'litany_departed_eternal_rest_2' => 'Eternal Rest (part 2)',
|
||||
'litany_departed_concluding' => 'Concluding Prayer',
|
||||
],
|
||||
'Divine Mercy Novena' => [
|
||||
'dm_opening' => 'Opening Prayer',
|
||||
'dm_blood_water' => 'O Blood and Water (×3)',
|
||||
'dm_eternal_father' => 'Eternal Father (chaplet)',
|
||||
'dm_for_sake' => 'For the Sake of His Sorrowful Passion (×10)',
|
||||
'dm_holy_god' => 'Holy God (×3)',
|
||||
'dm_intention_day_1' => 'Day 1 — Jesus\' Intention',
|
||||
'dm_prayer_day_1' => 'Day 1 — Day Prayer',
|
||||
'dm_intention_day_2' => 'Day 2 — Jesus\' Intention',
|
||||
'dm_prayer_day_2' => 'Day 2 — Day Prayer',
|
||||
'dm_intention_day_3' => 'Day 3 — Jesus\' Intention',
|
||||
'dm_prayer_day_3' => 'Day 3 — Day Prayer',
|
||||
'dm_intention_day_4' => 'Day 4 — Jesus\' Intention',
|
||||
'dm_prayer_day_4' => 'Day 4 — Day Prayer',
|
||||
'dm_intention_day_5' => 'Day 5 — Jesus\' Intention',
|
||||
'dm_prayer_day_5' => 'Day 5 — Day Prayer',
|
||||
'dm_intention_day_6' => 'Day 6 — Jesus\' Intention',
|
||||
'dm_prayer_day_6' => 'Day 6 — Day Prayer',
|
||||
'dm_intention_day_7' => 'Day 7 — Jesus\' Intention',
|
||||
'dm_prayer_day_7' => 'Day 7 — Day Prayer',
|
||||
'dm_intention_day_8' => 'Day 8 — Jesus\' Intention',
|
||||
'dm_prayer_day_8' => 'Day 8 — Day Prayer',
|
||||
'dm_intention_day_9' => 'Day 9 — Jesus\' Intention',
|
||||
'dm_prayer_day_9' => 'Day 9 — Day Prayer',
|
||||
],
|
||||
];
|
||||
|
||||
$total_keys = array_sum(array_map('count', $AUDIO_KEYS));
|
||||
$uploaded_count = 0;
|
||||
foreach ($AUDIO_KEYS as $keys) {
|
||||
foreach (array_keys($keys) as $k) {
|
||||
if (isset($uploaded_files[$k])) $uploaded_count++;
|
||||
}
|
||||
}
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="icon" type="image/svg+xml" href="<?= BASE_URL ?>/favicon.svg">
|
||||
<title>Audio — <?= htmlspecialchars($site_name) ?></title>
|
||||
<link rel="stylesheet" href="<?= BASE_URL ?>/assets/css/setup.css">
|
||||
<style>
|
||||
.audio-summary { display:flex; gap:24px; margin-bottom:28px; flex-wrap:wrap; }
|
||||
.audio-summary-stat { background:#fff; border:1px solid #e5e7eb; border-radius:8px; padding:16px 24px; }
|
||||
.audio-summary-stat .num { font-size:32px; font-weight:800; color:#1e3a5f; line-height:1; }
|
||||
.audio-summary-stat .lbl { font-size:13px; color:#6b7280; margin-top:4px; }
|
||||
.audio-section { margin-bottom:32px; }
|
||||
.audio-section h3 { font-size:16px; font-weight:700; color:#1e3a5f; margin:0 0 12px;
|
||||
border-bottom:2px solid #c9973d; padding-bottom:8px; display:inline-block; }
|
||||
.audio-table { width:100%; border-collapse:collapse; font-size:14px; }
|
||||
.audio-table th { text-align:left; padding:8px 12px; background:#f9fafb;
|
||||
font-weight:600; border-bottom:2px solid #e5e7eb; }
|
||||
.audio-table td { padding:8px 12px; border-bottom:1px solid #f3f4f6; vertical-align:middle; }
|
||||
.audio-table tr:last-child td { border-bottom:none; }
|
||||
.status-uploaded { color:#15803d; font-weight:600; font-size:13px; }
|
||||
.status-missing { color:#9ca3af; font-size:13px; }
|
||||
.file-ext { background:#e0f2fe; color:#0369a1; padding:2px 7px; border-radius:4px;
|
||||
font-size:11px; font-weight:700; text-transform:uppercase; margin-left:6px; }
|
||||
.upload-btn { font-size:13px; padding:5px 12px; }
|
||||
.delete-btn { font-size:13px; padding:5px 10px; margin-left:6px; }
|
||||
.key-code { font-family:monospace; font-size:12px; color:#6b7280; }
|
||||
.help-note { background:#f0f9ff; border:1px solid #bae6fd; border-radius:8px;
|
||||
padding:16px 20px; font-size:14px; color:#0c4a6e; margin-bottom:28px; }
|
||||
.help-note strong { display:block; margin-bottom:6px; }
|
||||
</style>
|
||||
<script>var BASE_URL = '<?= BASE_URL ?>';</script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="admin-container">
|
||||
|
||||
<header class="admin-header">
|
||||
<h1>✝ <?= htmlspecialchars($site_name) ?></h1>
|
||||
<div class="header-actions">
|
||||
<a href="<?= BASE_URL ?>/" class="btn btn-ghost" style="font-size:13px">← View Site</a>
|
||||
<?php if (has_role('admin')): ?>
|
||||
<a href="<?= BASE_URL ?>/admin/users.php" class="btn btn-ghost">Users</a>
|
||||
<?php endif; ?>
|
||||
<?php if (has_role('superadmin')): ?>
|
||||
<a href="<?= BASE_URL ?>/admin/settings.php" class="btn btn-ghost">Settings</a>
|
||||
<?php endif; ?>
|
||||
<a href="<?= BASE_URL ?>/admin/profile.php" class="btn btn-ghost"><?= htmlspecialchars($user['display_name'] ?: $user['username']) ?></a>
|
||||
<a href="<?= BASE_URL ?>/admin/" class="btn btn-ghost">← Dashboard</a>
|
||||
<a href="<?= BASE_URL ?>/logout" class="btn btn-ghost">Logout</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<h2 style="margin-bottom:8px">Prayer Audio</h2>
|
||||
<p style="color:#6b7280;margin:0 0 24px">
|
||||
Upload pre-recorded audio for each prayer. A 🔊 toggle appears in the presenter when audio is available.
|
||||
</p>
|
||||
|
||||
<div class="help-note">
|
||||
<strong>How it works</strong>
|
||||
Accepted formats: MP3, M4A, OGG, WAV • Max file size: 50 MB per file.<br>
|
||||
Prayers that repeat (e.g. Our Father, Hail Mary, Glory Be) share a single recording — upload once and it plays on every occurrence.<br>
|
||||
Uploading a new file for a key automatically replaces the old one.
|
||||
</div>
|
||||
|
||||
<div class="audio-summary">
|
||||
<div class="audio-summary-stat">
|
||||
<div class="num"><?= $uploaded_count ?></div>
|
||||
<div class="lbl">of <?= $total_keys ?> prayers recorded</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="upload-msg" class="alert" style="display:none;margin-bottom:16px"></div>
|
||||
|
||||
<?php foreach ($AUDIO_KEYS as $category => $keys): ?>
|
||||
<div class="audio-section">
|
||||
<h3><?= htmlspecialchars($category) ?></h3>
|
||||
<div class="sessions-table-wrap">
|
||||
<table class="audio-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:38%">Prayer</th>
|
||||
<th style="width:28%">Key</th>
|
||||
<th style="width:16%">Status</th>
|
||||
<th style="width:18%">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($keys as $key => $label):
|
||||
$has = isset($uploaded_files[$key]);
|
||||
$ext = $has ? $uploaded_files[$key] : null;
|
||||
?>
|
||||
<tr id="row-<?= $key ?>">
|
||||
<td><?= htmlspecialchars($label) ?></td>
|
||||
<td><span class="key-code"><?= htmlspecialchars($key) ?></span></td>
|
||||
<td>
|
||||
<?php if ($has): ?>
|
||||
<span class="status-uploaded">✓ Uploaded<span class="file-ext"><?= $ext ?></span></span>
|
||||
<?php else: ?>
|
||||
<span class="status-missing">— Not uploaded</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td>
|
||||
<label class="btn btn-sm btn-secondary upload-btn" style="cursor:pointer;display:inline-block">
|
||||
<?= $has ? 'Replace' : 'Upload' ?>
|
||||
<input type="file" style="display:none" accept="audio/*"
|
||||
data-key="<?= htmlspecialchars($key) ?>">
|
||||
</label>
|
||||
<?php if ($has): ?>
|
||||
<button class="btn btn-sm btn-danger delete-btn"
|
||||
data-key="<?= htmlspecialchars($key) ?>">Delete</button>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var msgBox = document.getElementById('upload-msg');
|
||||
|
||||
function showMsg(type, text) {
|
||||
msgBox.className = 'alert alert-' + type;
|
||||
msgBox.textContent = text;
|
||||
msgBox.style.display = '';
|
||||
setTimeout(function () { msgBox.style.display = 'none'; }, 4000);
|
||||
}
|
||||
|
||||
function updateRow(key, ext) {
|
||||
var row = document.getElementById('row-' + key);
|
||||
if (!row) return;
|
||||
|
||||
var statusCell = row.cells[2];
|
||||
var actionsCell = row.cells[3];
|
||||
|
||||
if (ext) {
|
||||
statusCell.innerHTML =
|
||||
'<span class="status-uploaded">✓ Uploaded' +
|
||||
'<span class="file-ext">' + ext + '</span></span>';
|
||||
|
||||
// Rebuild actions cell
|
||||
actionsCell.innerHTML =
|
||||
'<label class="btn btn-sm btn-secondary upload-btn" style="cursor:pointer;display:inline-block">' +
|
||||
'Replace<input type="file" style="display:none" accept="audio/*" data-key="' + key + '"></label>' +
|
||||
' <button class="btn btn-sm btn-danger delete-btn" data-key="' + key + '">Delete</button>';
|
||||
} else {
|
||||
statusCell.innerHTML = '<span class="status-missing">— Not uploaded</span>';
|
||||
actionsCell.innerHTML =
|
||||
'<label class="btn btn-sm btn-secondary upload-btn" style="cursor:pointer;display:inline-block">' +
|
||||
'Upload<input type="file" style="display:none" accept="audio/*" data-key="' + key + '"></label>';
|
||||
}
|
||||
|
||||
// Re-attach listeners on rebuilt elements
|
||||
wireRow(row);
|
||||
}
|
||||
|
||||
function wireRow(row) {
|
||||
var fileInput = row.querySelector('input[type="file"]');
|
||||
if (fileInput) {
|
||||
fileInput.addEventListener('change', handleUpload);
|
||||
}
|
||||
var delBtn = row.querySelector('.delete-btn');
|
||||
if (delBtn) {
|
||||
delBtn.addEventListener('click', handleDelete);
|
||||
}
|
||||
}
|
||||
|
||||
function handleUpload() {
|
||||
var key = this.dataset.key;
|
||||
var file = this.files[0];
|
||||
if (!file) return;
|
||||
|
||||
var label = this.closest('label');
|
||||
if (label) { label.textContent = 'Uploading\u2026'; }
|
||||
|
||||
var fd = new FormData();
|
||||
fd.append('key', key);
|
||||
fd.append('audio', file);
|
||||
|
||||
fetch(BASE_URL + '/api/upload_audio.php', { method: 'POST', body: fd })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
if (data.error) {
|
||||
showMsg('error', 'Upload failed: ' + data.error);
|
||||
updateRow(key, null); // reset UI
|
||||
} else {
|
||||
showMsg('success', '\u2713 Uploaded: ' + key + '.' + data.ext);
|
||||
updateRow(key, data.ext);
|
||||
}
|
||||
})
|
||||
.catch(function () {
|
||||
showMsg('error', 'Network error during upload.');
|
||||
updateRow(key, null);
|
||||
});
|
||||
}
|
||||
|
||||
function handleDelete() {
|
||||
var key = this.dataset.key;
|
||||
if (!confirm('Delete audio for "' + key + '"?')) return;
|
||||
|
||||
var fd = new FormData();
|
||||
fd.append('key', key);
|
||||
|
||||
fetch(BASE_URL + '/api/delete_audio.php', { method: 'POST', body: fd })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
if (data.error) {
|
||||
showMsg('error', 'Delete failed: ' + data.error);
|
||||
} else {
|
||||
showMsg('success', 'Deleted: ' + key);
|
||||
updateRow(key, null);
|
||||
}
|
||||
})
|
||||
.catch(function () { showMsg('error', 'Network error.'); });
|
||||
}
|
||||
|
||||
// Wire all rows on load
|
||||
document.querySelectorAll('tr[id^="row-"]').forEach(function (row) {
|
||||
wireRow(row);
|
||||
});
|
||||
|
||||
}());
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user