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
+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;');
}
})();