8c047f5b28
Each prayer in the library has an optional default_bead_type (small/large/ crucifix). Standard prayers get sensible defaults: Our Father=large, Hail Mary=small, Sign of Cross=crucifix, Divine Mercy beads accordingly. In the sequence, each step card shows a bead selector (—/○/●/✝) so users can override the default per step. Adding a prayer pre-fills its default. Bead library icon hints (○●✝) appear on prayer cards in the library. Modal now includes a Bead selector for creating/editing prayers. Remove the separate Bead Markers library section — beads live on prayers. build_slides: prayer steps with bead_type now get a real bead_index so the ring advances correctly during presentation. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
510 lines
24 KiB
JavaScript
510 lines
24 KiB
JavaScript
/**
|
|
* 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
|
|
───────────────────────────────────────────────────────── */
|
|
function init() {
|
|
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) {
|
|
if (s.step_type === 'bead') {
|
|
STEPS.push({ step_type: 'bead', bead_type: s.bead_type });
|
|
} else {
|
|
const prayer = PRAYERS.find(p => String(p.id) === String(s.prayer_id));
|
|
if (prayer) STEPS.push({ step_type: 'prayer', prayer_id: s.prayer_id, attribution: s.attribution, bead_type: s.bead_type || null, _prayer: prayer });
|
|
}
|
|
});
|
|
}
|
|
|
|
renderLibrary();
|
|
renderSequence();
|
|
bindEvents();
|
|
}
|
|
|
|
// Scripts are at end of body — DOM is fully parsed by the time this runs,
|
|
// so DOMContentLoaded may have already fired. Check readyState first.
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', init);
|
|
} else {
|
|
init();
|
|
}
|
|
|
|
/* ─────────────────────────────────────────────────────────
|
|
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 bead selector buttons
|
|
document.querySelectorAll('#modal-bead-row .bead-sel-btn').forEach(function (btn) {
|
|
btn.addEventListener('click', function () {
|
|
document.querySelectorAll('#modal-bead-row .bead-sel-btn').forEach(b => b.classList.remove('active'));
|
|
this.classList.add('active');
|
|
document.getElementById('modal-bead-type').value = this.dataset.bead;
|
|
});
|
|
});
|
|
|
|
// 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;
|
|
}
|
|
|
|
const beadIcon = { small: '○', large: '●', crucifix: '✝' };
|
|
|
|
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;
|
|
const beadHint = p.default_bead_type ? `<span class="prayer-bead-hint" title="${p.default_bead_type} bead">${beadIcon[p.default_bead_type]}</span>` : '';
|
|
|
|
return `<div class="prayer-card">
|
|
<div class="prayer-card-top">
|
|
<span class="prayer-card-name">${esc(p.name)} ${beadHint}</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;
|
|
}
|
|
|
|
const beadMeta = {
|
|
small: { icon: '○', label: 'Small Bead', sub: 'Hail Mary bead' },
|
|
large: { icon: '●', label: 'Large Bead', sub: 'Our Father bead' },
|
|
crucifix: { icon: '✝', label: 'Crucifix', sub: 'Cross bead' },
|
|
};
|
|
|
|
list.innerHTML = STEPS.map(function (step, i) {
|
|
const moveUp = `<button class="btn-icon" title="Move up" onclick="builderMove(${i},-1)" ${i===0?'disabled':''}>↑</button>`;
|
|
const moveDn = `<button class="btn-icon" title="Move down" onclick="builderMove(${i}, 1)" ${i===STEPS.length-1?'disabled':''}>↓</button>`;
|
|
const remove = `<button class="btn-icon remove" title="Remove" onclick="builderRemove(${i})">✕</button>`;
|
|
|
|
if (step.step_type === 'bead') {
|
|
const m = beadMeta[step.bead_type] || beadMeta.small;
|
|
return `<div class="step-card bead-step">
|
|
<div class="step-num">${i + 1}</div>
|
|
<div class="step-body">
|
|
<div class="bead-step-name">${m.icon} ${m.label}</div>
|
|
<div class="bead-step-sub">${m.sub}</div>
|
|
</div>
|
|
<div class="step-actions">${moveUp}${moveDn}${remove}</div>
|
|
</div>`;
|
|
}
|
|
|
|
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);
|
|
|
|
const bead = step.bead_type || '';
|
|
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>` : ''}
|
|
<div class="step-controls-row">
|
|
<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 (unison)</option>
|
|
<option value="none" ${step.attribution==='none' ? 'selected':''}>No attribution</option>
|
|
</select>
|
|
<div class="step-bead-btns">
|
|
<button class="bead-sel-btn ${bead===''?'active':''}" onclick="builderSetBead(${i},null)" title="No bead">—</button>
|
|
<button class="bead-sel-btn ${bead==='small'?'active':''}" onclick="builderSetBead(${i},'small')" title="Small bead (Hail Mary)">○</button>
|
|
<button class="bead-sel-btn ${bead==='large'?'active':''}" onclick="builderSetBead(${i},'large')" title="Large bead (Our Father)">●</button>
|
|
<button class="bead-sel-btn ${bead==='crucifix'?'active':''}" onclick="builderSetBead(${i},'crucifix')" title="Crucifix">✝</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="step-actions">${moveUp}${moveDn}${remove}</div>
|
|
</div>`;
|
|
}).join('');
|
|
}
|
|
|
|
/* ─────────────────────────────────────────────────────────
|
|
Step manipulation (exposed globally)
|
|
───────────────────────────────────────────────────────── */
|
|
window.builderAddBead = function (beadType) {
|
|
STEPS.push({ step_type: 'bead', bead_type: beadType });
|
|
renderSequence();
|
|
const cards = document.querySelectorAll('#step-list .step-card');
|
|
const last = cards[cards.length - 1];
|
|
if (last) {
|
|
last.style.outline = '2px solid #f59e0b';
|
|
setTimeout(() => { last.style.outline = ''; }, 800);
|
|
last.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
|
}
|
|
};
|
|
|
|
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';
|
|
|
|
const defaultBead = prayer.default_bead_type || null;
|
|
STEPS.push({ step_type: 'prayer', prayer_id: prayerId, attribution: attr, bead_type: defaultBead, _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.builderSetBead = function (index, value) {
|
|
if (STEPS[index]) {
|
|
STEPS[index].bead_type = value;
|
|
renderSequence(); // re-render so active bead button updates
|
|
}
|
|
};
|
|
|
|
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;
|
|
// Pre-fill bead type
|
|
const beadVal = p.default_bead_type || '';
|
|
document.getElementById('modal-bead-type').value = beadVal;
|
|
document.querySelectorAll('#modal-bead-row .bead-sel-btn').forEach(function (btn) {
|
|
btn.classList.toggle('active', btn.dataset.bead === beadVal);
|
|
});
|
|
|
|
// 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.classList.add('modal-open');
|
|
document.getElementById('modal-name').focus();
|
|
}
|
|
|
|
function closeModal() {
|
|
document.getElementById('prayer-modal').classList.remove('modal-open');
|
|
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 beadType = document.getElementById('modal-bead-type').value || null;
|
|
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, default_bead_type: beadType, 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 => s.step_type === 'bead'
|
|
? { step_type: 'bead', bead_type: s.bead_type }
|
|
: { step_type: 'prayer', prayer_id: s.prayer_id, attribution: s.attribution, bead_type: s.bead_type || null }),
|
|
};
|
|
|
|
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,'"');
|
|
}
|
|
|
|
})();
|