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,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;
|
||||
}
|
||||
}
|
||||
@@ -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