Files
pguzman 21e856f250 Add Builder and Prayers nav links to all admin pages
Every admin page now shows the Builder link (superuser+) and
Prayers link (admin+) consistently in the header nav.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 19:18:38 -07:00

391 lines
18 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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('superuser')): ?>
<a href="<?= BASE_URL ?>/admin/builder.php" class="btn btn-ghost">&#x2712; Builder</a>
<?php endif; ?>
<?php if (has_role('admin')): ?>
<a href="<?= BASE_URL ?>/admin/prayers.php" class="btn btn-ghost">Prayers</a>
<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>