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:
@@ -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 →</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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
})();
|
||||
Reference in New Issue
Block a user