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>
|
||||
+247
@@ -0,0 +1,247 @@
|
||||
<?php
|
||||
/**
|
||||
* admin/index.php — Dashboard. Shows sessions for current user (or all for admin+).
|
||||
*/
|
||||
require_once __DIR__ . '/../config/db.php';
|
||||
require_once __DIR__ . '/../includes/auth.php';
|
||||
|
||||
require_auth();
|
||||
|
||||
$pdo = get_pdo();
|
||||
$user = current_user();
|
||||
$uid = (int)$user['id'];
|
||||
$is_admin = has_role('admin');
|
||||
$site_name = get_setting('site_name', APP_NAME);
|
||||
|
||||
// Handle deletions
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
if (isset($_POST['delete_group_id'])) {
|
||||
$gid = (int)$_POST['delete_group_id'];
|
||||
// Verify ownership or admin
|
||||
if ($is_admin) {
|
||||
$pdo->prepare('DELETE FROM sessions WHERE novena_group_id = ?')->execute([$gid]);
|
||||
$pdo->prepare('DELETE FROM novena_groups WHERE id = ?')->execute([$gid]);
|
||||
} else {
|
||||
$pdo->prepare('DELETE FROM sessions WHERE novena_group_id = ? AND (SELECT user_id FROM novena_groups WHERE id = ?) = ?')
|
||||
->execute([$gid, $gid, $uid]);
|
||||
$pdo->prepare('DELETE FROM novena_groups WHERE id = ? AND user_id = ?')->execute([$gid, $uid]);
|
||||
}
|
||||
header('Location: ' . BASE_URL . '/admin/');
|
||||
exit;
|
||||
}
|
||||
|
||||
if (isset($_POST['delete_id'])) {
|
||||
$id = (int)$_POST['delete_id'];
|
||||
if ($is_admin) {
|
||||
$pdo->prepare('DELETE FROM sessions WHERE id = ?')->execute([$id]);
|
||||
} else {
|
||||
$pdo->prepare('DELETE FROM sessions WHERE id = ? AND user_id = ?')->execute([$id, $uid]);
|
||||
}
|
||||
header('Location: ' . BASE_URL . '/admin/');
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// Load sessions
|
||||
if ($is_admin) {
|
||||
$regular = $pdo->query("
|
||||
SELECT s.*, 'session' AS row_type, u.username, u.display_name
|
||||
FROM sessions s
|
||||
LEFT JOIN users u ON u.id = s.user_id
|
||||
WHERE s.novena_group_id IS NULL
|
||||
ORDER BY s.created_at DESC
|
||||
")->fetchAll();
|
||||
|
||||
$novena_groups = $pdo->query("
|
||||
SELECT ng.*, 'novena_group' AS row_type, COUNT(s.id) AS day_count,
|
||||
u.username, u.display_name
|
||||
FROM novena_groups ng
|
||||
LEFT JOIN sessions s ON s.novena_group_id = ng.id
|
||||
LEFT JOIN users u ON u.id = ng.user_id
|
||||
GROUP BY ng.id
|
||||
ORDER BY ng.created_at DESC
|
||||
")->fetchAll();
|
||||
} else {
|
||||
$regular = $pdo->prepare("
|
||||
SELECT s.*, 'session' AS row_type, u.username, u.display_name
|
||||
FROM sessions s
|
||||
LEFT JOIN users u ON u.id = s.user_id
|
||||
WHERE s.novena_group_id IS NULL AND s.user_id = ?
|
||||
ORDER BY s.created_at DESC
|
||||
");
|
||||
$regular->execute([$uid]);
|
||||
$regular = $regular->fetchAll();
|
||||
|
||||
$ng_stmt = $pdo->prepare("
|
||||
SELECT ng.*, 'novena_group' AS row_type, COUNT(s.id) AS day_count,
|
||||
u.username, u.display_name
|
||||
FROM novena_groups ng
|
||||
LEFT JOIN sessions s ON s.novena_group_id = ng.id
|
||||
LEFT JOIN users u ON u.id = ng.user_id
|
||||
WHERE ng.user_id = ?
|
||||
GROUP BY ng.id
|
||||
ORDER BY ng.created_at DESC
|
||||
");
|
||||
$ng_stmt->execute([$uid]);
|
||||
$novena_groups = $ng_stmt->fetchAll();
|
||||
}
|
||||
|
||||
$all_rows = array_merge($regular, $novena_groups);
|
||||
usort($all_rows, fn($a, $b) => strcmp($b['created_at'], $a['created_at']));
|
||||
|
||||
$occasion_labels = [
|
||||
'novena_deceased' => 'Novena for Deceased',
|
||||
'divine_mercy_novena' => 'Divine Mercy Novena',
|
||||
'general_rosary' => 'General Rosary',
|
||||
'memorial' => 'Memorial',
|
||||
];
|
||||
$mystery_labels = [
|
||||
'sorrowful' => 'Sorrowful',
|
||||
'joyful' => 'Joyful',
|
||||
'glorious' => 'Glorious',
|
||||
'luminous' => 'Luminous',
|
||||
'by_day_of_week' => 'By Day',
|
||||
];
|
||||
|
||||
$novena_created = isset($_GET['novena_created']) ? (int)$_GET['novena_created'] : 0;
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="icon" type="image/svg+xml" href="<?= BASE_URL ?>/favicon.svg">
|
||||
<title>Dashboard — <?= htmlspecialchars($site_name) ?></title>
|
||||
<link rel="stylesheet" href="<?= BASE_URL ?>/assets/css/setup.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="admin-container">
|
||||
|
||||
<header class="admin-header">
|
||||
<h1>✝ <?= htmlspecialchars($site_name) ?></h1>
|
||||
<div class="header-actions">
|
||||
<a href="<?= BASE_URL ?>/" class="btn btn-ghost" style="font-size:13px">← View Site</a>
|
||||
<?php if (has_role('admin')): ?>
|
||||
<a href="<?= BASE_URL ?>/admin/users.php" class="btn btn-ghost">Users</a>
|
||||
<a href="<?= BASE_URL ?>/admin/audio.php" class="btn btn-ghost">Audio</a>
|
||||
<?php endif; ?>
|
||||
<?php if (has_role('superadmin')): ?>
|
||||
<a href="<?= BASE_URL ?>/admin/settings.php" class="btn btn-ghost">Settings</a>
|
||||
<?php endif; ?>
|
||||
<a href="<?= BASE_URL ?>/admin/profile.php" class="btn btn-ghost"><?= htmlspecialchars($user['display_name'] ?: $user['username']) ?></a>
|
||||
<a href="<?= BASE_URL ?>/logout" class="btn btn-ghost">Logout</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<?php if ($novena_created > 0): ?>
|
||||
<div class="alert alert-success">
|
||||
✓ <?= $novena_created ?> novena day sessions created.
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:20px">
|
||||
<h2 style="margin:0"><?= $is_admin ? 'All Sessions' : 'My Sessions' ?></h2>
|
||||
<a href="<?= BASE_URL ?>/admin/setup.php" class="btn btn-primary">+ New Session</a>
|
||||
</div>
|
||||
|
||||
<?php if (empty($all_rows)): ?>
|
||||
<div class="empty-state">
|
||||
<p>No sessions yet.</p>
|
||||
<a href="<?= BASE_URL ?>/admin/setup.php" class="btn btn-primary">Create Your First Session</a>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="sessions-table-wrap">
|
||||
<table class="sessions-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Occasion</th>
|
||||
<th>Mysteries</th>
|
||||
<?php if ($is_admin): ?><th>Owner</th><?php endif; ?>
|
||||
<th>Public</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($all_rows as $row): ?>
|
||||
|
||||
<?php if ($row['row_type'] === 'novena_group'): ?>
|
||||
<tr class="novena-group-row">
|
||||
<td class="session-name">
|
||||
<span class="novena-group-icon">✝</span>
|
||||
<?= htmlspecialchars($row['name']) ?>
|
||||
<?php if ($row['subject_name']): ?>
|
||||
<span class="subject-name"><?= htmlspecialchars($row['subject_name']) ?></span>
|
||||
<?php endif; ?>
|
||||
<span class="novena-badge">9-Day Novena</span>
|
||||
</td>
|
||||
<td><?= ($row['mystery_set'] === 'chaplet') ? 'Divine Mercy Novena' : 'Novena for Deceased' ?></td>
|
||||
<td><?= htmlspecialchars($mystery_labels[$row['mystery_set']] ?? $row['mystery_set']) ?></td>
|
||||
<?php if ($is_admin): ?>
|
||||
<td><?= htmlspecialchars($row['display_name'] ?: ($row['username'] ?? '—')) ?></td>
|
||||
<?php endif; ?>
|
||||
<td><?= $row['is_public'] ? '<span style="color:#15803d">✓ Yes</span>' : '<span style="color:#9ca3af">No</span>' ?></td>
|
||||
<td><?= date('M j, Y', strtotime($row['created_at'])) ?></td>
|
||||
<td class="actions">
|
||||
<a href="<?= BASE_URL ?>/admin/novena_group.php?id=<?= $row['id'] ?>"
|
||||
class="btn btn-sm btn-primary">View Days →</a>
|
||||
<?php if ($is_admin || (int)$row['user_id'] === $uid): ?>
|
||||
<form method="post" style="display:inline"
|
||||
onsubmit="return confirm('Delete this entire novena (all 9 days)?')">
|
||||
<input type="hidden" name="delete_group_id" value="<?= $row['id'] ?>">
|
||||
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<?php else: ?>
|
||||
<tr>
|
||||
<td class="session-name">
|
||||
<?= htmlspecialchars($row['name']) ?>
|
||||
<?php if ($row['subject_name']): ?>
|
||||
<span class="subject-name"><?= htmlspecialchars($row['subject_name']) ?></span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td><?= htmlspecialchars($occasion_labels[$row['occasion']] ?? $row['occasion']) ?></td>
|
||||
<td><?= htmlspecialchars($mystery_labels[$row['mystery_set']] ?? $row['mystery_set']) ?></td>
|
||||
<?php if ($is_admin): ?>
|
||||
<td><?= htmlspecialchars($row['display_name'] ?: ($row['username'] ?? '—')) ?></td>
|
||||
<?php endif; ?>
|
||||
<td><?= $row['is_public'] ? '<span style="color:#15803d">✓ Yes</span>' : '<span style="color:#9ca3af">No</span>' ?></td>
|
||||
<td><?= date('M j, Y', strtotime($row['created_at'])) ?></td>
|
||||
<td class="actions">
|
||||
<?php
|
||||
$slug_val = $row['slug'] ?? '';
|
||||
$owner = $row['username'] ?? '';
|
||||
$present_url = ($slug_val && $owner)
|
||||
? BASE_URL . '/' . rawurlencode($owner) . '/' . rawurlencode($slug_val)
|
||||
: BASE_URL . '/present.php?id=' . $row['id'];
|
||||
?>
|
||||
<a href="<?= htmlspecialchars($present_url) ?>"
|
||||
target="_blank"
|
||||
class="btn btn-sm btn-primary">Present</a>
|
||||
<?php if ($is_admin || (int)$row['user_id'] === $uid): ?>
|
||||
<a href="<?= BASE_URL ?>/admin/setup.php?id=<?= $row['id'] ?>"
|
||||
class="btn btn-sm btn-secondary">Edit</a>
|
||||
<form method="post" style="display:inline"
|
||||
onsubmit="return confirm('Delete this session?')">
|
||||
<input type="hidden" name="delete_id" value="<?= $row['id'] ?>">
|
||||
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</main>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,338 @@
|
||||
<?php
|
||||
/**
|
||||
* admin/novena_group.php — View and manage one novena group.
|
||||
*/
|
||||
require_once __DIR__ . '/../config/db.php';
|
||||
require_once __DIR__ . '/../includes/auth.php';
|
||||
|
||||
require_auth();
|
||||
|
||||
$pdo = get_pdo();
|
||||
$user = current_user();
|
||||
$uid = (int)$user['id'];
|
||||
$gid = isset($_GET['id']) ? (int)$_GET['id'] : 0;
|
||||
$site_name = get_setting('site_name', APP_NAME);
|
||||
|
||||
if ($gid < 1) {
|
||||
header('Location: ' . BASE_URL . '/admin/');
|
||||
exit;
|
||||
}
|
||||
|
||||
// Load group and verify ownership
|
||||
$group = $pdo->prepare('SELECT * FROM novena_groups WHERE id = ?');
|
||||
$group->execute([$gid]);
|
||||
$group = $group->fetch();
|
||||
|
||||
if (!$group) {
|
||||
header('Location: ' . BASE_URL . '/admin/');
|
||||
exit;
|
||||
}
|
||||
|
||||
// Ownership check
|
||||
if (!has_role('admin') && (int)$group['user_id'] !== $uid) {
|
||||
header('Location: ' . BASE_URL . '/admin/');
|
||||
exit;
|
||||
}
|
||||
|
||||
$is_dm = ($group['mystery_set'] === 'chaplet');
|
||||
|
||||
// Handle delete of a single day session
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['delete_day_id'])) {
|
||||
$did = (int)$_POST['delete_day_id'];
|
||||
$pdo->prepare('DELETE FROM sessions WHERE id = ? AND novena_group_id = ?')->execute([$did, $gid]);
|
||||
|
||||
$remaining = (int)$pdo->query("SELECT COUNT(*) FROM sessions WHERE novena_group_id = {$gid}")->fetchColumn();
|
||||
if ($remaining < 1) {
|
||||
$pdo->prepare('DELETE FROM novena_groups WHERE id = ?')->execute([$gid]);
|
||||
header('Location: ' . BASE_URL . '/admin/');
|
||||
exit;
|
||||
}
|
||||
header('Location: ' . BASE_URL . '/admin/novena_group.php?id=' . $gid . '&deleted=1');
|
||||
exit;
|
||||
}
|
||||
|
||||
// Handle group-level metadata edit
|
||||
$save_error = '';
|
||||
$save_success = false;
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save_group'])) {
|
||||
$g_name = trim($_POST['g_name'] ?? '');
|
||||
$g_photo = trim($_POST['g_photo'] ?? '') ?: null;
|
||||
$g_public = isset($_POST['is_public']) ? 1 : 0;
|
||||
|
||||
if ($is_dm) {
|
||||
// Divine Mercy: keep mystery_set as 'chaplet', no subject fields
|
||||
$g_mystery = 'chaplet';
|
||||
$g_subject = null;
|
||||
$g_pronoun = null;
|
||||
$g_dates = null;
|
||||
} else {
|
||||
$g_mystery = trim($_POST['g_mystery'] ?? '');
|
||||
$g_subject = trim($_POST['g_subject'] ?? '') ?: null;
|
||||
$g_pronoun = trim($_POST['g_pronoun'] ?? '') ?: null;
|
||||
$g_dates = trim($_POST['g_dates'] ?? '') ?: null;
|
||||
}
|
||||
|
||||
$valid_mysteries = ['sorrowful', 'joyful', 'glorious', 'luminous', 'by_day_of_week', 'chaplet'];
|
||||
|
||||
if ($g_name === '') {
|
||||
$save_error = 'Name is required.';
|
||||
} elseif (!in_array($g_mystery, $valid_mysteries, true)) {
|
||||
$save_error = 'Invalid mystery set.';
|
||||
} else {
|
||||
$pdo->prepare('
|
||||
UPDATE novena_groups
|
||||
SET name = ?, mystery_set = ?, subject_name = ?,
|
||||
subject_pronoun = ?, subject_dates = ?,
|
||||
photo_path = COALESCE(?, photo_path),
|
||||
is_public = ?
|
||||
WHERE id = ?
|
||||
')->execute([$g_name, $g_mystery, $g_subject, $g_pronoun, $g_dates, $g_photo, $g_public, $gid]);
|
||||
|
||||
$pdo->prepare('
|
||||
UPDATE sessions
|
||||
SET mystery_set = ?, subject_name = ?,
|
||||
subject_pronoun = ?, subject_dates = ?,
|
||||
photo_path = COALESCE(?, photo_path),
|
||||
name = CONCAT(?, CONCAT(\' — Day \', novena_day)),
|
||||
is_public = ?
|
||||
WHERE novena_group_id = ?
|
||||
')->execute([$g_mystery, $g_subject, $g_pronoun, $g_dates, $g_photo, $g_name, $g_public, $gid]);
|
||||
|
||||
$save_success = true;
|
||||
// Reload group
|
||||
$grp_stmt = $pdo->prepare('SELECT * FROM novena_groups WHERE id = ?');
|
||||
$grp_stmt->execute([$gid]);
|
||||
$group = $grp_stmt->fetch();
|
||||
}
|
||||
}
|
||||
|
||||
$days = $pdo->prepare('SELECT * FROM sessions WHERE novena_group_id = ? ORDER BY novena_day');
|
||||
$days->execute([$gid]);
|
||||
$days = $days->fetchAll();
|
||||
|
||||
$mystery_labels = [
|
||||
'sorrowful' => 'Sorrowful Mysteries',
|
||||
'joyful' => 'Joyful Mysteries',
|
||||
'glorious' => 'Glorious Mysteries',
|
||||
'luminous' => 'Luminous Mysteries',
|
||||
'by_day_of_week' => 'By Day of Week',
|
||||
'chaplet' => 'Chaplet of Divine Mercy',
|
||||
];
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="icon" type="image/svg+xml" href="<?= BASE_URL ?>/favicon.svg">
|
||||
<title><?= htmlspecialchars($group['name']) ?> — <?= htmlspecialchars($site_name) ?></title>
|
||||
<link rel="stylesheet" href="<?= BASE_URL ?>/assets/css/setup.css">
|
||||
<script>var BASE_URL = '<?= BASE_URL ?>';</script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="admin-container">
|
||||
|
||||
<header class="admin-header">
|
||||
<h1>✝ <?= htmlspecialchars($site_name) ?></h1>
|
||||
<div class="header-actions">
|
||||
<a href="<?= BASE_URL ?>/" class="btn btn-ghost" style="font-size:13px">← View Site</a>
|
||||
<?php if (has_role('admin')): ?>
|
||||
<a href="<?= BASE_URL ?>/admin/users.php" class="btn btn-ghost">Users</a>
|
||||
<?php endif; ?>
|
||||
<?php if (has_role('superadmin')): ?>
|
||||
<a href="<?= BASE_URL ?>/admin/settings.php" class="btn btn-ghost">Settings</a>
|
||||
<?php endif; ?>
|
||||
<a href="<?= BASE_URL ?>/admin/profile.php" class="btn btn-ghost"><?= htmlspecialchars($user['display_name'] ?: $user['username']) ?></a>
|
||||
<a href="<?= BASE_URL ?>/admin/" class="btn btn-ghost">← All Sessions</a>
|
||||
<a href="<?= BASE_URL ?>/logout" class="btn btn-ghost">Logout</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<nav class="breadcrumb">
|
||||
<a href="<?= BASE_URL ?>/admin/">Sessions</a>
|
||||
<span class="bc-sep">/</span>
|
||||
<span><?= htmlspecialchars($group['name']) ?></span>
|
||||
</nav>
|
||||
|
||||
<?php if (isset($_GET['deleted'])): ?>
|
||||
<div class="alert alert-success">Day deleted successfully.</div>
|
||||
<?php endif; ?>
|
||||
<?php if ($save_success): ?>
|
||||
<div class="alert alert-success">✓ Novena updated. Changes applied to all 9 day sessions.</div>
|
||||
<?php elseif ($save_error): ?>
|
||||
<div class="alert alert-error"><?= htmlspecialchars($save_error) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<section class="card" style="margin-bottom:32px">
|
||||
<h2 class="card-title">Novena Details</h2>
|
||||
<form method="post">
|
||||
<input type="hidden" name="save_group" value="1">
|
||||
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="g_name">Novena Name</label>
|
||||
<input type="text" id="g_name" name="g_name" class="form-input"
|
||||
value="<?= htmlspecialchars($group['name']) ?>" required>
|
||||
<p class="form-help">Used as the base name for all 9 days.</p>
|
||||
</div>
|
||||
|
||||
<?php if (!$is_dm): ?>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="g_mystery">Mysteries</label>
|
||||
<select id="g_mystery" name="g_mystery" class="form-input">
|
||||
<?php foreach ([
|
||||
'sorrowful' => 'All Sorrowful Mysteries',
|
||||
'by_day_of_week' => 'By Day of Week',
|
||||
'joyful' => 'All Joyful Mysteries',
|
||||
'glorious' => 'All Glorious Mysteries',
|
||||
'luminous' => 'All Luminous Mysteries',
|
||||
] as $val => $label): ?>
|
||||
<option value="<?= $val ?>" <?= $group['mystery_set'] === $val ? 'selected' : '' ?>>
|
||||
<?= $label ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="g_subject">Name of Deceased</label>
|
||||
<input type="text" id="g_subject" name="g_subject" class="form-input"
|
||||
value="<?= htmlspecialchars($group['subject_name'] ?? '') ?>">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Pronoun</label>
|
||||
<div class="radio-row">
|
||||
<label><input type="radio" name="g_pronoun" value="he"
|
||||
<?= ($group['subject_pronoun'] ?? 'he') === 'he' ? 'checked' : '' ?>> He / Him</label>
|
||||
<label><input type="radio" name="g_pronoun" value="she"
|
||||
<?= ($group['subject_pronoun'] ?? '') === 'she' ? 'checked' : '' ?>> She / Her</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="g_dates">Life Dates</label>
|
||||
<input type="text" id="g_dates" name="g_dates" class="form-input"
|
||||
placeholder="e.g., March 15, 1942 – June 3, 2025"
|
||||
value="<?= htmlspecialchars($group['subject_dates'] ?? '') ?>">
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="form-group" id="photo-group">
|
||||
<label class="form-label">Photo</label>
|
||||
<?php if ($group['photo_path']): ?>
|
||||
<div class="photo-preview-wrap">
|
||||
<img id="photo-preview" src="<?= htmlspecialchars('/' . ltrim($group['photo_path'], '/')) ?>"
|
||||
alt="Current photo" class="photo-preview">
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="photo-preview-wrap" style="display:none">
|
||||
<img id="photo-preview" src="" alt="" class="photo-preview">
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<input type="hidden" id="g_photo" name="g_photo"
|
||||
value="<?= htmlspecialchars($group['photo_path'] ?? '') ?>">
|
||||
<label class="btn btn-secondary btn-upload" style="margin-top:8px">
|
||||
<?= $group['photo_path'] ? 'Replace Photo' : 'Upload Photo' ?>
|
||||
<input type="file" id="photo-file" accept="image/*" style="display:none">
|
||||
</label>
|
||||
<span id="photo-status" class="form-help"></span>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label style="display:flex;align-items:center;gap:10px;cursor:pointer">
|
||||
<input type="checkbox" name="is_public" value="1"
|
||||
<?= $group['is_public'] ? 'checked' : '' ?>
|
||||
style="width:18px;height:18px">
|
||||
<span>Public (visible on your profile)</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">Save Changes to All Days</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 style="margin-bottom:16px">Days (<?= count($days) ?> of 9)</h2>
|
||||
<div class="sessions-table-wrap">
|
||||
<table class="sessions-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Day</th>
|
||||
<th>Mysteries</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php for ($d = 1; $d <= 9; $d++):
|
||||
$ses = null;
|
||||
foreach ($days as $day_row) {
|
||||
if ((int)$day_row['novena_day'] === $d) { $ses = $day_row; break; }
|
||||
}
|
||||
?>
|
||||
<tr <?= !$ses ? 'class="day-missing"' : '' ?>>
|
||||
<td><strong>Day <?= $d ?></strong></td>
|
||||
<td><?= $ses ? htmlspecialchars($mystery_labels[$ses['mystery_set']] ?? $ses['mystery_set']) : '<em>missing</em>' ?></td>
|
||||
<td class="actions">
|
||||
<?php if ($ses): ?>
|
||||
<a href="<?= BASE_URL ?>/present.php?id=<?= $ses['id'] ?>"
|
||||
target="_blank"
|
||||
class="btn btn-sm btn-primary">Present</a>
|
||||
<form method="post" style="display:inline"
|
||||
onsubmit="return confirm('Delete Day <?= $d ?>?')">
|
||||
<input type="hidden" name="delete_day_id" value="<?= $ses['id'] ?>">
|
||||
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
|
||||
</form>
|
||||
<?php else: ?>
|
||||
<span class="text-muted">—</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endfor; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var fileInput = document.getElementById('photo-file');
|
||||
var photoHidden = document.getElementById('g_photo');
|
||||
var photoStatus = document.getElementById('photo-status');
|
||||
var previewWrap = document.querySelector('.photo-preview-wrap');
|
||||
var previewImg = document.getElementById('photo-preview');
|
||||
|
||||
if (!fileInput) return;
|
||||
|
||||
fileInput.addEventListener('change', function () {
|
||||
var file = fileInput.files[0];
|
||||
if (!file) return;
|
||||
var fd = new FormData();
|
||||
fd.append('photo', file);
|
||||
photoStatus.textContent = 'Uploading\u2026';
|
||||
|
||||
fetch(BASE_URL + '/api/upload_photo.php', { method: 'POST', body: fd })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
if (data.path) {
|
||||
photoHidden.value = data.path;
|
||||
previewImg.src = '/' + data.path.replace(/^\//, '');
|
||||
previewWrap.style.display = '';
|
||||
photoStatus.textContent = 'Photo ready.';
|
||||
} else {
|
||||
photoStatus.textContent = 'Upload failed: ' + (data.error || 'unknown error');
|
||||
}
|
||||
})
|
||||
.catch(function () { photoStatus.textContent = 'Upload failed.'; });
|
||||
});
|
||||
}());
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,253 @@
|
||||
<?php
|
||||
/**
|
||||
* admin/profile.php — Current user's account settings.
|
||||
*/
|
||||
require_once __DIR__ . '/../config/db.php';
|
||||
require_once __DIR__ . '/../includes/auth.php';
|
||||
|
||||
require_auth();
|
||||
|
||||
$pdo = get_pdo();
|
||||
$user = current_user();
|
||||
$uid = (int)$user['id'];
|
||||
$site_name = get_setting('site_name', APP_NAME);
|
||||
$messages = [];
|
||||
$errors = [];
|
||||
|
||||
// Superadmin can view/edit another user's profile via ?id=X
|
||||
$target_id = $uid;
|
||||
if (has_role('superadmin') && isset($_GET['id'])) {
|
||||
$target_id = (int)$_GET['id'];
|
||||
}
|
||||
|
||||
// Load target user
|
||||
$stmt = $pdo->prepare('SELECT * FROM users WHERE id = ?');
|
||||
$stmt->execute([$target_id]);
|
||||
$profile = $stmt->fetch();
|
||||
if (!$profile) {
|
||||
header('Location: ' . BASE_URL . '/admin/');
|
||||
exit;
|
||||
}
|
||||
|
||||
// ── Handle form submissions ───────────────────────────────────────────────────
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$action = $_POST['action'] ?? '';
|
||||
|
||||
// ── Update profile ───────────────────────────────────────────────────────
|
||||
if ($action === 'update_profile') {
|
||||
$new_display = trim($_POST['display_name'] ?? '');
|
||||
$pdo->prepare('UPDATE users SET display_name=? WHERE id=?')->execute([$new_display, $target_id]);
|
||||
|
||||
// Refresh session if editing own profile
|
||||
if ($target_id === $uid) {
|
||||
$_SESSION['display_name'] = $new_display;
|
||||
}
|
||||
$messages[] = 'Profile updated.';
|
||||
$profile['display_name'] = $new_display;
|
||||
}
|
||||
|
||||
// ── Update email ─────────────────────────────────────────────────────────
|
||||
if ($action === 'update_email') {
|
||||
$new_email = trim($_POST['new_email'] ?? '');
|
||||
$cur_pass = $_POST['current_password'] ?? '';
|
||||
|
||||
if (!filter_var($new_email, FILTER_VALIDATE_EMAIL)) {
|
||||
$errors[] = 'Invalid email address.';
|
||||
} elseif (!password_verify($cur_pass, $profile['password_hash'])) {
|
||||
$errors[] = 'Current password is incorrect.';
|
||||
} else {
|
||||
$chk = $pdo->prepare('SELECT id FROM users WHERE email=? AND id!=?');
|
||||
$chk->execute([$new_email, $target_id]);
|
||||
if ($chk->fetch()) {
|
||||
$errors[] = 'That email is already in use.';
|
||||
} else {
|
||||
$pdo->prepare('UPDATE users SET email=? WHERE id=?')->execute([$new_email, $target_id]);
|
||||
if ($target_id === $uid) $_SESSION['email'] = $new_email;
|
||||
$messages[] = 'Email updated.';
|
||||
$profile['email'] = $new_email;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Change password ───────────────────────────────────────────────────────
|
||||
if ($action === 'change_password') {
|
||||
$cur_pass = $_POST['current_password'] ?? '';
|
||||
$new_pass = $_POST['new_password'] ?? '';
|
||||
$conf_pass = $_POST['confirm_password'] ?? '';
|
||||
|
||||
if (!password_verify($cur_pass, $profile['password_hash'])) {
|
||||
$errors[] = 'Current password is incorrect.';
|
||||
} elseif (strlen($new_pass) < 8) {
|
||||
$errors[] = 'New password must be at least 8 characters.';
|
||||
} elseif ($new_pass !== $conf_pass) {
|
||||
$errors[] = 'New passwords do not match.';
|
||||
} else {
|
||||
$hash = password_hash($new_pass, PASSWORD_BCRYPT);
|
||||
$pdo->prepare('UPDATE users SET password_hash=? WHERE id=?')->execute([$hash, $target_id]);
|
||||
$messages[] = 'Password changed successfully.';
|
||||
$profile['password_hash'] = $hash;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Superadmin: change rosary limit ──────────────────────────────────────
|
||||
if ($action === 'update_limit' && has_role('superadmin')) {
|
||||
$new_limit = (int)($_POST['rosary_limit'] ?? 1);
|
||||
$pdo->prepare('UPDATE users SET rosary_limit=? WHERE id=?')->execute([$new_limit, $target_id]);
|
||||
if ($target_id === $uid) $_SESSION['rosary_limit'] = $new_limit;
|
||||
$messages[] = 'Rosary limit updated.';
|
||||
$profile['rosary_limit'] = $new_limit;
|
||||
}
|
||||
}
|
||||
|
||||
$is_own = ($target_id === $uid);
|
||||
$is_super = has_role('superadmin');
|
||||
$role_labels = ['superadmin'=>'Superadmin','admin'=>'Admin','superuser'=>'Superuser','user'=>'User'];
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="icon" type="image/svg+xml" href="<?= BASE_URL ?>/favicon.svg">
|
||||
<title><?= $is_own ? 'My Account' : 'Edit User' ?> — <?= htmlspecialchars($site_name) ?></title>
|
||||
<link rel="stylesheet" href="<?= BASE_URL ?>/assets/css/setup.css">
|
||||
<style>
|
||||
.profile-section{background:#fff;border:1px solid #e5e7eb;border-radius:8px;padding:28px;margin-bottom:24px}
|
||||
.profile-section h3{margin:0 0 20px;font-size:16px;font-weight:700;color:#1e3a5f;border-bottom:2px solid #e5e7eb;padding-bottom:10px}
|
||||
.readonly-field{background:#f9fafb;border:1px solid #e5e7eb;border-radius:6px;padding:8px 12px;font-size:14px;color:#374151}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="admin-container">
|
||||
|
||||
<header class="admin-header">
|
||||
<h1>✝ <?= htmlspecialchars($site_name) ?></h1>
|
||||
<div class="header-actions">
|
||||
<a href="<?= BASE_URL ?>/" class="btn btn-ghost" style="font-size:13px">← View Site</a>
|
||||
<a href="<?= BASE_URL ?>/admin/" class="btn btn-ghost">Dashboard</a>
|
||||
<?php if (has_role('admin')): ?>
|
||||
<a href="<?= BASE_URL ?>/admin/users.php" class="btn btn-ghost">Users</a>
|
||||
<?php endif; ?>
|
||||
<?php if ($is_super): ?>
|
||||
<a href="<?= BASE_URL ?>/admin/settings.php" class="btn btn-ghost">Settings</a>
|
||||
<?php endif; ?>
|
||||
<a href="<?= BASE_URL ?>/logout" class="btn btn-ghost">Logout</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<?php foreach ($messages as $m): ?>
|
||||
<div class="alert alert-success">✓ <?= htmlspecialchars($m) ?></div>
|
||||
<?php endforeach; ?>
|
||||
<?php foreach ($errors as $e): ?>
|
||||
<div class="alert alert-error"><?= htmlspecialchars($e) ?></div>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<h2 style="margin-bottom:24px"><?= $is_own ? 'My Account' : 'Edit: ' . htmlspecialchars($profile['username']) ?></h2>
|
||||
|
||||
<!-- Account Info -->
|
||||
<div class="profile-section">
|
||||
<h3>Account Info</h3>
|
||||
<div class="form-group">
|
||||
<label>Username</label>
|
||||
<div class="readonly-field"><?= htmlspecialchars($profile['username']) ?></div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Role</label>
|
||||
<div class="readonly-field"><?= htmlspecialchars($role_labels[$profile['role']] ?? $profile['role']) ?></div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Rosary Limit</label>
|
||||
<div class="readonly-field">
|
||||
<?= $profile['rosary_limit'] < 0 ? 'Unlimited' : (int)$profile['rosary_limit'] ?>
|
||||
<?php if ($is_super && $target_id !== $uid): ?>
|
||||
<a href="#limit-section" style="font-size:12px">(edit below)</a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Member Since</label>
|
||||
<div class="readonly-field"><?= date('F j, Y', strtotime($profile['created_at'])) ?></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Display Name -->
|
||||
<div class="profile-section">
|
||||
<h3>Display Name</h3>
|
||||
<form method="post">
|
||||
<input type="hidden" name="action" value="update_profile">
|
||||
<div class="form-group">
|
||||
<label for="display_name">Display Name</label>
|
||||
<input type="text" id="display_name" name="display_name"
|
||||
maxlength="100"
|
||||
value="<?= htmlspecialchars($profile['display_name'] ?? '') ?>">
|
||||
<p class="help-text">Shown publicly on your profile and rosary cards.</p>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Save</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Email -->
|
||||
<div class="profile-section">
|
||||
<h3>Email Address</h3>
|
||||
<form method="post">
|
||||
<input type="hidden" name="action" value="update_email">
|
||||
<div class="form-group">
|
||||
<label>Current Email</label>
|
||||
<div class="readonly-field"><?= htmlspecialchars($profile['email']) ?></div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="new_email">New Email</label>
|
||||
<input type="email" id="new_email" name="new_email" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="cur_pass_email">Current Password (to confirm)</label>
|
||||
<input type="password" id="cur_pass_email" name="current_password" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Update Email</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Change Password -->
|
||||
<div class="profile-section">
|
||||
<h3>Change Password</h3>
|
||||
<form method="post">
|
||||
<input type="hidden" name="action" value="change_password">
|
||||
<div class="form-group">
|
||||
<label for="cur_pass">Current Password</label>
|
||||
<input type="password" id="cur_pass" name="current_password" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="new_pass">New Password</label>
|
||||
<input type="password" id="new_pass" name="new_password" minlength="8" required>
|
||||
<p class="help-text">At least 8 characters.</p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="conf_pass">Confirm New Password</label>
|
||||
<input type="password" id="conf_pass" name="confirm_password" minlength="8" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Change Password</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Rosary Limit (superadmin editing another user) -->
|
||||
<?php if ($is_super && !$is_own): ?>
|
||||
<div class="profile-section" id="limit-section">
|
||||
<h3>Rosary Limit</h3>
|
||||
<form method="post">
|
||||
<input type="hidden" name="action" value="update_limit">
|
||||
<div class="form-group">
|
||||
<label for="rosary_limit">Limit (-1 = unlimited)</label>
|
||||
<input type="number" id="rosary_limit" name="rosary_limit"
|
||||
min="-1" value="<?= (int)$profile['rosary_limit'] ?>">
|
||||
<p class="help-text">How many rosary sessions this user can create.</p>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Save Limit</button>
|
||||
</form>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,260 @@
|
||||
<?php
|
||||
/**
|
||||
* admin/settings.php — Site settings. Requires superadmin role.
|
||||
*/
|
||||
require_once __DIR__ . '/../config/db.php';
|
||||
require_once __DIR__ . '/../includes/auth.php';
|
||||
require_once __DIR__ . '/../includes/mailer.php';
|
||||
|
||||
require_role('superadmin');
|
||||
|
||||
$user = current_user();
|
||||
$site_name = get_setting('site_name', APP_NAME);
|
||||
$message = '';
|
||||
$error = '';
|
||||
|
||||
// Save settings
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$action = $_POST['action'] ?? 'save';
|
||||
|
||||
if ($action === 'save') {
|
||||
$keys = ['site_name','site_url','smtp_host','smtp_port','smtp_user','smtp_pass','smtp_from','smtp_from_name',
|
||||
'donate_enabled','donate_type','donate_handle','donate_label'];
|
||||
foreach ($keys as $k) {
|
||||
if (isset($_POST[$k])) {
|
||||
set_setting($k, trim($_POST[$k]));
|
||||
}
|
||||
}
|
||||
$message = 'Settings saved.';
|
||||
$site_name = get_setting('site_name', APP_NAME); // refresh
|
||||
}
|
||||
|
||||
if ($action === 'test_email') {
|
||||
$to = $user['email'];
|
||||
$tname = $user['display_name'] ?: $user['username'];
|
||||
$body = email_template(
|
||||
'Test Email — ' . $site_name,
|
||||
"<h2 style='margin-top:0;color:#1e3a5f'>Test Email</h2>
|
||||
<p>Hello, <strong>" . htmlspecialchars($tname) . "</strong>!</p>
|
||||
<p>This is a test email from your {$site_name} installation. If you received this, your SMTP settings are working correctly.</p>"
|
||||
);
|
||||
$ok = send_email($to, $tname, 'Test Email — ' . $site_name, $body);
|
||||
if ($ok) {
|
||||
$message = 'Test email sent to ' . htmlspecialchars($to);
|
||||
} else {
|
||||
$error = 'Failed to send test email. Check your SMTP settings and server error log.';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$settings = [
|
||||
'site_name' => get_setting('site_name', 'Rosary Presenter'),
|
||||
'site_url' => get_setting('site_url'),
|
||||
'smtp_host' => get_setting('smtp_host'),
|
||||
'smtp_port' => get_setting('smtp_port', '587'),
|
||||
'smtp_user' => get_setting('smtp_user'),
|
||||
'smtp_pass' => get_setting('smtp_pass'),
|
||||
'smtp_from' => get_setting('smtp_from'),
|
||||
'smtp_from_name' => get_setting('smtp_from_name', 'Rosary Presenter'),
|
||||
'donate_enabled' => get_setting('donate_enabled', '0'),
|
||||
'donate_type' => get_setting('donate_type', 'custom'),
|
||||
'donate_handle' => get_setting('donate_handle', ''),
|
||||
'donate_label' => get_setting('donate_label', ''),
|
||||
];
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="icon" type="image/svg+xml" href="<?= BASE_URL ?>/favicon.svg">
|
||||
<title>Settings — <?= htmlspecialchars($site_name) ?></title>
|
||||
<link rel="stylesheet" href="<?= BASE_URL ?>/assets/css/setup.css">
|
||||
<style>
|
||||
.settings-section { background:#fff;border:1px solid #e5e7eb;border-radius:8px;padding:28px;margin-bottom:24px }
|
||||
.settings-section h3 { margin:0 0 20px;font-size:16px;font-weight:700;color:#1e3a5f;border-bottom:2px solid #e5e7eb;padding-bottom:10px }
|
||||
.pass-wrap { position:relative }
|
||||
.pass-wrap input { padding-right:80px }
|
||||
.pass-toggle { position:absolute;right:10px;top:50%;transform:translateY(-50%);background:none;border:none;cursor:pointer;font-size:12px;color:#6b7280;font-weight:600 }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="admin-container">
|
||||
|
||||
<header class="admin-header">
|
||||
<h1>✝ <?= htmlspecialchars($site_name) ?></h1>
|
||||
<div class="header-actions">
|
||||
<a href="<?= BASE_URL ?>/" class="btn btn-ghost" style="font-size:13px">← View Site</a>
|
||||
<a href="<?= BASE_URL ?>/admin/" class="btn btn-ghost">Dashboard</a>
|
||||
<a href="<?= BASE_URL ?>/admin/users.php" class="btn btn-ghost">Users</a>
|
||||
<a href="<?= BASE_URL ?>/admin/profile.php" class="btn btn-ghost"><?= htmlspecialchars($user['display_name'] ?: $user['username']) ?></a>
|
||||
<a href="<?= BASE_URL ?>/logout" class="btn btn-ghost">Logout</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<?php if ($message): ?>
|
||||
<div class="alert alert-success">✓ <?= htmlspecialchars($message) ?></div>
|
||||
<?php endif; ?>
|
||||
<?php if ($error): ?>
|
||||
<div class="alert alert-error"><?= htmlspecialchars($error) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<h2 style="margin-bottom:24px">Site Settings</h2>
|
||||
|
||||
<form method="post">
|
||||
<input type="hidden" name="action" value="save">
|
||||
|
||||
<div class="settings-section">
|
||||
<h3>General</h3>
|
||||
<div class="form-group">
|
||||
<label for="site_name">Site Name</label>
|
||||
<input type="text" id="site_name" name="site_name"
|
||||
value="<?= htmlspecialchars($settings['site_name']) ?>" required>
|
||||
<p class="help-text">Displayed in the browser tab, emails, and the site header.</p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="site_url">Site URL</label>
|
||||
<input type="url" id="site_url" name="site_url"
|
||||
placeholder="https://yourdomain.com"
|
||||
value="<?= htmlspecialchars($settings['site_url']) ?>">
|
||||
<p class="help-text">Used for links in emails. Include protocol, no trailing slash.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<h3>SMTP Email</h3>
|
||||
<p class="help-text" style="margin-top:0;margin-bottom:20px">
|
||||
Leave SMTP Host blank to use PHP's built-in <code>mail()</code> function.
|
||||
For Gmail: host=smtp.gmail.com, port=587, use an App Password.
|
||||
</p>
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
<label for="smtp_host">SMTP Host</label>
|
||||
<input type="text" id="smtp_host" name="smtp_host"
|
||||
placeholder="smtp.gmail.com"
|
||||
value="<?= htmlspecialchars($settings['smtp_host']) ?>">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="smtp_port">SMTP Port</label>
|
||||
<input type="number" id="smtp_port" name="smtp_port"
|
||||
placeholder="587" min="1" max="65535"
|
||||
value="<?= htmlspecialchars($settings['smtp_port']) ?>">
|
||||
<p class="help-text">587 for STARTTLS, 465 for SSL.</p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="smtp_user">SMTP Username</label>
|
||||
<input type="text" id="smtp_user" name="smtp_user"
|
||||
autocomplete="off"
|
||||
value="<?= htmlspecialchars($settings['smtp_user']) ?>">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="smtp_pass">SMTP Password</label>
|
||||
<div class="pass-wrap">
|
||||
<input type="password" id="smtp_pass" name="smtp_pass"
|
||||
autocomplete="new-password"
|
||||
value="<?= htmlspecialchars($settings['smtp_pass']) ?>">
|
||||
<button type="button" class="pass-toggle" onclick="togglePass()">Show</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="smtp_from">From Email</label>
|
||||
<input type="email" id="smtp_from" name="smtp_from"
|
||||
placeholder="noreply@yourdomain.com"
|
||||
value="<?= htmlspecialchars($settings['smtp_from']) ?>">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="smtp_from_name">From Name</label>
|
||||
<input type="text" id="smtp_from_name" name="smtp_from_name"
|
||||
value="<?= htmlspecialchars($settings['smtp_from_name']) ?>">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<h3>Donate Button</h3>
|
||||
<p class="help-text" style="margin-top:0;margin-bottom:20px">
|
||||
Show a small donation strip on the public home page. Choose a service, enter your handle or URL, and enable it when ready.
|
||||
</p>
|
||||
<div class="form-group">
|
||||
<label style="display:flex;align-items:center;gap:10px;cursor:pointer">
|
||||
<input type="checkbox" name="donate_enabled" value="1"
|
||||
id="donate_enabled"
|
||||
<?= $settings['donate_enabled'] ? 'checked' : '' ?>
|
||||
style="width:18px;height:18px">
|
||||
<span>Enable donate strip on public pages</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-grid" id="donate-fields">
|
||||
<div class="form-group">
|
||||
<label for="donate_type">Service</label>
|
||||
<select id="donate_type" name="donate_type" class="form-input" onchange="updateDonateHint()">
|
||||
<option value="paypal" <?= $settings['donate_type'] === 'paypal' ? 'selected' : '' ?>>PayPal (paypal.me)</option>
|
||||
<option value="venmo" <?= $settings['donate_type'] === 'venmo' ? 'selected' : '' ?>>Venmo</option>
|
||||
<option value="buymeacoffee" <?= $settings['donate_type'] === 'buymeacoffee' ? 'selected' : '' ?>>Buy Me a Coffee</option>
|
||||
<option value="custom" <?= $settings['donate_type'] === 'custom' ? 'selected' : '' ?>>Custom URL</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="donate_handle" id="donate_handle_label">Handle / URL</label>
|
||||
<input type="text" id="donate_handle" name="donate_handle"
|
||||
value="<?= htmlspecialchars($settings['donate_handle']) ?>"
|
||||
placeholder="">
|
||||
<p class="help-text" id="donate_hint"></p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="donate_label">Button Label <span style="font-weight:400;color:#6b7280">(optional)</span></label>
|
||||
<input type="text" id="donate_label" name="donate_label"
|
||||
value="<?= htmlspecialchars($settings['donate_label']) ?>"
|
||||
placeholder="Leave blank for default">
|
||||
<p class="help-text">Overrides the default label for the selected service.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">Save Settings</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="settings-section" style="margin-top:24px">
|
||||
<h3>Test Email</h3>
|
||||
<p class="help-text">Send a test email to <strong><?= htmlspecialchars($user['email']) ?></strong> to verify your SMTP settings.</p>
|
||||
<form method="post">
|
||||
<input type="hidden" name="action" value="test_email">
|
||||
<button type="submit" class="btn btn-secondary">Send Test Email</button>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var donateHints = {
|
||||
paypal: { label: 'PayPal.me username', hint: 'Enter your PayPal.me handle, e.g. YourName — link becomes paypal.me/YourName' },
|
||||
venmo: { label: 'Venmo username', hint: 'Enter your Venmo handle without @, e.g. YourName — link becomes venmo.com/u/YourName' },
|
||||
buymeacoffee: { label: 'Buy Me a Coffee username', hint: 'Enter your buymeacoffee.com handle, e.g. YourName' },
|
||||
custom: { label: 'Full URL', hint: 'Enter the complete URL, e.g. https://example.com/donate' },
|
||||
};
|
||||
|
||||
function updateDonateHint() {
|
||||
var type = document.getElementById('donate_type').value;
|
||||
var info = donateHints[type] || donateHints.custom;
|
||||
document.getElementById('donate_handle_label').textContent = info.label;
|
||||
document.getElementById('donate_hint').textContent = info.hint;
|
||||
}
|
||||
updateDonateHint();
|
||||
|
||||
function togglePass() {
|
||||
var inp = document.getElementById('smtp_pass');
|
||||
var btn = inp.nextElementSibling;
|
||||
if (inp.type === 'password') {
|
||||
inp.type = 'text';
|
||||
btn.textContent = 'Hide';
|
||||
} else {
|
||||
inp.type = 'password';
|
||||
btn.textContent = 'Show';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+250
@@ -0,0 +1,250 @@
|
||||
<?php
|
||||
/**
|
||||
* admin/setup.php — Create or edit a rosary session.
|
||||
*/
|
||||
require_once __DIR__ . '/../config/db.php';
|
||||
require_once __DIR__ . '/../includes/auth.php';
|
||||
|
||||
require_auth();
|
||||
|
||||
$pdo = get_pdo();
|
||||
$user = current_user();
|
||||
$uid = (int)$user['id'];
|
||||
$site_name = get_setting('site_name', APP_NAME);
|
||||
|
||||
// Load existing session for editing
|
||||
$session = null;
|
||||
if (isset($_GET['id'])) {
|
||||
$stmt = $pdo->prepare('SELECT * FROM sessions WHERE id = ?');
|
||||
$stmt->execute([(int)$_GET['id']]);
|
||||
$session = $stmt->fetch();
|
||||
if (!$session) {
|
||||
header('Location: ' . BASE_URL . '/admin/');
|
||||
exit;
|
||||
}
|
||||
// Ownership check: must own or be admin
|
||||
if (!has_role('admin') && (int)$session['user_id'] !== $uid) {
|
||||
header('Location: ' . BASE_URL . '/admin/');
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// Creating new session? Check rosary limit
|
||||
$limit_error = '';
|
||||
if (!$session && !can_create_rosary($uid, $user['rosary_limit'])) {
|
||||
$limit = $user['rosary_limit'];
|
||||
$limit_error = "You have reached your rosary limit ({$limit}). Please contact an administrator to increase your limit.";
|
||||
}
|
||||
|
||||
$page_title = $session ? 'Edit Session' : 'New Session';
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="icon" type="image/svg+xml" href="<?= BASE_URL ?>/favicon.svg">
|
||||
<title><?= $page_title ?> — <?= htmlspecialchars($site_name) ?></title>
|
||||
<link rel="stylesheet" href="<?= BASE_URL ?>/assets/css/setup.css">
|
||||
<script>var BASE_URL = '<?= BASE_URL ?>';</script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="admin-container">
|
||||
<header class="admin-header">
|
||||
<h1>✝ <?= htmlspecialchars($site_name) ?></h1>
|
||||
<div class="header-actions">
|
||||
<a href="<?= BASE_URL ?>/" class="btn btn-ghost" style="font-size:13px">← View Site</a>
|
||||
<?php if (has_role('admin')): ?>
|
||||
<a href="<?= BASE_URL ?>/admin/users.php" class="btn btn-ghost">Users</a>
|
||||
<?php endif; ?>
|
||||
<?php if (has_role('superadmin')): ?>
|
||||
<a href="<?= BASE_URL ?>/admin/settings.php" class="btn btn-ghost">Settings</a>
|
||||
<?php endif; ?>
|
||||
<a href="<?= BASE_URL ?>/admin/profile.php" class="btn btn-ghost"><?= htmlspecialchars($user['display_name'] ?: $user['username']) ?></a>
|
||||
<a href="<?= BASE_URL ?>/admin/" class="btn btn-ghost">← Back</a>
|
||||
<a href="<?= BASE_URL ?>/logout" class="btn btn-ghost">Logout</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<h2><?= $page_title ?></h2>
|
||||
|
||||
<?php if ($limit_error): ?>
|
||||
<div class="alert alert-error"><?= htmlspecialchars($limit_error) ?></div>
|
||||
<?php else: ?>
|
||||
|
||||
<div id="form-message" class="alert" style="display:none"></div>
|
||||
|
||||
<form id="session-form" novalidate>
|
||||
<?php if ($session): ?>
|
||||
<input type="hidden" name="id" value="<?= (int)$session['id'] ?>">
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Session Name -->
|
||||
<div class="form-group">
|
||||
<label for="name">Session Label <span class="required">*</span></label>
|
||||
<input type="text" id="name" name="name"
|
||||
placeholder="e.g. Medy"
|
||||
value="<?= htmlspecialchars($session['name'] ?? '') ?>"
|
||||
required>
|
||||
<p class="help-text" id="name-help">
|
||||
For novena: enter just the name (e.g. "Medy") — sessions will be created as
|
||||
"Medy — Day 1" through "Medy — Day 9".
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Occasion -->
|
||||
<div class="form-group">
|
||||
<label for="occasion">Occasion <span class="required">*</span></label>
|
||||
<select id="occasion" name="occasion" required>
|
||||
<option value="">— Select —</option>
|
||||
<option value="novena_deceased"
|
||||
<?= ($session['occasion'] ?? '') === 'novena_deceased' ? 'selected' : '' ?>>
|
||||
Novena for Deceased
|
||||
</option>
|
||||
<option value="divine_mercy_novena"
|
||||
<?= ($session['occasion'] ?? '') === 'divine_mercy_novena' ? 'selected' : '' ?>>
|
||||
Divine Mercy Novena
|
||||
</option>
|
||||
<option value="general_rosary"
|
||||
<?= ($session['occasion'] ?? '') === 'general_rosary' ? 'selected' : '' ?>>
|
||||
General Rosary
|
||||
</option>
|
||||
<option value="memorial"
|
||||
<?= ($session['occasion'] ?? '') === 'memorial' ? 'selected' : '' ?>>
|
||||
Memorial / Month's Mind
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Mystery Set (non-novena) -->
|
||||
<div class="form-group" id="field-mystery_set_standard">
|
||||
<label for="mystery_set">Mystery Set <span class="required">*</span></label>
|
||||
<select id="mystery_set" name="mystery_set" required>
|
||||
<option value="">— Select —</option>
|
||||
<option value="sorrowful"
|
||||
<?= ($session['mystery_set'] ?? '') === 'sorrowful' ? 'selected' : '' ?>>
|
||||
Sorrowful Mysteries
|
||||
</option>
|
||||
<option value="joyful"
|
||||
<?= ($session['mystery_set'] ?? '') === 'joyful' ? 'selected' : '' ?>>
|
||||
Joyful Mysteries
|
||||
</option>
|
||||
<option value="glorious"
|
||||
<?= ($session['mystery_set'] ?? '') === 'glorious' ? 'selected' : '' ?>>
|
||||
Glorious Mysteries
|
||||
</option>
|
||||
<option value="luminous"
|
||||
<?= ($session['mystery_set'] ?? '') === 'luminous' ? 'selected' : '' ?>>
|
||||
Luminous Mysteries
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Novena Mystery Mode (only shown for novena_deceased) -->
|
||||
<div class="form-group conditional-field" id="field-novena_mystery_mode" style="display:none">
|
||||
<label for="novena_mystery_mode">Mysteries for this Novena <span class="required">*</span></label>
|
||||
<select id="novena_mystery_mode" name="novena_mystery_mode">
|
||||
<option value="all_sorrowful"
|
||||
<?= ($session['mystery_set'] ?? '') === 'sorrowful' || ($session['mystery_set'] ?? '') === 'all_sorrowful' ? 'selected' : '' ?>>
|
||||
All Sorrowful (traditional for deceased)
|
||||
</option>
|
||||
<option value="by_day_of_week"
|
||||
<?= ($session['mystery_set'] ?? '') === 'by_day_of_week' ? 'selected' : '' ?>>
|
||||
By Day of Week — auto-selected at presentation time
|
||||
</option>
|
||||
</select>
|
||||
<p class="help-text">
|
||||
Day-of-week schedule: Sun & Wed = Glorious · Mon & Sat = Joyful ·
|
||||
Tue & Fri = Sorrowful · Thu = Luminous
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Novena Day -->
|
||||
<div class="form-group conditional-field" id="field-novena_day" style="display:none">
|
||||
<?php if ($session): ?>
|
||||
<label>Novena Day</label>
|
||||
<div class="form-static">Day <?= (int)($session['novena_day'] ?? 1) ?> of 9</div>
|
||||
<?php else: ?>
|
||||
<div class="novena-auto-notice">
|
||||
✓ Will automatically create <strong>Day 1 through Day 9</strong>.
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<!-- Subject Name (conditional) -->
|
||||
<div class="form-group conditional-field" id="field-subject_name" style="display:none">
|
||||
<label for="subject_name">Name of Deceased / Honoree</label>
|
||||
<input type="text" id="subject_name" name="subject_name"
|
||||
placeholder="e.g. Maria Santos"
|
||||
value="<?= htmlspecialchars($session['subject_name'] ?? '') ?>">
|
||||
</div>
|
||||
|
||||
<!-- Pronoun (conditional) -->
|
||||
<div class="form-group conditional-field" id="field-subject_pronoun" style="display:none">
|
||||
<label for="subject_pronoun">Pronoun</label>
|
||||
<select id="subject_pronoun" name="subject_pronoun">
|
||||
<option value="he"
|
||||
<?= ($session['subject_pronoun'] ?? 'he') === 'he' ? 'selected' : '' ?>>
|
||||
He / Him / His
|
||||
</option>
|
||||
<option value="she"
|
||||
<?= ($session['subject_pronoun'] ?? '') === 'she' ? 'selected' : '' ?>>
|
||||
She / Her / Her
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Dates (conditional) -->
|
||||
<div class="form-group conditional-field" id="field-subject_dates" style="display:none">
|
||||
<label for="subject_dates">Life Dates</label>
|
||||
<input type="text" id="subject_dates" name="subject_dates"
|
||||
placeholder="e.g. Aug 12, 1949 – July 25, 2025"
|
||||
value="<?= htmlspecialchars($session['subject_dates'] ?? '') ?>">
|
||||
</div>
|
||||
|
||||
<!-- Photo -->
|
||||
<div class="form-group" id="field-photo">
|
||||
<label for="photo">Photo (optional)</label>
|
||||
<?php if (!empty($session['photo_path'])): ?>
|
||||
<div class="photo-preview">
|
||||
<img src="<?= htmlspecialchars('/' . ltrim($session['photo_path'], '/')) ?>"
|
||||
alt="Current photo" style="max-height:120px">
|
||||
<p class="help-text">Current photo. Upload a new one to replace it.</p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<input type="file" id="photo" name="photo" accept="image/*">
|
||||
<p class="help-text">JPEG, PNG, WebP — max 5 MB. Recommended: square or landscape, at least 800 × 600 px. Shown on the home page card and cover slide.</p>
|
||||
<input type="hidden" id="photo_path" name="photo_path"
|
||||
value="<?= htmlspecialchars($session['photo_path'] ?? '') ?>">
|
||||
<div id="upload-status" style="display:none" class="upload-status"></div>
|
||||
</div>
|
||||
|
||||
<!-- Public toggle -->
|
||||
<div class="form-group">
|
||||
<label style="display:flex;align-items:center;gap:10px;cursor:pointer">
|
||||
<input type="checkbox" name="is_public" value="1"
|
||||
<?= (!$session || $session['is_public']) ? 'checked' : '' ?>
|
||||
style="width:18px;height:18px">
|
||||
<span>Make this session public (visible on your profile and the home page)</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary btn-large" id="submit-btn"
|
||||
data-label-default="<?= $session ? 'Save Changes' : 'Create Session' ?>"
|
||||
data-label-novena-deceased="<?= $session ? 'Save Changes' : 'Create 9-Day Novena' ?>"
|
||||
data-label-divine-mercy="<?= $session ? 'Save Changes' : 'Create Divine Mercy Novena' ?>">
|
||||
<?= $session ? 'Save Changes' : 'Create Session' ?>
|
||||
</button>
|
||||
<a href="<?= BASE_URL ?>/admin/" class="btn btn-ghost">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<?php endif; ?>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="<?= BASE_URL ?>/assets/js/setup.js?v=5"></script>
|
||||
</body>
|
||||
</html>
|
||||
+425
@@ -0,0 +1,425 @@
|
||||
<?php
|
||||
/**
|
||||
* admin/users.php — User management. Requires admin role.
|
||||
*/
|
||||
require_once __DIR__ . '/../config/db.php';
|
||||
require_once __DIR__ . '/../includes/auth.php';
|
||||
require_once __DIR__ . '/../includes/mailer.php';
|
||||
|
||||
require_role('admin');
|
||||
|
||||
$pdo = get_pdo();
|
||||
$user = current_user();
|
||||
$uid = (int)$user['id'];
|
||||
$is_super = has_role('superadmin');
|
||||
$site_name = get_setting('site_name', APP_NAME);
|
||||
$messages = [];
|
||||
$errors = [];
|
||||
|
||||
// ── Handle actions ───────────────────────────────────────────────────────────
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$action = $_POST['action'] ?? '';
|
||||
|
||||
// ── Create user ──────────────────────────────────────────────────────────
|
||||
if ($action === 'create_user') {
|
||||
$new_username = trim($_POST['new_username'] ?? '');
|
||||
$new_display_name = trim($_POST['new_display_name'] ?? '');
|
||||
$new_email = trim($_POST['new_email'] ?? '');
|
||||
$new_password = $_POST['new_password'] ?? '';
|
||||
$new_role = $_POST['new_role'] ?? 'user';
|
||||
$new_limit = (int)($_POST['new_rosary_limit'] ?? 1);
|
||||
|
||||
// Validate role — admins can't set superadmin
|
||||
$allowed_roles = $is_super ? ['user','superuser','admin','superadmin'] : ['user','superuser'];
|
||||
if (!in_array($new_role, $allowed_roles, true)) $new_role = 'user';
|
||||
|
||||
if (!preg_match('/^[a-zA-Z0-9_]{3,30}$/', $new_username)) {
|
||||
$errors[] = 'Invalid username.';
|
||||
} elseif (!filter_var($new_email, FILTER_VALIDATE_EMAIL)) {
|
||||
$errors[] = 'Invalid email.';
|
||||
} elseif (strlen($new_password) < 8) {
|
||||
$errors[] = 'Password must be at least 8 characters.';
|
||||
} else {
|
||||
$chk = $pdo->prepare('SELECT id FROM users WHERE username=? OR email=?');
|
||||
$chk->execute([$new_username, $new_email]);
|
||||
if ($chk->fetch()) {
|
||||
$errors[] = 'Username or email already in use.';
|
||||
} else {
|
||||
$hash = password_hash($new_password, PASSWORD_BCRYPT);
|
||||
$pdo->prepare("
|
||||
INSERT INTO users (username,email,password_hash,display_name,role,rosary_limit,email_confirmed)
|
||||
VALUES (?,?,?,?,?,?,1)
|
||||
")->execute([$new_username, $new_email, $hash, $new_display_name ?: $new_username, $new_role, $new_limit]);
|
||||
$messages[] = "User '{$new_username}' created.";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Update user ──────────────────────────────────────────────────────────
|
||||
if ($action === 'update_user') {
|
||||
$target_id = (int)($_POST['target_id'] ?? 0);
|
||||
$upd_display = trim($_POST['upd_display_name'] ?? '');
|
||||
$upd_email = trim($_POST['upd_email'] ?? '');
|
||||
$upd_role = $_POST['upd_role'] ?? '';
|
||||
$upd_limit = (int)($_POST['upd_rosary_limit'] ?? 1);
|
||||
|
||||
// Fetch target to check if superadmin
|
||||
$tgt = $pdo->prepare('SELECT * FROM users WHERE id=?');
|
||||
$tgt->execute([$target_id]);
|
||||
$tgt = $tgt->fetch();
|
||||
|
||||
if (!$tgt) {
|
||||
$errors[] = 'User not found.';
|
||||
} else {
|
||||
// Role protection
|
||||
$allowed_roles = $is_super ? ['user','superuser','admin','superadmin'] : ['user','superuser'];
|
||||
if (!in_array($upd_role, $allowed_roles, true)) $upd_role = $tgt['role'];
|
||||
// Non-superadmin cannot change superadmin's role
|
||||
if (!$is_super && $tgt['role'] === 'superadmin') {
|
||||
$errors[] = 'Cannot modify a superadmin account.';
|
||||
} else {
|
||||
if (!filter_var($upd_email, FILTER_VALIDATE_EMAIL)) {
|
||||
$errors[] = 'Invalid email.';
|
||||
} else {
|
||||
$chk = $pdo->prepare('SELECT id FROM users WHERE email=? AND id!=?');
|
||||
$chk->execute([$upd_email, $target_id]);
|
||||
if ($chk->fetch()) {
|
||||
$errors[] = 'Email already in use by another account.';
|
||||
} else {
|
||||
$pdo->prepare('UPDATE users SET display_name=?,email=?,role=?,rosary_limit=? WHERE id=?')
|
||||
->execute([$upd_display, $upd_email, $upd_role, $upd_limit, $target_id]);
|
||||
$messages[] = "User '{$tgt['username']}' updated.";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Reset password ───────────────────────────────────────────────────────
|
||||
if ($action === 'reset_password') {
|
||||
$target_id = (int)($_POST['target_id'] ?? 0);
|
||||
$new_pass = $_POST['new_pass'] ?? '';
|
||||
|
||||
if (strlen($new_pass) < 8) {
|
||||
$errors[] = 'New password must be at least 8 characters.';
|
||||
} else {
|
||||
$hash = password_hash($new_pass, PASSWORD_BCRYPT);
|
||||
$pdo->prepare('UPDATE users SET password_hash=? WHERE id=?')->execute([$hash, $target_id]);
|
||||
$messages[] = 'Password reset successfully.';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Resend confirmation email ─────────────────────────────────────────────
|
||||
if ($action === 'resend_confirmation') {
|
||||
$target_id = (int)($_POST['target_id'] ?? 0);
|
||||
$tgt = $pdo->prepare('SELECT * FROM users WHERE id = ? AND email_confirmed = 0');
|
||||
$tgt->execute([$target_id]);
|
||||
$tgt = $tgt->fetch();
|
||||
|
||||
if (!$tgt) {
|
||||
$errors[] = 'User not found or already confirmed.';
|
||||
} else {
|
||||
$smtp_host = get_setting('smtp_host');
|
||||
if ($smtp_host === '') {
|
||||
// No SMTP — confirm directly
|
||||
$pdo->prepare('UPDATE users SET email_confirmed = 1, confirm_token = NULL WHERE id = ?')
|
||||
->execute([$target_id]);
|
||||
$messages[] = "No SMTP configured — {$tgt['username']} has been confirmed directly.";
|
||||
} else {
|
||||
$token = bin2hex(random_bytes(32));
|
||||
$site_url = rtrim(get_setting('site_url'), '/');
|
||||
$site_name = get_setting('site_name', APP_NAME);
|
||||
$link = $site_url . '/confirm?token=' . urlencode($token);
|
||||
$disp = $tgt['display_name'] ?: $tgt['username'];
|
||||
|
||||
$pdo->prepare('UPDATE users SET confirm_token = ? WHERE id = ?')
|
||||
->execute([$token, $target_id]);
|
||||
|
||||
$body_html = "
|
||||
<h2 style='margin-top:0;color:#1e3a5f'>Confirm your email</h2>
|
||||
<p>Hello, <strong>" . htmlspecialchars($disp) . "</strong>!</p>
|
||||
<p>An administrator has resent your confirmation email for {$site_name}. Click the button below to confirm your email address and activate your account:</p>
|
||||
<p style='text-align:center;margin:28px 0'>
|
||||
<a href='" . htmlspecialchars($link) . "' style='display:inline-block;background:#1e3a5f;color:#fff;padding:12px 28px;border-radius:6px;text-decoration:none;font-weight:600'>Confirm Email</a>
|
||||
</p>
|
||||
<p style='color:#6b7280;font-size:13px'>Or copy this link: " . htmlspecialchars($link) . "</p>
|
||||
<p style='color:#6b7280;font-size:13px'>If you did not register, ignore this email.</p>
|
||||
";
|
||||
$html = email_template('Confirm your email — ' . $site_name, $body_html);
|
||||
$ok = send_email($tgt['email'], $disp, 'Confirm your email — ' . $site_name, $html);
|
||||
|
||||
if ($ok) {
|
||||
$messages[] = "Confirmation email resent to {$tgt['email']}.";
|
||||
} else {
|
||||
$errors[] = 'Failed to send email. Check your SMTP settings.';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Delete user ──────────────────────────────────────────────────────────
|
||||
if ($action === 'delete_user') {
|
||||
$target_id = (int)($_POST['target_id'] ?? 0);
|
||||
if ($target_id === $uid) {
|
||||
$errors[] = 'You cannot delete your own account.';
|
||||
} else {
|
||||
$tgt = $pdo->prepare('SELECT role,username FROM users WHERE id=?');
|
||||
$tgt->execute([$target_id]);
|
||||
$tgt = $tgt->fetch();
|
||||
if ($tgt && $tgt['role'] === 'superadmin' && !$is_super) {
|
||||
$errors[] = 'Cannot delete a superadmin account.';
|
||||
} elseif ($tgt) {
|
||||
$pdo->prepare('DELETE FROM users WHERE id=?')->execute([$target_id]);
|
||||
$messages[] = "User '{$tgt['username']}' deleted.";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Load all users with rosary counts ────────────────────────────────────────
|
||||
$users = $pdo->query("
|
||||
SELECT u.*,
|
||||
(SELECT COUNT(*) FROM sessions WHERE user_id=u.id AND occasion != 'novena_deceased') +
|
||||
(SELECT COUNT(*) FROM novena_groups WHERE user_id=u.id)
|
||||
AS rosary_count
|
||||
FROM users u
|
||||
ORDER BY u.created_at DESC
|
||||
")->fetchAll();
|
||||
|
||||
$role_labels = ['superadmin'=>'Superadmin','admin'=>'Admin','superuser'=>'Superuser','user'=>'User'];
|
||||
$role_colors = [
|
||||
'superadmin' => '#dc2626',
|
||||
'admin' => '#ea580c',
|
||||
'superuser' => '#2563eb',
|
||||
'user' => '#6b7280',
|
||||
];
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="icon" type="image/svg+xml" href="<?= BASE_URL ?>/favicon.svg">
|
||||
<title>Users — <?= htmlspecialchars($site_name) ?></title>
|
||||
<link rel="stylesheet" href="<?= BASE_URL ?>/assets/css/setup.css">
|
||||
<style>
|
||||
.role-badge{display:inline-block;padding:2px 10px;border-radius:99px;font-size:12px;font-weight:700;color:#fff}
|
||||
.edit-panel{display:none;background:#f9fafb;border:1px solid #e5e7eb;border-radius:8px;padding:20px;margin-top:12px}
|
||||
.edit-panel.open{display:block}
|
||||
.user-row td{vertical-align:top;padding:12px 10px}
|
||||
.mini-form{display:flex;flex-wrap:wrap;gap:12px;align-items:flex-end}
|
||||
.mini-form .form-group{margin:0;min-width:160px}
|
||||
.mini-form label{font-size:12px;font-weight:600;display:block;margin-bottom:4px;color:#374151}
|
||||
.mini-form input,.mini-form select{font-size:14px;padding:6px 10px;border:1px solid #d1d5db;border-radius:6px;width:100%}
|
||||
.create-panel{background:#fff;border:1px solid #e5e7eb;border-radius:8px;padding:24px;margin-bottom:28px;display:none}
|
||||
.create-panel.open{display:block}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="admin-container">
|
||||
|
||||
<header class="admin-header">
|
||||
<h1>✝ <?= htmlspecialchars($site_name) ?></h1>
|
||||
<div class="header-actions">
|
||||
<a href="<?= BASE_URL ?>/" class="btn btn-ghost" style="font-size:13px">← View Site</a>
|
||||
<a href="<?= BASE_URL ?>/admin/" class="btn btn-ghost">Dashboard</a>
|
||||
<?php if ($is_super): ?>
|
||||
<a href="<?= BASE_URL ?>/admin/settings.php" class="btn btn-ghost">Settings</a>
|
||||
<?php endif; ?>
|
||||
<a href="<?= BASE_URL ?>/admin/profile.php" class="btn btn-ghost"><?= htmlspecialchars($user['display_name'] ?: $user['username']) ?></a>
|
||||
<a href="<?= BASE_URL ?>/logout" class="btn btn-ghost">Logout</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<?php foreach ($messages as $m): ?>
|
||||
<div class="alert alert-success">✓ <?= htmlspecialchars($m) ?></div>
|
||||
<?php endforeach; ?>
|
||||
<?php foreach ($errors as $e): ?>
|
||||
<div class="alert alert-error"><?= htmlspecialchars($e) ?></div>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:20px">
|
||||
<h2 style="margin:0">Users (<?= count($users) ?>)</h2>
|
||||
<button class="btn btn-primary" onclick="toggleCreate()">+ Create User</button>
|
||||
</div>
|
||||
|
||||
<!-- Create User Panel -->
|
||||
<div class="create-panel" id="create-panel">
|
||||
<h3 style="margin:0 0 16px">Create New User</h3>
|
||||
<form method="post">
|
||||
<input type="hidden" name="action" value="create_user">
|
||||
<div class="mini-form">
|
||||
<div class="form-group">
|
||||
<label>Username *</label>
|
||||
<input type="text" name="new_username" pattern="[a-zA-Z0-9_]{3,30}" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Display Name</label>
|
||||
<input type="text" name="new_display_name" maxlength="100">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Email *</label>
|
||||
<input type="email" name="new_email" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Password * (min 8)</label>
|
||||
<input type="password" name="new_password" minlength="8" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Role</label>
|
||||
<select name="new_role">
|
||||
<option value="user">User</option>
|
||||
<option value="superuser">Superuser</option>
|
||||
<option value="admin">Admin</option>
|
||||
<?php if ($is_super): ?>
|
||||
<option value="superadmin">Superadmin</option>
|
||||
<?php endif; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Rosary Limit (-1 = unlimited)</label>
|
||||
<input type="number" name="new_rosary_limit" value="1" min="-1">
|
||||
</div>
|
||||
<div>
|
||||
<button type="submit" class="btn btn-primary">Create</button>
|
||||
<button type="button" class="btn btn-ghost" onclick="toggleCreate()">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Users Table -->
|
||||
<div class="sessions-table-wrap">
|
||||
<table class="sessions-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
<th>Display Name</th>
|
||||
<th>Email</th>
|
||||
<th>Role</th>
|
||||
<th>Limit</th>
|
||||
<th>Rosaries</th>
|
||||
<th>Joined</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($users as $u): ?>
|
||||
<tr class="user-row">
|
||||
<td>
|
||||
<strong><?= htmlspecialchars($u['username']) ?></strong>
|
||||
<?= !$u['email_confirmed'] ? '<br><span style="font-size:11px;color:#d97706">Unconfirmed</span>' : '' ?>
|
||||
</td>
|
||||
<td><?= htmlspecialchars($u['display_name'] ?? '') ?></td>
|
||||
<td style="font-size:13px"><?= htmlspecialchars($u['email']) ?></td>
|
||||
<td>
|
||||
<span class="role-badge" style="background:<?= $role_colors[$u['role']] ?? '#6b7280' ?>">
|
||||
<?= htmlspecialchars($role_labels[$u['role']] ?? $u['role']) ?>
|
||||
</span>
|
||||
</td>
|
||||
<td><?= $u['rosary_limit'] < 0 ? '∞' : (int)$u['rosary_limit'] ?></td>
|
||||
<td><?= (int)$u['rosary_count'] ?></td>
|
||||
<td style="font-size:12px"><?= date('M j, Y', strtotime($u['created_at'])) ?></td>
|
||||
<td class="actions">
|
||||
<?php $can_edit = $is_super || ($u['role'] !== 'superadmin' && $u['role'] !== 'admin'); ?>
|
||||
<?php if ($can_edit || (int)$u['id'] !== $uid): ?>
|
||||
<button class="btn btn-sm btn-secondary"
|
||||
onclick="toggleEdit(<?= $u['id'] ?>)">Edit</button>
|
||||
<?php endif; ?>
|
||||
<?php if (!$u['email_confirmed']): ?>
|
||||
<form method="post" style="display:inline">
|
||||
<input type="hidden" name="action" value="resend_confirmation">
|
||||
<input type="hidden" name="target_id" value="<?= $u['id'] ?>">
|
||||
<button type="submit" class="btn btn-sm"
|
||||
style="background:#d97706;color:#fff;border:none"
|
||||
title="Resend confirmation email to <?= htmlspecialchars($u['email']) ?>">
|
||||
Resend Email
|
||||
</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
<?php if ((int)$u['id'] !== $uid && ($is_super || $u['role'] !== 'superadmin')): ?>
|
||||
<form method="post" style="display:inline"
|
||||
onsubmit="return confirm('Delete user <?= htmlspecialchars(addslashes($u['username'])) ?>? Their sessions will remain.')">
|
||||
<input type="hidden" name="action" value="delete_user">
|
||||
<input type="hidden" name="target_id" value="<?= $u['id'] ?>">
|
||||
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Edit Panel -->
|
||||
<div class="edit-panel" id="edit-<?= $u['id'] ?>">
|
||||
<form method="post" style="margin-bottom:16px">
|
||||
<input type="hidden" name="action" value="update_user">
|
||||
<input type="hidden" name="target_id" value="<?= $u['id'] ?>">
|
||||
<div class="mini-form">
|
||||
<div class="form-group">
|
||||
<label>Display Name</label>
|
||||
<input type="text" name="upd_display_name" maxlength="100"
|
||||
value="<?= htmlspecialchars($u['display_name'] ?? '') ?>">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Email</label>
|
||||
<input type="email" name="upd_email" required
|
||||
value="<?= htmlspecialchars($u['email']) ?>">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Role</label>
|
||||
<select name="upd_role">
|
||||
<option value="user" <?= $u['role']==='user' ?'selected':'' ?>>User</option>
|
||||
<option value="superuser" <?= $u['role']==='superuser' ?'selected':'' ?>>Superuser</option>
|
||||
<option value="admin" <?= $u['role']==='admin' ?'selected':'' ?>>Admin</option>
|
||||
<?php if ($is_super): ?>
|
||||
<option value="superadmin" <?= $u['role']==='superadmin'?'selected':'' ?>>Superadmin</option>
|
||||
<?php endif; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Rosary Limit (-1=unlimited)</label>
|
||||
<input type="number" name="upd_rosary_limit" min="-1"
|
||||
value="<?= (int)$u['rosary_limit'] ?>">
|
||||
</div>
|
||||
<div>
|
||||
<button type="submit" class="btn btn-primary btn-sm">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form method="post">
|
||||
<input type="hidden" name="action" value="reset_password">
|
||||
<input type="hidden" name="target_id" value="<?= $u['id'] ?>">
|
||||
<div class="mini-form">
|
||||
<div class="form-group">
|
||||
<label>New Password (min 8)</label>
|
||||
<input type="password" name="new_pass" minlength="8" required>
|
||||
</div>
|
||||
<div>
|
||||
<button type="submit" class="btn btn-secondary btn-sm"
|
||||
onclick="return confirm('Reset this user\'s password?')">
|
||||
Reset Password
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function toggleCreate() {
|
||||
var p = document.getElementById('create-panel');
|
||||
p.classList.toggle('open');
|
||||
}
|
||||
function toggleEdit(id) {
|
||||
var p = document.getElementById('edit-' + id);
|
||||
p.classList.toggle('open');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user