Add Rosary Builder — custom prayer-sequence sessions

Superuser+ can now build a custom prayer sequence from scratch:
- Two-panel builder UI: step sequence (left) + searchable prayer library (right)
- 16 standard prayers seeded globally; users can create private custom prayers
- Admin can promote private prayers to global and manage the library
- Four attribution modes per step: Leader/All, Leader only, All together, None
- Optional subject name/pronoun for variable substitution in prayers
- Custom sessions fully presented via the existing presenter (auto-split works)
- migrate_v4.php creates custom_prayers + builder_steps tables

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-13 19:07:11 -07:00
parent 663fde3909
commit f3fa77da17
9 changed files with 2086 additions and 2 deletions
+264
View File
@@ -0,0 +1,264 @@
<?php
/**
* admin/builder.php — Rosary Builder
* Available to superuser, admin, superadmin.
* Create or edit a custom prayer-sequence session.
*/
require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/../includes/auth.php';
require_role('superuser');
$pdo = get_pdo();
$user = current_user();
$uid = (int)$user['id'];
$is_admin = has_role('admin');
$site_name = get_setting('site_name', APP_NAME);
// ── Load existing session for edit mode ──────────────────────────────────────
$session = null;
$edit_steps = [];
if (isset($_GET['id'])) {
$stmt = $pdo->prepare("SELECT * FROM sessions WHERE id = ? AND occasion = 'custom'");
$stmt->execute([(int)$_GET['id']]);
$session = $stmt->fetch();
if (!$session) {
header('Location: ' . BASE_URL . '/admin/');
exit;
}
if (!$is_admin && (int)$session['user_id'] !== $uid) {
header('Location: ' . BASE_URL . '/admin/');
exit;
}
// Load steps with prayer data
$step_stmt = $pdo->prepare("
SELECT bs.prayer_id, bs.attribution,
cp.name, cp.leader_text, cp.all_text, cp.is_global, cp.created_by,
IF(cp.is_global=1 AND u.role='superadmin','standard',
IF(cp.is_global=1,'global','mine')) AS source_tag
FROM builder_steps bs
JOIN custom_prayers cp ON cp.id = bs.prayer_id
LEFT JOIN users u ON u.id = cp.created_by
WHERE bs.session_id = ?
ORDER BY bs.step_order ASC
");
$step_stmt->execute([(int)$session['id']]);
$edit_steps = $step_stmt->fetchAll();
}
// ── Load prayer library ───────────────────────────────────────────────────────
$lib_stmt = $pdo->prepare("
SELECT cp.id, cp.name, cp.leader_text, cp.all_text, cp.is_global, cp.created_by,
IF(cp.is_global=1 AND u.role='superadmin','standard',
IF(cp.is_global=1,'global','mine')) AS source_tag
FROM custom_prayers cp
LEFT JOIN users u ON u.id = cp.created_by
WHERE cp.is_global = 1 OR cp.created_by = ?
ORDER BY (cp.is_global=1 AND u.role='superadmin') DESC, cp.name ASC
");
$lib_stmt->execute([$uid]);
$prayers_data = $lib_stmt->fetchAll();
$page_title = $session ? 'Edit: ' . htmlspecialchars($session['name']) : 'Rosary Builder';
?>
<!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">
<link rel="stylesheet" href="<?= BASE_URL ?>/assets/css/builder.css?v=1">
</head>
<body style="overflow:hidden">
<div class="admin-container" style="max-width:100%;padding:0">
<header class="admin-header" style="position:sticky;top:0;z-index:100;padding:0 24px;height:64px">
<h1 style="font-size:1rem">&#x271D; <?= htmlspecialchars($site_name) ?></h1>
<div class="header-actions">
<a href="<?= BASE_URL ?>/" class="btn btn-ghost" style="font-size:12px">&#x2190; View Site</a>
<?php if ($is_admin): ?>
<a href="<?= BASE_URL ?>/admin/prayers.php" class="btn btn-ghost">Prayer Library</a>
<?php endif; ?>
<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>
<div class="builder-wrap">
<!-- Toolbar -->
<div class="builder-toolbar">
<h2>&#x2712; <?= $session ? 'Edit Sequence' : 'Rosary Builder' ?></h2>
<div class="divider"></div>
<input type="text" id="session-name" class="session-name-input"
placeholder="Session label (required)"
value="<?= htmlspecialchars($session['name'] ?? '') ?>">
<div class="toolbar-right">
<label class="public-toggle">
<input type="checkbox" id="is-public"
<?= (!$session || $session['is_public']) ? 'checked' : '' ?>>
Public
</label>
<?php if ($session): ?>
<?php
$owner_stmt = $pdo->prepare("SELECT username FROM users WHERE id = ?");
$owner_stmt->execute([$session['user_id']]);
$owner_uname = $owner_stmt->fetchColumn() ?: '';
$present_url = BASE_URL . '/' . rawurlencode($owner_uname) . '/' . rawurlencode($session['slug'] ?? '');
?>
<a id="present-link" href="<?= htmlspecialchars($present_url) ?>"
target="_blank" class="btn btn-ghost" style="font-size:13px">
Present &#x2197;
</a>
<?php else: ?>
<a id="present-link" href="#" target="_blank"
class="btn btn-ghost" style="font-size:13px;display:none">
Present &#x2197;
</a>
<?php endif; ?>
<button id="btn-save" class="btn btn-primary">Save Session</button>
</div>
</div>
<!-- Message bar -->
<div id="builder-msg"></div>
<!-- Optional subject (for variable substitution in prayers like Eternal Rest) -->
<details class="builder-subject">
<summary>For a specific person (optional — enables {name}, {pronoun} substitutions)</summary>
<div class="builder-subject-fields">
<input type="text" id="subject-name"
placeholder="Full name, e.g. Maria Santos"
value="<?= htmlspecialchars($session['subject_name'] ?? '') ?>">
<select id="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>
<input type="text" id="subject-dates"
placeholder="Life dates, e.g. Aug 12, 1949 July 25, 2025"
style="min-width:280px"
value="<?= htmlspecialchars($session['subject_dates'] ?? '') ?>">
</div>
</details>
<!-- Two-panel body -->
<div class="builder-body">
<!-- Left: Sequence -->
<div class="builder-sequence">
<div class="panel-header">
<h3>Your Sequence</h3>
<span id="step-count-badge" class="step-count-badge">0 prayers</span>
</div>
<div id="step-list"></div>
</div>
<!-- Right: Prayer library -->
<div class="builder-library">
<div class="library-header">
<input type="search" id="prayer-search" class="library-search"
placeholder="&#x1F50D; Search prayers by name or text…">
<div class="library-tabs">
<button class="tab-btn active" data-tab="all">All</button>
<button class="tab-btn" data-tab="standard">Standard</button>
<button class="tab-btn" data-tab="mine">My Prayers</button>
</div>
</div>
<div id="prayer-list">
<div class="library-empty" style="grid-column:1/-1;padding:40px;text-align:center;color:var(--muted)">
Loading…
</div>
</div>
<div class="library-footer">
<button id="btn-create-prayer" class="btn btn-secondary">+ Create Custom Prayer</button>
</div>
</div>
</div><!-- /builder-body -->
</div><!-- /builder-wrap -->
</div><!-- /admin-container -->
<!-- ── Create / Edit Prayer Modal ─────────────────────────────── -->
<div id="prayer-modal" class="modal-overlay" hidden>
<div class="modal-box">
<h3 id="modal-title">New Custom Prayer</h3>
<div class="form-group">
<label for="modal-name">Prayer Name <span class="required">*</span></label>
<input type="text" id="modal-name" class="form-control"
placeholder="e.g. Opening Prayer, Special Intention…"
style="width:100%;padding:9px 12px;border:1px solid var(--border);border-radius:6px;font-family:var(--font);font-size:14px">
</div>
<div class="form-group">
<label>Attribution</label>
<div class="attr-radios">
<label class="attr-radio-label">
<input type="radio" name="modal-attr" value="leader_all" checked>
<span><strong>Leader / All</strong><br><small>Two voices — leader speaks, congregation responds</small></span>
</label>
<label class="attr-radio-label">
<input type="radio" name="modal-attr" value="leader_only">
<span><strong>Leader only</strong><br><small>Leader speaks the whole prayer</small></span>
</label>
<label class="attr-radio-label">
<input type="radio" name="modal-attr" value="all_only">
<span><strong>All together</strong><br><small>Everyone prays in unison</small></span>
</label>
<label class="attr-radio-label">
<input type="radio" name="modal-attr" value="none">
<span><strong>No attribution</strong><br><small>Plain text — no leader/all labels</small></span>
</label>
</div>
</div>
<div class="form-group" id="modal-leader-group">
<label for="modal-leader">Leader text</label>
<textarea id="modal-leader" class="modal-textarea" rows="5"
placeholder="What the leader says…"></textarea>
<p class="help-hint">Use line breaks to control pacing. Variables: {name}, {pronoun_obj}, {pronoun_poss}</p>
</div>
<div class="form-group" id="modal-all-group">
<label for="modal-all">All / Response text</label>
<textarea id="modal-all" class="modal-textarea" rows="3"
placeholder="What everyone says together…"></textarea>
</div>
<?php if ($is_admin): ?>
<div class="form-group" id="modal-global-group">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;font-weight:normal">
<input type="checkbox" id="modal-global" style="width:16px;height:16px">
Make this prayer available to all users (global library)
</label>
</div>
<?php else: ?>
<input type="hidden" id="modal-global">
<?php endif; ?>
<div class="modal-footer">
<button id="modal-cancel" class="btn btn-ghost">Cancel</button>
<button id="modal-save" class="btn btn-primary">Save Prayer</button>
</div>
</div>
</div>
<script>
var BASE_URL = '<?= BASE_URL ?>';
var IS_ADMIN = <?= $is_admin ? 'true' : 'false' ?>;
var EDIT_SESSION_ID = <?= $session ? (int)$session['id'] : 'null' ?>;
var PRAYERS_DATA = <?= json_encode(array_values($prayers_data)) ?>;
var EXISTING_STEPS = <?= json_encode(array_map(fn($s) => [
'prayer_id' => (int)$s['prayer_id'],
'attribution' => $s['attribution'],
], $edit_steps)) ?>;
</script>
<script src="<?= BASE_URL ?>/assets/js/builder.js?v=1"></script>
</body>
</html>
+15 -2
View File
@@ -94,6 +94,7 @@ $occasion_labels = [
'divine_mercy_novena' => 'Divine Mercy Novena',
'general_rosary' => 'General Rosary',
'memorial' => 'Memorial',
'custom' => 'Custom Sequence',
];
$mystery_labels = [
'sorrowful' => 'Sorrowful',
@@ -121,7 +122,11 @@ $novena_created = isset($_GET['novena_created']) ? (int)$_GET['novena_created']
<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>
<a href="<?= BASE_URL ?>/admin/audio.php" class="btn btn-ghost">Audio</a>
<?php endif; ?>
@@ -142,7 +147,12 @@ $novena_created = isset($_GET['novena_created']) ? (int)$_GET['novena_created']
<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 style="display:flex;gap:8px">
<?php if (has_role('superuser')): ?>
<a href="<?= BASE_URL ?>/admin/builder.php" class="btn btn-secondary">&#x2712; Build Custom</a>
<?php endif; ?>
<a href="<?= BASE_URL ?>/admin/setup.php" class="btn btn-primary">+ New Session</a>
</div>
</div>
<?php if (empty($all_rows)): ?>
@@ -224,7 +234,10 @@ $novena_created = isset($_GET['novena_created']) ? (int)$_GET['novena_created']
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'] ?>"
<?php $edit_url = ($row['occasion'] === 'custom')
? BASE_URL . '/admin/builder.php?id=' . $row['id']
: BASE_URL . '/admin/setup.php?id=' . $row['id']; ?>
<a href="<?= htmlspecialchars($edit_url) ?>"
class="btn btn-sm btn-secondary">Edit</a>
<form method="post" style="display:inline"
onsubmit="return confirm('Delete this session?')">
+226
View File
@@ -0,0 +1,226 @@
<?php
/**
* admin/prayers.php — Global prayer library management.
* Admin and superadmin only.
*/
require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/../includes/auth.php';
require_role('admin');
$pdo = get_pdo();
$user = current_user();
$uid = (int)$user['id'];
$site_name = get_setting('site_name', APP_NAME);
// Handle form actions
$msg = '';
$msg_type = 'success';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$action = $_POST['action'] ?? '';
if ($action === 'delete') {
$id = (int)($_POST['prayer_id'] ?? 0);
if ($id) {
// Check if in use
$in_use = (int)$pdo->prepare("SELECT COUNT(*) FROM builder_steps WHERE prayer_id = ?")
->execute([$id]) ? 0 : 0;
$st = $pdo->prepare("SELECT COUNT(*) FROM builder_steps WHERE prayer_id = ?");
$st->execute([$id]);
if ((int)$st->fetchColumn() > 0) {
$msg = 'Cannot delete: this prayer is used in one or more sessions.';
$msg_type = 'error';
} else {
$pdo->prepare("DELETE FROM custom_prayers WHERE id = ?")->execute([$id]);
$msg = 'Prayer deleted.';
}
}
}
if ($action === 'toggle_global') {
$id = (int)($_POST['prayer_id'] ?? 0);
$global = (int)($_POST['new_global'] ?? 0);
if ($id) {
$pdo->prepare("UPDATE custom_prayers SET is_global=? WHERE id=?")->execute([$global, $id]);
$msg = $global ? 'Prayer made global.' : 'Prayer set to private.';
}
}
}
// Load all prayers visible to admins
$prayers = $pdo->prepare("
SELECT cp.*,
u.username AS creator_username, u.display_name AS creator_display, u.role AS creator_role,
(SELECT COUNT(*) FROM builder_steps WHERE prayer_id = cp.id) AS use_count
FROM custom_prayers cp
LEFT JOIN users u ON u.id = cp.created_by
ORDER BY (cp.is_global=1 AND u.role='superadmin') DESC, cp.is_global DESC, cp.name ASC
");
$prayers->execute();
$prayers = $prayers->fetchAll();
$filter = $_GET['filter'] ?? 'all';
?>
<!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>Prayer Library — <?= htmlspecialchars($site_name) ?></title>
<link rel="stylesheet" href="<?= BASE_URL ?>/assets/css/setup.css">
<style>
.filter-tabs { display:flex; gap:8px; margin-bottom:20px; flex-wrap:wrap; }
.filter-tabs a { padding:6px 16px; border:1px solid var(--border); border-radius:20px; font-size:13px; text-decoration:none; color:var(--muted); }
.filter-tabs a.active { background:var(--primary); border-color:var(--primary); color:#fff; font-weight:500; }
.prayer-table { width:100%; border-collapse:collapse; }
.prayer-table th, .prayer-table td { padding:10px 12px; text-align:left; border-bottom:1px solid var(--border); font-size:13px; vertical-align:top; }
.prayer-table th { font-size:11px; text-transform:uppercase; letter-spacing:.05em; color:var(--muted); background:var(--bg); font-weight:600; }
.prayer-table tbody tr:hover { background:var(--bg); }
.prayer-preview { color:var(--muted); font-size:12px; margin-top:3px; }
.badge-std { background:#fef9c3; color:#854d0e; }
.badge-glob { background:#dcfce7; color:#166534; }
.badge-priv { background:#ede9fe; color:#6d28d9; }
.use-badge { background:#e0e7ff; color:#3730a3; padding:2px 8px; border-radius:4px; font-size:11px; font-weight:600; }
.action-form { display:inline; }
details.preview-detail { font-size:12px; color:var(--muted); margin-top:4px; }
details.preview-detail summary { cursor:pointer; color:var(--primary); }
details.preview-detail pre { white-space:pre-wrap; font-family:inherit; margin-top:6px; line-height:1.5; }
</style>
</head>
<body>
<div class="admin-container">
<header class="admin-header">
<h1>&#x271D; <?= htmlspecialchars($site_name) ?></h1>
<div class="header-actions">
<a href="<?= BASE_URL ?>/" class="btn btn-ghost" style="font-size:13px">&#x2190; View Site</a>
<a href="<?= BASE_URL ?>/admin/builder.php" class="btn btn-ghost">Builder</a>
<a href="<?= BASE_URL ?>/admin/users.php" class="btn btn-ghost">Users</a>
<?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/" class="btn btn-ghost">&#x2190; Dashboard</a>
</div>
</header>
<main>
<?php if ($msg): ?>
<div class="alert alert-<?= $msg_type === 'error' ? 'error' : 'success' ?>" style="margin-bottom:16px">
<?= htmlspecialchars($msg) ?>
</div>
<?php endif; ?>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:20px">
<div>
<h2 style="margin:0">Prayer Library</h2>
<p style="margin:4px 0 0;font-size:13px;color:var(--muted)">
Global prayers are available to all users in the Rosary Builder.
</p>
</div>
<a href="<?= BASE_URL ?>/admin/builder.php" class="btn btn-primary">+ Open Builder</a>
</div>
<!-- Filter tabs -->
<div class="filter-tabs">
<a href="?filter=all" class="<?= $filter==='all' ? 'active':'' ?>">All (<?= count($prayers) ?>)</a>
<a href="?filter=standard" class="<?= $filter==='standard' ? 'active':'' ?>">Standard (<?= count(array_filter($prayers, fn($p)=>$p['is_global']&&$p['creator_role']==='superadmin')) ?>)</a>
<a href="?filter=global" class="<?= $filter==='global' ? 'active':'' ?>">Global Custom (<?= count(array_filter($prayers, fn($p)=>$p['is_global']&&$p['creator_role']!=='superadmin')) ?>)</a>
<a href="?filter=private" class="<?= $filter==='private' ? 'active':'' ?>">Private (<?= count(array_filter($prayers, fn($p)=>!$p['is_global'])) ?>)</a>
</div>
<div class="sessions-table-wrap">
<table class="prayer-table">
<thead>
<tr>
<th style="width:30%">Name / Preview</th>
<th>Visibility</th>
<th>Created by</th>
<th>Used in</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<?php
$shown = array_filter($prayers, function($p) use ($filter) {
if ($filter === 'standard') return $p['is_global'] && $p['creator_role'] === 'superadmin';
if ($filter === 'global') return $p['is_global'] && $p['creator_role'] !== 'superadmin';
if ($filter === 'private') return !$p['is_global'];
return true;
});
foreach ($shown as $p):
$is_standard = $p['is_global'] && $p['creator_role'] === 'superadmin';
$badge_class = $is_standard ? 'badge-std' : ($p['is_global'] ? 'badge-glob' : 'badge-priv');
$badge_label = $is_standard ? 'Standard' : ($p['is_global'] ? 'Global' : 'Private');
$creator = $p['creator_display'] ?: $p['creator_username'] ?: '—';
?>
<tr>
<td>
<strong><?= htmlspecialchars($p['name']) ?></strong>
<details class="preview-detail">
<summary>Preview</summary>
<?php if ($p['leader_text']): ?>
<pre><strong>Leader:</strong> <?= htmlspecialchars(mb_substr($p['leader_text'], 0, 200)) ?><?= strlen($p['leader_text'])>200?'…':'' ?></pre>
<?php endif; ?>
<?php if ($p['all_text']): ?>
<pre><strong>All:</strong> <?= htmlspecialchars(mb_substr($p['all_text'], 0, 200)) ?><?= strlen($p['all_text'])>200?'…':'' ?></pre>
<?php endif; ?>
</details>
</td>
<td>
<span class="source-badge <?= $badge_class ?>" style="padding:3px 8px;border-radius:4px;font-size:11px;font-weight:600">
<?= $badge_label ?>
</span>
</td>
<td style="color:var(--muted)"><?= htmlspecialchars($creator) ?></td>
<td>
<?php if ($p['use_count'] > 0): ?>
<span class="use-badge"><?= $p['use_count'] ?> session<?= $p['use_count']!==1?'s':'' ?></span>
<?php else: ?>
<span style="color:var(--muted);font-size:12px">—</span>
<?php endif; ?>
</td>
<td class="actions">
<?php if (!$is_standard): ?>
<?php if ($p['is_global']): ?>
<form method="post" class="action-form">
<input type="hidden" name="action" value="toggle_global">
<input type="hidden" name="prayer_id" value="<?= $p['id'] ?>">
<input type="hidden" name="new_global" value="0">
<button class="btn btn-sm btn-ghost">Make Private</button>
</form>
<?php else: ?>
<form method="post" class="action-form">
<input type="hidden" name="action" value="toggle_global">
<input type="hidden" name="prayer_id" value="<?= $p['id'] ?>">
<input type="hidden" name="new_global" value="1">
<button class="btn btn-sm btn-secondary">Make Global</button>
</form>
<?php endif; ?>
<?php if ($p['use_count'] == 0): ?>
<form method="post" class="action-form"
onsubmit="return confirm('Delete &quot;<?= htmlspecialchars(addslashes($p['name'])) ?>&quot;?')">
<input type="hidden" name="action" value="delete">
<input type="hidden" name="prayer_id" value="<?= $p['id'] ?>">
<button class="btn btn-sm btn-danger">Delete</button>
</form>
<?php endif; ?>
<?php else: ?>
<span style="color:var(--muted);font-size:12px">Standard prayers cannot be deleted</span>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
<?php if (!$shown): ?>
<tr>
<td colspan="5" style="text-align:center;padding:40px;color:var(--muted)">No prayers in this category.</td>
</tr>
<?php endif; ?>
</tbody>
</table>
</div>
</main>
</div>
</body>
</html>
+139
View File
@@ -0,0 +1,139 @@
<?php
/**
* api/builder_session.php — Save or update a custom builder session.
* Requires superuser+.
*
* POST — create or update session + steps
* Body JSON: {
* id? : int (existing session ID for update)
* name : string
* is_public : bool
* subject_name? : string
* subject_pronoun? : 'he'|'she'
* subject_dates? : string
* steps : [{prayer_id, attribution}, ...]
* }
*/
require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/../includes/auth.php';
header('Content-Type: application/json');
function json_err(string $msg, int $code = 400): never {
http_response_code($code);
echo json_encode(['error' => $msg]);
exit;
}
require_auth();
if (!has_role('superuser')) json_err('Access denied', 403);
$pdo = get_pdo();
$user = current_user();
$uid = (int)$user['id'];
if ($_SERVER['REQUEST_METHOD'] !== 'POST') json_err('Method not allowed', 405);
$body = json_decode(file_get_contents('php://input'), true);
if (!$body) json_err('Invalid JSON');
$name = trim($body['name'] ?? '');
$is_public = (int)!empty($body['is_public']);
$subject_name = trim($body['subject_name'] ?? '');
$subject_pronoun = in_array($body['subject_pronoun'] ?? '', ['he','she']) ? $body['subject_pronoun'] : 'he';
$subject_dates = trim($body['subject_dates'] ?? '');
$steps = $body['steps'] ?? [];
$session_id = (int)($body['id'] ?? 0);
if ($name === '') json_err('Session name is required');
if (empty($steps)) json_err('Add at least one prayer to your sequence');
$valid_attributions = ['leader_all', 'leader_only', 'all_only', 'none'];
// Validate steps
foreach ($steps as $i => $step) {
$pid = (int)($step['prayer_id'] ?? 0);
$att = $step['attribution'] ?? 'leader_all';
if (!$pid) json_err("Step " . ($i + 1) . " is missing a prayer");
if (!in_array($att, $valid_attributions)) json_err("Invalid attribution on step " . ($i + 1));
}
// Verify all prayer_ids exist and are accessible
$prayer_ids = array_unique(array_column($steps, 'prayer_id'));
$in_placeholders = implode(',', array_fill(0, count($prayer_ids), '?'));
$valid_stmt = $pdo->prepare("
SELECT id FROM custom_prayers
WHERE id IN ($in_placeholders)
AND (is_global = 1 OR created_by = ?)
");
$valid_stmt->execute([...$prayer_ids, $uid]);
$valid_ids = array_column($valid_stmt->fetchAll(), 'id');
foreach ($prayer_ids as $pid) {
if (!in_array((string)$pid, array_map('strval', $valid_ids))) {
json_err("Prayer ID {$pid} not found or not accessible");
}
}
$pdo->beginTransaction();
try {
if ($session_id > 0) {
// Update existing session — verify ownership
$owner_stmt = $pdo->prepare("SELECT user_id FROM sessions WHERE id = ? AND occasion = 'custom'");
$owner_stmt->execute([$session_id]);
$existing = $owner_stmt->fetch();
if (!$existing) json_err('Session not found', 404);
if (!has_role('admin') && (int)$existing['user_id'] !== $uid) json_err('Access denied', 403);
$slug = unique_slug($name, $uid, 'sessions', $session_id);
$pdo->prepare("
UPDATE sessions
SET name=?, is_public=?, subject_name=?, subject_pronoun=?, subject_dates=?,
slug=?, updated_at=NOW()
WHERE id=?
")->execute([$name, $is_public, $subject_name ?: null, $subject_pronoun, $subject_dates ?: null, $slug, $session_id]);
// Replace all steps
$pdo->prepare("DELETE FROM builder_steps WHERE session_id = ?")->execute([$session_id]);
} else {
// Check rosary limit
if (!can_create_rosary($uid, (int)$user['rosary_limit'])) {
json_err('You have reached your rosary limit. Contact an administrator.');
}
$slug = unique_slug($name, $uid);
$pdo->prepare("
INSERT INTO sessions
(user_id, is_public, slug, name, occasion, mystery_set,
subject_name, subject_pronoun, subject_dates)
VALUES (?, ?, ?, ?, 'custom', 'custom', ?, ?, ?)
")->execute([$uid, $is_public, $slug, $name, $subject_name ?: null, $subject_pronoun, $subject_dates ?: null]);
$session_id = (int)$pdo->lastInsertId();
}
// Insert steps
$step_stmt = $pdo->prepare(
"INSERT INTO builder_steps (session_id, step_order, prayer_id, attribution)
VALUES (?, ?, ?, ?)"
);
foreach ($steps as $order => $step) {
$step_stmt->execute([$session_id, $order, (int)$step['prayer_id'], $step['attribution']]);
}
$pdo->commit();
// Return session URL
$user_row = $pdo->prepare("SELECT username FROM users WHERE id = ?");
$user_row->execute([$uid]);
$username = $user_row->fetchColumn();
$present_url = BASE_URL . '/' . rawurlencode($username) . '/' . rawurlencode($slug);
echo json_encode([
'saved' => true,
'session_id' => $session_id,
'slug' => $slug,
'present_url' => $present_url,
'edit_url' => BASE_URL . '/admin/builder.php?id=' . $session_id,
]);
} catch (Throwable $e) {
$pdo->rollBack();
json_err('Save failed: ' . $e->getMessage());
}
+181
View File
@@ -0,0 +1,181 @@
<?php
/**
* api/prayers_api.php — CRUD for custom_prayers.
* Requires superuser+.
*
* GET ?q=&source=all|standard|mine — list prayers
* POST — create prayer (body JSON)
* PUT ?id=X — update prayer (body JSON)
* DELETE ?id=X — delete prayer
*/
require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/../includes/auth.php';
header('Content-Type: application/json');
function json_err(string $msg, int $code = 400): never {
http_response_code($code);
echo json_encode(['error' => $msg]);
exit;
}
require_auth();
if (!has_role('superuser')) json_err('Access denied', 403);
$pdo = get_pdo();
$user = current_user();
$uid = (int)$user['id'];
$is_admin = has_role('admin');
$method = $_SERVER['REQUEST_METHOD'];
// ─────────────────────────────────────────────────
// GET — list prayers
// ─────────────────────────────────────────────────
if ($method === 'GET') {
$source = $_GET['source'] ?? 'all';
$q = trim($_GET['q'] ?? '');
$where = [];
$params = [];
if ($source === 'standard' || $source === 'mine') {
if ($source === 'standard') {
$where[] = 'cp.is_global = 1 AND u.role = \'superadmin\'';
} else {
$where[] = 'cp.created_by = ?';
$params[] = $uid;
}
} else {
// all: global prayers + own private prayers
$where[] = '(cp.is_global = 1 OR cp.created_by = ?)';
$params[] = $uid;
}
if ($q !== '') {
$like = '%' . $q . '%';
$where[] = '(cp.name LIKE ? OR cp.leader_text LIKE ? OR cp.all_text LIKE ?)';
$params[] = $like;
$params[] = $like;
$params[] = $like;
}
$sql = "
SELECT cp.id, cp.name, cp.leader_text, cp.all_text,
cp.is_global, cp.created_by,
u.role AS creator_role,
IF(cp.is_global=1 AND u.role='superadmin', 'standard',
IF(cp.is_global=1, 'global', 'mine')) AS source_tag
FROM custom_prayers cp
LEFT JOIN users u ON u.id = cp.created_by
WHERE " . implode(' AND ', $where) . "
ORDER BY (cp.is_global=1 AND u.role='superadmin') DESC, cp.name ASC
";
$st = $pdo->prepare($sql);
$st->execute($params);
echo json_encode($st->fetchAll());
exit;
}
// ─────────────────────────────────────────────────
// POST — create prayer
// ─────────────────────────────────────────────────
if ($method === 'POST') {
$body = json_decode(file_get_contents('php://input'), true);
if (!$body) json_err('Invalid JSON');
$name = trim($body['name'] ?? '');
$leader = trim($body['leader_text'] ?? '');
$all = trim($body['all_text'] ?? '');
$global = $is_admin ? (int)!empty($body['is_global']) : 0;
if ($name === '') json_err('Prayer name is required');
$st = $pdo->prepare(
"INSERT INTO custom_prayers (name, leader_text, all_text, is_global, created_by)
VALUES (?, ?, ?, ?, ?)"
);
$st->execute([$name, $leader, $all, $global, $uid]);
$new_id = (int)$pdo->lastInsertId();
$row = $pdo->prepare("
SELECT cp.id, cp.name, cp.leader_text, cp.all_text, cp.is_global, cp.created_by,
u.role AS creator_role,
IF(cp.is_global=1 AND u.role='superadmin', 'standard',
IF(cp.is_global=1, 'global', 'mine')) AS source_tag
FROM custom_prayers cp LEFT JOIN users u ON u.id = cp.created_by
WHERE cp.id = ?
");
$row->execute([$new_id]);
echo json_encode(['created' => true, 'prayer' => $row->fetch()]);
exit;
}
// ─────────────────────────────────────────────────
// PUT — update prayer
// ─────────────────────────────────────────────────
if ($method === 'PUT') {
$id = (int)($_GET['id'] ?? 0);
$body = json_decode(file_get_contents('php://input'), true);
if (!$id || !$body) json_err('Invalid request');
$st = $pdo->prepare("SELECT * FROM custom_prayers WHERE id = ?");
$st->execute([$id]);
$prayer = $st->fetch();
if (!$prayer) json_err('Prayer not found', 404);
$can_edit = $is_admin || (int)$prayer['created_by'] === $uid;
if (!$can_edit) json_err('Access denied', 403);
$name = trim($body['name'] ?? $prayer['name']);
$leader = trim($body['leader_text'] ?? $prayer['leader_text']);
$all = trim($body['all_text'] ?? $prayer['all_text']);
$global = $is_admin ? (int)!empty($body['is_global']) : (int)$prayer['is_global'];
if ($name === '') json_err('Prayer name is required');
$pdo->prepare(
"UPDATE custom_prayers SET name=?, leader_text=?, all_text=?, is_global=?, updated_at=NOW()
WHERE id=?"
)->execute([$name, $leader, $all, $global, $id]);
$row = $pdo->prepare("
SELECT cp.id, cp.name, cp.leader_text, cp.all_text, cp.is_global, cp.created_by,
u.role AS creator_role,
IF(cp.is_global=1 AND u.role='superadmin', 'standard',
IF(cp.is_global=1, 'global', 'mine')) AS source_tag
FROM custom_prayers cp LEFT JOIN users u ON u.id = cp.created_by
WHERE cp.id = ?
");
$row->execute([$id]);
echo json_encode(['updated' => true, 'prayer' => $row->fetch()]);
exit;
}
// ─────────────────────────────────────────────────
// DELETE — remove prayer
// ─────────────────────────────────────────────────
if ($method === 'DELETE') {
$id = (int)($_GET['id'] ?? 0);
if (!$id) json_err('Missing id');
$st = $pdo->prepare("SELECT * FROM custom_prayers WHERE id = ?");
$st->execute([$id]);
$prayer = $st->fetch();
if (!$prayer) json_err('Prayer not found', 404);
$can_delete = $is_admin || (int)$prayer['created_by'] === $uid;
if (!$can_delete) json_err('Access denied', 403);
// Prevent deleting if used in builder_steps
$st2 = $pdo->prepare("SELECT COUNT(*) FROM builder_steps WHERE prayer_id = ?");
$st2->execute([$id]);
if ((int)$st2->fetchColumn() > 0) {
json_err('Cannot delete: this prayer is used in one or more sessions. Remove it from those sessions first.');
}
$pdo->prepare("DELETE FROM custom_prayers WHERE id = ?")->execute([$id]);
echo json_encode(['deleted' => true]);
exit;
}
json_err('Method not allowed', 405);
+550
View File
@@ -0,0 +1,550 @@
/* ================================================================
builder.css — Rosary Builder page styles
Inherits design tokens from setup.css (loaded first)
================================================================ */
/* ── Page layout ─────────────────────────────────────────────── */
.builder-wrap {
display: flex;
flex-direction: column;
height: calc(100vh - 64px); /* below fixed admin header */
}
.builder-toolbar {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 24px;
border-bottom: 1px solid var(--border);
background: var(--bg-card);
flex-shrink: 0;
flex-wrap: wrap;
}
.builder-toolbar h2 {
font-size: 1.1rem;
font-weight: 600;
margin: 0;
flex-shrink: 0;
}
.builder-toolbar .divider {
width: 1px;
height: 24px;
background: var(--border);
flex-shrink: 0;
}
.builder-toolbar .session-name-input {
flex: 1;
min-width: 180px;
max-width: 340px;
padding: 7px 12px;
border: 1px solid var(--border);
border-radius: 6px;
font-size: 14px;
font-family: var(--font);
background: var(--bg);
}
.builder-toolbar .session-name-input:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(37,99,235,0.1);
}
.toolbar-right {
display: flex;
align-items: center;
gap: 8px;
margin-left: auto;
flex-shrink: 0;
}
.toolbar-right .public-toggle {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--muted);
cursor: pointer;
user-select: none;
}
/* ── Subject row (collapsible) ───────────────────────────────── */
.builder-subject {
background: var(--bg);
border-bottom: 1px solid var(--border);
padding: 0 24px;
flex-shrink: 0;
}
.builder-subject summary {
padding: 10px 0;
font-size: 13px;
color: var(--muted);
cursor: pointer;
list-style: none;
display: flex;
align-items: center;
gap: 6px;
}
.builder-subject summary::before {
content: '▶';
font-size: 10px;
transition: transform 0.15s;
}
.builder-subject[open] summary::before {
transform: rotate(90deg);
}
.builder-subject-fields {
display: flex;
gap: 10px;
padding-bottom: 12px;
flex-wrap: wrap;
}
.builder-subject-fields input,
.builder-subject-fields select {
padding: 7px 10px;
border: 1px solid var(--border);
border-radius: 6px;
font-size: 13px;
font-family: var(--font);
background: var(--bg-card);
}
.builder-subject-fields input:focus,
.builder-subject-fields select:focus {
outline: none;
border-color: var(--primary);
}
/* ── Two-panel body ──────────────────────────────────────────── */
.builder-body {
display: grid;
grid-template-columns: 380px 1fr;
flex: 1;
overflow: hidden;
}
/* ── Sequence panel (left) ───────────────────────────────────── */
.builder-sequence {
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--bg);
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid var(--border);
background: var(--bg-card);
flex-shrink: 0;
}
.panel-header h3 {
font-size: 0.9rem;
font-weight: 600;
margin: 0;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--muted);
}
.step-count-badge {
font-size: 12px;
background: var(--border);
color: var(--muted);
padding: 2px 8px;
border-radius: 20px;
}
.step-count-badge.has-steps {
background: #dbeafe;
color: var(--primary);
}
#step-list {
flex: 1;
overflow-y: auto;
padding: 10px;
}
.step-empty {
text-align: center;
padding: 48px 20px;
color: var(--muted);
font-size: 14px;
line-height: 1.6;
}
.step-empty svg {
opacity: 0.3;
margin-bottom: 12px;
}
/* ── Step card ───────────────────────────────────────────────── */
.step-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
margin-bottom: 8px;
display: grid;
grid-template-columns: 28px 1fr auto;
gap: 0;
overflow: hidden;
}
.step-num {
display: flex;
align-items: flex-start;
justify-content: center;
padding: 12px 0 0;
font-size: 11px;
font-weight: 700;
color: var(--muted);
background: var(--bg);
border-right: 1px solid var(--border);
}
.step-body {
padding: 10px 12px;
min-width: 0;
}
.step-name {
font-size: 13px;
font-weight: 600;
color: var(--text);
margin-bottom: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.step-preview {
font-size: 11px;
color: var(--muted);
line-height: 1.4;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 2px;
}
.step-preview.leader { color: #2563eb; }
.step-preview.response { color: #059669; }
.step-attribution-select {
margin-top: 6px;
padding: 3px 6px;
font-size: 11px;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg);
color: var(--text);
font-family: var(--font);
cursor: pointer;
width: 100%;
}
.step-actions {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
padding: 8px 6px;
border-left: 1px solid var(--border);
background: var(--bg);
}
.btn-icon {
background: none;
border: none;
cursor: pointer;
padding: 3px 5px;
border-radius: 4px;
color: var(--muted);
font-size: 13px;
line-height: 1;
transition: background 0.1s, color 0.1s;
}
.btn-icon:hover { background: var(--border); color: var(--text); }
.btn-icon.remove:hover { background: #fee2e2; color: var(--danger); }
.btn-icon:disabled { opacity: 0.3; cursor: default; }
.btn-icon:disabled:hover { background: none; color: var(--muted); }
/* ── Library panel (right) ───────────────────────────────────── */
.builder-library {
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--bg);
}
.library-header {
padding: 12px 16px;
border-bottom: 1px solid var(--border);
background: var(--bg-card);
flex-shrink: 0;
}
.library-search {
width: 100%;
padding: 8px 12px;
border: 1px solid var(--border);
border-radius: 6px;
font-size: 14px;
font-family: var(--font);
margin-bottom: 10px;
background: var(--bg);
}
.library-search:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(37,99,235,0.1);
}
.library-tabs {
display: flex;
gap: 4px;
}
.tab-btn {
padding: 5px 14px;
border: 1px solid var(--border);
border-radius: 20px;
background: none;
font-size: 12px;
cursor: pointer;
color: var(--muted);
font-family: var(--font);
transition: all 0.15s;
}
.tab-btn:hover { border-color: var(--primary); color: var(--primary); }
.tab-btn.active {
background: var(--primary);
border-color: var(--primary);
color: #fff;
font-weight: 500;
}
#prayer-list {
flex: 1;
overflow-y: auto;
padding: 12px 16px;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 10px;
align-content: start;
}
.library-empty {
grid-column: 1 / -1;
text-align: center;
padding: 40px;
color: var(--muted);
font-size: 14px;
}
/* ── Prayer card ─────────────────────────────────────────────── */
.prayer-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px;
display: flex;
flex-direction: column;
gap: 6px;
transition: border-color 0.15s, box-shadow 0.15s;
}
.prayer-card:hover {
border-color: #93c5fd;
box-shadow: 0 2px 8px rgba(37,99,235,0.08);
}
.prayer-card-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
}
.prayer-card-name {
font-size: 13px;
font-weight: 600;
color: var(--text);
flex: 1;
min-width: 0;
}
.source-badge {
font-size: 10px;
padding: 2px 6px;
border-radius: 4px;
font-weight: 500;
flex-shrink: 0;
white-space: nowrap;
}
.source-badge.standard { background: #fef9c3; color: #854d0e; }
.source-badge.global { background: #dcfce7; color: #166534; }
.source-badge.mine { background: #ede9fe; color: #6d28d9; }
.prayer-card-preview {
font-size: 11px;
color: var(--muted);
line-height: 1.45;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.prayer-card-footer {
display: flex;
gap: 6px;
margin-top: 2px;
}
/* ── Library footer ──────────────────────────────────────────── */
.library-footer {
padding: 12px 16px;
border-top: 1px solid var(--border);
background: var(--bg-card);
flex-shrink: 0;
}
/* ── Message bar (save feedback) ─────────────────────────────── */
#builder-msg {
padding: 0 24px;
flex-shrink: 0;
}
#builder-msg .alert {
margin: 10px 0 0;
}
/* ── Modal ───────────────────────────────────────────────────── */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.45);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.modal-box {
background: var(--bg-card);
border-radius: 12px;
padding: 28px;
width: 100%;
max-width: 540px;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 20px 60px rgba(0,0,0,0.2);
}
.modal-box h3 {
font-size: 1.1rem;
margin: 0 0 20px;
}
.attr-radios {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-top: 4px;
}
.attr-radio-label {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border: 1px solid var(--border);
border-radius: 6px;
cursor: pointer;
font-size: 13px;
transition: all 0.15s;
}
.attr-radio-label:hover { border-color: var(--primary); }
.attr-radio-label input[type=radio] { accent-color: var(--primary); }
.attr-radio-label.checked { border-color: var(--primary); background: #eff6ff; }
.modal-textarea {
width: 100%;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 6px;
font-family: var(--font);
font-size: 14px;
line-height: 1.5;
resize: vertical;
background: var(--bg);
}
.modal-textarea:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(37,99,235,0.1);
}
.help-hint {
font-size: 11px;
color: var(--muted);
margin-top: 4px;
}
.modal-footer {
display: flex;
gap: 8px;
margin-top: 20px;
justify-content: flex-end;
}
/* ── Responsive ──────────────────────────────────────────────── */
@media (max-width: 768px) {
.builder-body {
grid-template-columns: 1fr;
grid-template-rows: auto 1fr;
}
.builder-sequence {
border-right: none;
border-bottom: 1px solid var(--border);
max-height: 45vh;
}
#prayer-list {
grid-template-columns: 1fr;
}
}
+429
View File
@@ -0,0 +1,429 @@
/**
* builder.js — Rosary Builder interactive logic
* Depends on: window.PRAYERS_DATA (from PHP), window.EXISTING_STEPS (edit mode)
*/
(function () {
'use strict';
/* ─────────────────────────────────────────────────────────
State
───────────────────────────────────────────────────────── */
let STEPS = []; // [{prayer_id, attribution, _prayer: {...}}]
let PRAYERS = []; // full list from PHP
let activeTab = 'all';
let searchQuery = '';
let editingPrayerId = null; // null = create, int = edit
/* ─────────────────────────────────────────────────────────
Boot
───────────────────────────────────────────────────────── */
document.addEventListener('DOMContentLoaded', function () {
PRAYERS = window.PRAYERS_DATA || [];
// Populate existing steps when editing a session
if (window.EXISTING_STEPS && window.EXISTING_STEPS.length) {
window.EXISTING_STEPS.forEach(function (s) {
const prayer = PRAYERS.find(p => String(p.id) === String(s.prayer_id));
if (prayer) STEPS.push({ prayer_id: s.prayer_id, attribution: s.attribution, _prayer: prayer });
});
}
renderLibrary();
renderSequence();
bindEvents();
});
/* ─────────────────────────────────────────────────────────
Event bindings
───────────────────────────────────────────────────────── */
function bindEvents() {
// Search
document.getElementById('prayer-search').addEventListener('input', function () {
searchQuery = this.value.trim().toLowerCase();
renderLibrary();
});
// Tabs
document.querySelectorAll('.tab-btn').forEach(function (btn) {
btn.addEventListener('click', function () {
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
this.classList.add('active');
activeTab = this.dataset.tab;
renderLibrary();
});
});
// Create prayer button
document.getElementById('btn-create-prayer').addEventListener('click', function () {
openModal(null);
});
// Modal cancel
document.getElementById('modal-cancel').addEventListener('click', closeModal);
document.getElementById('prayer-modal').addEventListener('click', function (e) {
if (e.target === this) closeModal();
});
// Modal attribution radios
document.querySelectorAll('input[name="modal-attr"]').forEach(function (radio) {
radio.addEventListener('change', updateModalFields);
});
// Modal save
document.getElementById('modal-save').addEventListener('click', saveModalPrayer);
// Save session
document.getElementById('btn-save').addEventListener('click', saveSession);
}
/* ─────────────────────────────────────────────────────────
Library rendering
───────────────────────────────────────────────────────── */
function filteredPrayers() {
return PRAYERS.filter(function (p) {
if (activeTab === 'standard' && p.source_tag !== 'standard') return false;
if (activeTab === 'mine' && p.source_tag !== 'mine') return false;
if (searchQuery) {
const hay = (p.name + ' ' + (p.leader_text || '') + ' ' + (p.all_text || '')).toLowerCase();
return hay.includes(searchQuery);
}
return true;
});
}
function renderLibrary() {
const list = document.getElementById('prayer-list');
const visible = filteredPrayers();
if (!visible.length) {
list.innerHTML = '<div class="library-empty">No prayers found. Try a different search or tab.</div>';
return;
}
list.innerHTML = visible.map(function (p) {
const preview = (p.leader_text || p.all_text || '').replace(/\n/g, ' ').substring(0, 100);
const badgeClass = p.source_tag === 'standard' ? 'standard' : (p.source_tag === 'global' ? 'global' : 'mine');
const badgeLabel = p.source_tag === 'standard' ? 'Standard' : (p.source_tag === 'global' ? 'Global' : 'Mine');
const canEdit = p.source_tag === 'mine' || window.IS_ADMIN;
return `<div class="prayer-card">
<div class="prayer-card-top">
<span class="prayer-card-name">${esc(p.name)}</span>
<span class="source-badge ${badgeClass}">${badgeLabel}</span>
</div>
<div class="prayer-card-preview">${esc(preview)}${preview.length >= 100 ? '…' : ''}</div>
<div class="prayer-card-footer">
<button class="btn btn-sm btn-primary" onclick="builderAddPrayer(${p.id})">+ Add</button>
${canEdit ? `<button class="btn btn-sm btn-ghost" onclick="builderEditPrayer(${p.id})">Edit</button>` : ''}
</div>
</div>`;
}).join('');
}
/* ─────────────────────────────────────────────────────────
Sequence rendering
───────────────────────────────────────────────────────── */
function renderSequence() {
const list = document.getElementById('step-list');
const badge = document.getElementById('step-count-badge');
badge.textContent = STEPS.length + (STEPS.length === 1 ? ' prayer' : ' prayers');
badge.className = 'step-count-badge' + (STEPS.length ? ' has-steps' : '');
if (!STEPS.length) {
list.innerHTML = `<div class="step-empty">
<div style="font-size:32px;opacity:.25;margin-bottom:8px">✝</div>
Select a prayer from the library to begin building your sequence
</div>`;
return;
}
list.innerHTML = STEPS.map(function (step, i) {
const p = step._prayer;
const leaderPrev = (p.leader_text || '').replace(/\n/g, ' ').substring(0, 60);
const allPrev = (p.all_text || '').replace(/\n/g, ' ').substring(0, 60);
return `<div class="step-card">
<div class="step-num">${i + 1}</div>
<div class="step-body">
<div class="step-name">${esc(p.name)}</div>
${leaderPrev ? `<div class="step-preview leader">Leader: ${esc(leaderPrev)}${leaderPrev.length >= 60 ? '…' : ''}</div>` : ''}
${allPrev ? `<div class="step-preview response">All: ${esc(allPrev)}${allPrev.length >= 60 ? '…' : ''}</div>` : ''}
<select class="step-attribution-select" onchange="builderSetAttribution(${i}, this.value)">
<option value="leader_all" ${step.attribution==='leader_all' ? 'selected':''}>Leader / All</option>
<option value="leader_only" ${step.attribution==='leader_only' ? 'selected':''}>Leader only</option>
<option value="all_only" ${step.attribution==='all_only' ? 'selected':''}>All together (unison)</option>
<option value="none" ${step.attribution==='none' ? 'selected':''}>No attribution</option>
</select>
</div>
<div class="step-actions">
<button class="btn-icon" title="Move up" onclick="builderMove(${i}, -1)" ${i===0 ? 'disabled' : ''}>↑</button>
<button class="btn-icon" title="Move down" onclick="builderMove(${i}, 1)" ${i===STEPS.length-1 ? 'disabled' : ''}>↓</button>
<button class="btn-icon remove" title="Remove" onclick="builderRemove(${i})">✕</button>
</div>
</div>`;
}).join('');
}
/* ─────────────────────────────────────────────────────────
Step manipulation (exposed globally)
───────────────────────────────────────────────────────── */
window.builderAddPrayer = function (prayerId) {
const prayer = PRAYERS.find(p => String(p.id) === String(prayerId));
if (!prayer) return;
// Default attribution: leader_all if prayer has both parts, else leader_only / all_only / none
let attr = 'leader_all';
if (!prayer.leader_text && !prayer.all_text) attr = 'none';
else if (!prayer.all_text) attr = 'leader_only';
else if (!prayer.leader_text) attr = 'all_only';
STEPS.push({ prayer_id: prayerId, attribution: attr, _prayer: prayer });
renderSequence();
// Briefly highlight the new step
const list = document.getElementById('step-list');
const cards = list.querySelectorAll('.step-card');
const last = cards[cards.length - 1];
if (last) {
last.style.outline = '2px solid var(--primary)';
setTimeout(() => { last.style.outline = ''; }, 800);
last.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
};
window.builderRemove = function (index) {
STEPS.splice(index, 1);
renderSequence();
};
window.builderMove = function (index, dir) {
const target = index + dir;
if (target < 0 || target >= STEPS.length) return;
[STEPS[index], STEPS[target]] = [STEPS[target], STEPS[index]];
renderSequence();
};
window.builderSetAttribution = function (index, value) {
if (STEPS[index]) STEPS[index].attribution = value;
};
window.builderEditPrayer = function (prayerId) {
openModal(prayerId);
};
/* ─────────────────────────────────────────────────────────
Modal — create / edit prayer
───────────────────────────────────────────────────────── */
function openModal(prayerId) {
editingPrayerId = prayerId;
const modal = document.getElementById('prayer-modal');
const title = document.getElementById('modal-title');
// Reset form
document.getElementById('modal-name').value = '';
document.getElementById('modal-leader').value = '';
document.getElementById('modal-all').value = '';
document.getElementById('modal-global').checked = false;
document.querySelector('input[name="modal-attr"][value="leader_all"]').checked = true;
if (prayerId) {
const p = PRAYERS.find(pr => String(pr.id) === String(prayerId));
if (!p) return;
title.textContent = 'Edit Prayer';
document.getElementById('modal-name').value = p.name;
document.getElementById('modal-leader').value = p.leader_text || '';
document.getElementById('modal-all').value = p.all_text || '';
document.getElementById('modal-global').checked = !!p.is_global;
// Determine existing attribution for radio pre-selection
const hasLeader = !!(p.leader_text || '').trim();
const hasAll = !!(p.all_text || '').trim();
const attrVal = hasLeader && hasAll ? 'leader_all'
: hasLeader ? 'leader_only'
: hasAll ? 'all_only' : 'none';
const radio = document.querySelector(`input[name="modal-attr"][value="${attrVal}"]`);
if (radio) radio.checked = true;
} else {
title.textContent = 'New Custom Prayer';
}
updateModalFields();
updateAttrRadioStyles();
modal.hidden = false;
document.getElementById('modal-name').focus();
}
function closeModal() {
document.getElementById('prayer-modal').hidden = true;
editingPrayerId = null;
}
function updateModalFields() {
const attr = getSelectedAttr();
const leaderGroup = document.getElementById('modal-leader-group');
const allGroup = document.getElementById('modal-all-group');
leaderGroup.style.display = (attr === 'all_only') ? 'none' : '';
allGroup.style.display = (attr === 'leader_only' || attr === 'none') ? 'none' : '';
updateAttrRadioStyles();
}
function updateAttrRadioStyles() {
document.querySelectorAll('.attr-radio-label').forEach(function (label) {
const radio = label.querySelector('input[type=radio]');
label.classList.toggle('checked', radio && radio.checked);
});
}
function getSelectedAttr() {
const checked = document.querySelector('input[name="modal-attr"]:checked');
return checked ? checked.value : 'leader_all';
}
function saveModalPrayer() {
const name = document.getElementById('modal-name').value.trim();
const leader = document.getElementById('modal-leader').value.trim();
const all = document.getElementById('modal-all').value.trim();
const global = document.getElementById('modal-global').checked ? 1 : 0;
const attr = getSelectedAttr();
if (!name) {
document.getElementById('modal-name').focus();
showModalError('Prayer name is required.');
return;
}
// Set text fields based on attribution mode
const sendLeader = (attr !== 'all_only') ? leader : '';
const sendAll = (attr !== 'leader_only' && attr !== 'none') ? all : '';
const method = editingPrayerId ? 'PUT' : 'POST';
const url = BASE_URL + '/api/prayers_api.php' + (editingPrayerId ? '?id=' + editingPrayerId : '');
const saveBtn = document.getElementById('modal-save');
saveBtn.disabled = true;
saveBtn.textContent = 'Saving…';
fetch(url, {
method: method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, leader_text: sendLeader, all_text: sendAll, is_global: global }),
})
.then(r => r.json())
.then(function (data) {
if (data.error) { showModalError(data.error); return; }
const prayer = data.prayer;
if (editingPrayerId) {
// Update in local list
const idx = PRAYERS.findIndex(p => String(p.id) === String(prayer.id));
if (idx >= 0) PRAYERS[idx] = prayer;
// Also update any steps referencing this prayer
STEPS.forEach(function (step) {
if (String(step.prayer_id) === String(prayer.id)) step._prayer = prayer;
});
renderSequence();
} else {
PRAYERS.unshift(prayer);
}
renderLibrary();
closeModal();
})
.catch(function () { showModalError('Network error. Please try again.'); })
.finally(function () {
saveBtn.disabled = false;
saveBtn.textContent = 'Save Prayer';
});
}
function showModalError(msg) {
let el = document.getElementById('modal-error');
if (!el) {
el = document.createElement('div');
el.id = 'modal-error';
el.className = 'alert alert-error';
el.style.marginBottom = '12px';
document.querySelector('.modal-box').prepend(el);
}
el.textContent = msg;
el.style.display = '';
setTimeout(() => { el.style.display = 'none'; }, 5000);
}
/* ─────────────────────────────────────────────────────────
Save session
───────────────────────────────────────────────────────── */
function saveSession() {
const name = document.getElementById('session-name').value.trim();
if (!name) {
document.getElementById('session-name').focus();
showMsg('Please enter a session label.', 'error');
return;
}
if (!STEPS.length) {
showMsg('Add at least one prayer to your sequence before saving.', 'error');
return;
}
const payload = {
id: window.EDIT_SESSION_ID || 0,
name: name,
is_public: document.getElementById('is-public').checked,
subject_name: document.getElementById('subject-name').value.trim(),
subject_pronoun: document.getElementById('subject-pronoun').value,
subject_dates: document.getElementById('subject-dates').value.trim(),
steps: STEPS.map(s => ({ prayer_id: s.prayer_id, attribution: s.attribution })),
};
const btn = document.getElementById('btn-save');
btn.disabled = true;
btn.textContent = 'Saving…';
fetch(BASE_URL + '/api/builder_session.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
.then(r => r.json())
.then(function (data) {
if (data.error) { showMsg(data.error, 'error'); return; }
window.EDIT_SESSION_ID = data.session_id;
// Update page title & edit button
const presentLink = document.getElementById('present-link');
if (presentLink) {
presentLink.href = data.present_url;
presentLink.style.display = '';
}
document.title = name + ' — Rosary Builder';
// Update URL without reload
history.replaceState(null, '', BASE_URL + '/admin/builder.php?id=' + data.session_id);
showMsg('Session saved! <a href="' + data.present_url + '" target="_blank">Open presentation &rarr;</a>', 'success');
})
.catch(function () { showMsg('Network error. Please try again.', 'error'); })
.finally(function () {
btn.disabled = false;
btn.textContent = 'Save Session';
});
}
function showMsg(html, type) {
const wrap = document.getElementById('builder-msg');
wrap.innerHTML = `<div class="alert alert-${type}">${html}</div>`;
wrap.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
if (type === 'success') setTimeout(() => { wrap.innerHTML = ''; }, 6000);
}
/* ─────────────────────────────────────────────────────────
Utility
───────────────────────────────────────────────────────── */
function esc(str) {
if (!str) return '';
return str.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
})();
+100
View File
@@ -10,6 +10,21 @@
require_once __DIR__ . '/../data/prayers.php';
/**
* Fetch ordered builder steps (with prayer text) for a custom session.
*/
function get_builder_steps(PDO $pdo, int $session_id): array {
$st = $pdo->prepare("
SELECT bs.attribution, cp.name, cp.leader_text, cp.all_text
FROM builder_steps bs
JOIN custom_prayers cp ON cp.id = bs.prayer_id
WHERE bs.session_id = ?
ORDER BY bs.step_order ASC
");
$st->execute([$session_id]);
return $st->fetchAll();
}
/**
* Returns the slide array for one decade of the Chaplet of Divine Mercy.
*
@@ -65,6 +80,91 @@ function build_slides(array $session): array {
$slides = [];
// -----------------------------------------------------------------------
// Custom builder session — build directly from builder_steps
// -----------------------------------------------------------------------
if ($session['occasion'] === 'custom') {
$pdo = get_pdo();
$steps = get_builder_steps($pdo, (int)$session['id']);
// Cover slide
$cover_all = '';
if (!empty($session['subject_name'])) {
$cover_all = "For {$session['subject_name']}";
}
$slides[] = [
'id' => 'cover',
'type' => 'cover',
'section' => 'cover',
'title' => $session['name'],
'leader' => '',
'all' => $cover_all,
'bead' => null,
'bead_index' => null,
'photo_path' => $session['photo_path'] ?? null,
];
foreach ($steps as $i => $step) {
$attr = $step['attribution'];
$leader = '';
$all = '';
if ($attr === 'leader_all' || $attr === 'leader_only') {
$leader = $step['leader_text'] ?? '';
}
if ($attr === 'leader_all' || $attr === 'all_only') {
$all = $step['all_text'] ?? '';
}
if ($attr === 'none') {
// Show prayer text without attribution labels — put in leader field
$leader = ($step['leader_text'] ?: $step['all_text']) ?? '';
}
$slides[] = [
'id' => 'custom_' . $i,
'type' => 'prayer',
'section' => $step['name'],
'title' => $step['name'],
'leader' => $leader,
'all' => $all,
'bead' => null,
'bead_index' => null,
];
}
// Closing slide
$slides[] = [
'id' => 'closing_custom',
'type' => 'closing',
'section' => 'closing',
'title' => '',
'leader' => '',
'all' => "May the souls of the faithful departed,\nthrough the mercy of God, rest in peace.",
'bead' => null,
'bead_index' => null,
];
// Apply variable substitution and return early
$name = $session['subject_name'] ?? '';
$pronoun = $session['subject_pronoun'] ?? 'he';
$dates = $session['subject_dates'] ?? '';
$pronoun_obj = ($pronoun === 'she') ? 'her' : 'him';
$pronoun_poss = ($pronoun === 'she') ? 'her' : 'his';
$find = ['{name}', '{pronoun}', '{pronoun_obj}', '{pronoun_poss}', '{subject_dates}'];
$replace = [$name, $pronoun, $pronoun_obj, $pronoun_poss, $dates];
foreach ($slides as &$slide) {
$slide['title'] = str_replace($find, $replace, $slide['title'] ?? '');
$slide['leader'] = str_replace($find, $replace, $slide['leader'] ?? '');
$slide['all'] = str_replace($find, $replace, $slide['all'] ?? '');
}
unset($slide);
return $slides;
}
// -----------------------------------------------------------------------
// 1. Cover slide
// -----------------------------------------------------------------------
+182
View File
@@ -0,0 +1,182 @@
<?php
/**
* migrate_v4.php — Adds custom_prayers and builder_steps tables for Rosary Builder.
* Seeds standard prayers as global library entries.
* Run once in browser, then delete.
*/
require_once __DIR__ . '/config/db.php';
$pdo = get_pdo();
$log = [];
function mig_sql(PDO $pdo, string $label, string $sql, array &$log): void {
try {
$pdo->exec($sql);
$log[] = ['ok', $label];
} catch (PDOException $e) {
if (in_array($e->errorInfo[1], [1060, 1061, 1050], true)) {
$log[] = ['skip', $label . ' (already exists)'];
} else {
$log[] = ['err', $label . ': ' . $e->getMessage()];
}
}
}
mig_sql($pdo, 'Create custom_prayers table', "
CREATE TABLE IF NOT EXISTS custom_prayers (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
leader_text TEXT,
all_text TEXT,
is_global TINYINT(1) NOT NULL DEFAULT 0,
created_by INT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_global (is_global),
INDEX idx_created_by (created_by)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
", $log);
mig_sql($pdo, 'Create builder_steps table', "
CREATE TABLE IF NOT EXISTS builder_steps (
id INT AUTO_INCREMENT PRIMARY KEY,
session_id INT NOT NULL,
step_order INT NOT NULL DEFAULT 0,
prayer_id INT NOT NULL,
attribution ENUM('leader_all','leader_only','all_only','none') NOT NULL DEFAULT 'leader_all',
INDEX idx_session (session_id),
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
", $log);
// Seed standard global prayers (skip if already done)
$existing = (int)$pdo->query("SELECT COUNT(*) FROM custom_prayers WHERE is_global=1")->fetchColumn();
if ($existing > 0) {
$log[] = ['skip', "Standard prayers already seeded ({$existing} found)"];
} else {
$sa = $pdo->query("SELECT id FROM users WHERE role='superadmin' LIMIT 1")->fetch();
$creator_id = $sa ? (int)$sa['id'] : 1;
$prayers = [
[
'name' => 'Sign of the Cross',
'leader' => "In the name of the Father,\nand of the Son,\nand of the Holy Spirit.",
'all' => 'Amen.',
],
[
'name' => 'Apostles\' Creed',
'leader' => "I believe in God, the Father Almighty,\nCreator of Heaven and earth;\nand in Jesus Christ, His only Son, Our Lord,\nWho was conceived by the Holy Spirit,\nborn of the Virgin Mary,\nsuffered under Pontius Pilate,\nwas crucified, died, and was buried.\nHe descended into Hell;\nthe third day He rose again from the dead;\nHe ascended into Heaven,\nand sitteth at the right hand of God, the Father Almighty;\nfrom thence He shall come to judge the living and the dead.",
'all' => "I believe in the Holy Spirit,\nthe Holy Catholic Church,\nthe communion of saints,\nthe forgiveness of sins,\nthe resurrection of the body\nand life everlasting. Amen.",
],
[
'name' => 'Our Father',
'leader' => "Our Father, Who art in Heaven,\nhallowed be Thy name;\nThy kingdom come,\nThy will be done on earth as it is in Heaven.",
'all' => "Give us this day our daily bread,\nand forgive us our trespasses,\nas we forgive those who trespass against us;\nand lead us not into temptation,\nbut deliver us from evil. Amen.",
],
[
'name' => 'Hail Mary',
'leader' => "Hail Mary, full of grace, the Lord is with thee;\nblessed art thou amongst women,\nand blessed is the fruit of thy womb, Jesus.",
'all' => "Holy Mary, Mother of God,\npray for us sinners,\nnow and at the hour of our death. Amen.",
],
[
'name' => 'Glory Be',
'leader' => "Glory be to the Father, and to the Son,\nand to the Holy Spirit,",
'all' => "as it was in the beginning, is now,\nand ever shall be, world without end. Amen.",
],
[
'name' => 'Fatima Prayer',
'leader' => "O my Jesus, forgive us our sins,\nsave us from the fires of hell,",
'all' => "lead all souls to Heaven,\nespecially those who are in most need of Thy mercy.",
],
[
'name' => 'Hail Holy Queen',
'leader' => "Hail, Holy Queen, Mother of Mercy,\nour life, our sweetness and our hope.\nTo thee do we cry,\npoor banished children of Eve.\nTo thee do we send up our sighs,\nmourning and weeping in this valley of tears.\nTurn then, most gracious advocate,\nthine eyes of mercy toward us,\nand after this our exile\nshow unto us the blessed fruit of thy womb, Jesus.\nO clement, O loving,\nO sweet Virgin Mary.",
'all' => "Pray for us, O holy Mother of God,\nthat we may be made worthy of the promises of Christ.",
],
[
'name' => 'Eternal Rest',
'leader' => "Eternal rest grant unto {pronoun_obj}, O Lord,",
'all' => "and let perpetual light shine upon {pronoun_obj}.\nMay {pronoun_poss} soul and the souls of all the faithful departed,\nthrough the mercy of God, rest in peace. Amen.",
],
[
'name' => 'The Memorare',
'leader' => "Remember, O most gracious Virgin Mary,\nthat never was it known\nthat anyone who fled to thy protection,\nimplored thy help, or sought thy intercession,\nwas left unaided.\nInspired by this confidence,\nI fly unto thee, O Virgin of virgins, my mother;\nto thee do I come,\nbefore thee I stand, sinful and sorrowful.\nO Mother of the Word Incarnate,\ndespise not my petitions,\nbut in thy mercy hear and answer me.",
'all' => 'Amen.',
],
[
'name' => 'Act of Contrition',
'leader' => "O my God, I am heartily sorry for having offended Thee,\nand I detest all my sins\nbecause of thy just punishments,\nbut most of all because they offend Thee, my God,\nwho art all good and deserving of all my love.\nI firmly resolve, with the help of Thy grace,\nto sin no more and to avoid the near occasions of sin.",
'all' => 'Amen.',
],
[
'name' => 'O Blood and Water',
'leader' => "O Blood and Water,\nwhich gushed forth from the Heart of Jesus\nas a fount of mercy for us,",
'all' => 'I trust in You.',
],
[
'name' => 'Eternal Father (Divine Mercy)',
'leader' => "Eternal Father, I offer You the Body and Blood,\nSoul and Divinity of Your dearly beloved Son,\nOur Lord Jesus Christ,",
'all' => "in atonement for our sins and those of the whole world.",
],
[
'name' => 'For the Sake of His Sorrowful Passion',
'leader' => "For the sake of His sorrowful Passion,",
'all' => "have mercy on us and on the whole world.",
],
[
'name' => 'Holy God (Divine Mercy Closing)',
'leader' => "Holy God, Holy Mighty One, Holy Immortal One,",
'all' => "have mercy on us and on the whole world.",
],
[
'name' => 'Prayer to St. Michael the Archangel',
'leader' => "Saint Michael the Archangel,\ndefend us in battle.\nBe our defense against the wickedness and snares of the Devil.\nMay God rebuke him, we humbly pray,\nand do thou, O Prince of the heavenly hosts,\nby the power of God, thrust into hell Satan,\nand all the evil spirits,\nwho prowl about the world seeking the ruin of souls.",
'all' => 'Amen.',
],
[
'name' => 'Rosary Closing Prayer',
'leader' => "Let us pray.\n\nO God, whose only-begotten Son,\nby His life, death, and resurrection,\nhas purchased for us the rewards of eternal life,\ngrant, we beseech Thee,\nthat meditating upon these mysteries\nof the Most Holy Rosary of the Blessed Virgin Mary,\nwe may imitate what they contain\nand obtain what they promise,\nthrough the same Christ Our Lord.",
'all' => 'Amen.',
],
];
$stmt = $pdo->prepare(
"INSERT INTO custom_prayers (name, leader_text, all_text, is_global, created_by)
VALUES (?, ?, ?, 1, ?)"
);
foreach ($prayers as $p) {
$stmt->execute([$p['name'], $p['leader'], $p['all'], $creator_id]);
}
$log[] = ['ok', 'Seeded ' . count($prayers) . ' standard global prayers'];
}
?><!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Migrate v4</title>
<style>
body { font-family: system-ui; max-width: 700px; margin: 40px auto; padding: 0 20px; }
h2 { color: #1d2027; }
.ok { color: #15803d; } .skip { color: #b45309; } .err { color: #dc2626; }
li { margin: 4px 0; font-size: 14px; }
.done { background: #f0fdf4; border: 1px solid #86efac; padding: 16px; border-radius: 8px; margin-top: 20px; }
</style>
</head>
<body>
<h2>Migrate v4 — Rosary Builder Tables</h2>
<ul>
<?php foreach ($log as [$status, $msg]): ?>
<li class="<?= $status ?>">
<?= $status === 'ok' ? '&#x2713;' : ($status === 'skip' ? '&#x25CC;' : '&#x2717;') ?>
<?= htmlspecialchars($msg) ?>
</li>
<?php endforeach; ?>
</ul>
<?php if (!array_filter($log, fn($l) => $l[0] === 'err')): ?>
<div class="done">
<strong>Migration complete.</strong>
Delete this file now: <code>migrate_v4.php</code>
</div>
<?php endif; ?>
</body>
</html>