Files
ProdDash/modules/pco-plan/setup.html
T
Waters ChurchandClaude Fable 5.1 e7dc235a8a PCO Plan module: Planning Center order of service, following ProPresenter
New module modules/pco-plan: reads the current plan for a chosen Planning
Center Services service type (PAT or OAuth token), streams items with start
times, lengths, keys, descriptions and item notes to tiles (every detail a
per-tile show/hide setting), and follows the propresenter-now-next module's
live item over loopback SSE to highlight the current plan item and track
elapsed vs planned runtime. Ships its own setup page
(/modules/pco-plan/setup.html) to browse folders and pick the service type
through the shell admin API, plus tools/pco-mock.js for development.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-09 15:42:53 -04:00

373 lines
16 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>PCO Plan — Planning Center setup</title>
<link rel="stylesheet" href="/style.css">
<style>
/* The module's own setup page: browse Planning Center folders and pick the
service type to follow. Saves through the shell's admin API, so the
choice lands in config/modules.json like any other admin setting. */
body {
margin: 0;
padding: 24px 16px 48px;
background: var(--bg, #0b0e12);
color: var(--text, #e6edf3);
font: 14px/1.45 system-ui, -apple-system, "Segoe UI", sans-serif;
}
.ps-page { max-width: 760px; margin: 0 auto; }
.ps-top { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; }
.ps-top h1 { margin: 0; font-size: 20px; }
.ps-top a { color: var(--accent, #2ee59a); text-decoration: none; font-weight: 600; }
.ps-lead { color: var(--muted, #8b98a5); margin: 6px 0 18px; }
.ps-card {
background: var(--panel, #131920);
border: 1px solid var(--border, #232b35);
border-radius: 12px;
padding: 14px 16px;
margin-bottom: 14px;
}
.ps-card h2 { margin: 0 0 10px; font-size: 13px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--muted, #8b98a5); }
.ps-current { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.ps-current strong { font-size: 15px; }
.ps-crumbs { display: flex; align-items: center; flex-wrap: wrap; gap: 4px; margin-bottom: 10px; font-size: 13px; }
.ps-crumbs button {
background: none; border: none; color: var(--accent, #2ee59a); font: inherit; font-weight: 600; cursor: pointer; padding: 2px 4px; border-radius: 4px;
}
.ps-crumbs button:hover { background: color-mix(in srgb, var(--accent, #2ee59a) 14%, transparent); }
.ps-crumbs button[disabled] { color: var(--text, #e6edf3); cursor: default; background: none; }
.ps-crumbs .sep { color: var(--muted, #8b98a5); }
.ps-rows { display: flex; flex-direction: column; gap: 4px; }
.ps-row {
display: flex; align-items: center; gap: 10px;
padding: 9px 10px; border-radius: 8px; border: 1px solid transparent;
}
.ps-row:hover { background: color-mix(in srgb, var(--text, #e6edf3) 4%, transparent); }
.ps-row.is-folder { cursor: pointer; }
.ps-row.is-selected { border-color: color-mix(in srgb, var(--accent, #2ee59a) 50%, transparent); background: color-mix(in srgb, var(--accent, #2ee59a) 10%, transparent); }
.ps-icon { flex: none; width: 22px; text-align: center; color: var(--muted, #8b98a5); }
.ps-name { flex: 1; min-width: 0; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ps-meta { color: var(--muted, #8b98a5); font-size: 12px; white-space: nowrap; }
.ps-btn {
background: none; border: 1px solid var(--border, #232b35); color: var(--text, #e6edf3);
font: inherit; font-weight: 600; font-size: 13px; padding: 5px 12px; border-radius: 999px; cursor: pointer; white-space: nowrap;
}
.ps-btn:hover { border-color: var(--accent, #2ee59a); color: var(--accent, #2ee59a); }
.ps-btn.primary { background: var(--accent, #2ee59a); border-color: var(--accent, #2ee59a); color: var(--bg, #0b0e12); }
.ps-btn.primary:hover { color: var(--bg, #0b0e12); filter: brightness(1.08); }
.ps-btn[disabled] { opacity: 0.5; cursor: default; }
.ps-preview { margin-top: 6px; padding: 8px 10px 8px 42px; color: var(--muted, #8b98a5); font-size: 13px; }
.ps-preview strong { color: var(--text, #e6edf3); }
.ps-status { margin-top: 10px; font-size: 13px; color: var(--muted, #8b98a5); min-height: 1.3em; }
.ps-status.ok { color: var(--accent, #2ee59a); }
.ps-status.error { color: var(--danger, #f4433c); }
.ps-empty { color: var(--muted, #8b98a5); padding: 10px; }
.ps-login { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-top: 8px; }
.ps-login input {
background: var(--bg, #0b0e12); border: 1px solid var(--border, #232b35); color: var(--text, #e6edf3);
font: inherit; padding: 6px 10px; border-radius: 8px; width: 200px;
}
.ps-hint { color: var(--muted, #8b98a5); font-size: 12px; margin-top: 8px; }
.ps-hint code { background: color-mix(in srgb, var(--text, #e6edf3) 8%, transparent); padding: 1px 5px; border-radius: 4px; }
[hidden] { display: none !important; }
</style>
</head>
<body>
<div class="ps-page">
<div class="ps-top">
<h1>PCO Plan · Planning Center setup</h1>
<a href="/admin">← Admin</a>
<a href="/">Dashboard</a>
</div>
<p class="ps-lead">Browse your Planning Center Services folders and choose the service type whose plans the PCO Plan tiles should follow.</p>
<section class="ps-card">
<h2>Currently following</h2>
<div class="ps-current" id="current">Loading…</div>
<div class="ps-status" id="current-status"></div>
</section>
<section class="ps-card">
<h2>Service types</h2>
<div class="ps-crumbs" id="crumbs"></div>
<div class="ps-rows" id="rows"><div class="ps-empty">Loading folders from Planning Center…</div></div>
<div class="ps-status" id="browse-status"></div>
<div class="ps-login" id="login" hidden>
<span>Admin passcode:</span>
<input type="password" id="passcode" autocomplete="current-password">
<button class="ps-btn primary" id="login-btn">Unlock</button>
<span class="ps-status" id="login-status"></span>
</div>
<div class="ps-hint">Credentials (Application ID + Secret from <code>api.planningcenteronline.com</code>, or an OAuth token) are entered under PCO Plan in <a href="/admin" style="color:var(--accent)">Admin</a>. Saving here re-initialises the module; open tiles pick up the new plan on their own.</div>
</section>
</div>
<script>
(() => {
const MODULE = 'pco-plan';
const els = {
current: document.getElementById('current'),
currentStatus: document.getElementById('current-status'),
crumbs: document.getElementById('crumbs'),
rows: document.getElementById('rows'),
browseStatus: document.getElementById('browse-status'),
login: document.getElementById('login'),
passcode: document.getElementById('passcode'),
loginBtn: document.getElementById('login-btn'),
loginStatus: document.getElementById('login-status'),
};
let tree = null; // { folders: Map, serviceTypes: [], selected }
let path = []; // folder ids from root to the open folder
let pendingSave = null; // service type waiting for the passcode
async function api(url, opts = {}) {
const res = await fetch(url, { ...opts, headers: { 'Content-Type': 'application/json', ...(opts.headers || {}) } });
const body = await res.json().catch(() => ({}));
if (!res.ok) {
const err = new Error(body.error || `Request failed (${res.status})`);
err.status = res.status;
err.authRequired = Boolean(body.authRequired);
err.unconfigured = Boolean(body.unconfigured);
throw err;
}
return body;
}
function setStatus(el, text, kind) {
el.textContent = text || '';
el.className = 'ps-status' + (kind ? ' ' + kind : '');
}
/* — current selection — */
async function loadCurrent() {
try {
const { state } = await api(`/api/modules/${MODULE}/state`);
els.current.innerHTML = '';
if (!state.configured) {
els.current.textContent = 'No Planning Center credentials yet — add them in Admin first.';
return;
}
if (!state.serviceTypeSet) {
els.current.textContent = 'No service type selected.';
return;
}
const strong = document.createElement('strong');
strong.textContent = state.serviceType.name || `Service type ${state.serviceType.id}`;
els.current.appendChild(strong);
const meta = document.createElement('span');
meta.className = 'ps-meta';
if (state.plan) {
meta.textContent = `Plan: ${state.plan.dates}${state.plan.title ? ` — ${state.plan.title}` : ''} · ${state.items.length} items`;
} else if (state.reachable) {
meta.textContent = 'No upcoming or recent plan found';
} else {
meta.textContent = state.lastError || 'Reading…';
}
els.current.appendChild(meta);
setStatus(els.currentStatus, state.reachable ? '' : state.lastError, state.reachable ? '' : 'error');
} catch (e) {
els.current.textContent = `Module not running: ${e.message}`;
}
}
/* — browsing — */
async function loadTree() {
setStatus(els.browseStatus, 'Loading…');
try {
const body = await api(`/api/modules/${MODULE}/browse`);
const folders = new Map(body.folders.map((f) => [f.id, f]));
// A parent that still is not known is treated as root.
for (const f of folders.values()) if (f.parentId && !folders.has(f.parentId)) f.parentId = '';
for (const st of body.serviceTypes) if (st.parentId && !folders.has(st.parentId)) st.parentId = '';
tree = { folders, serviceTypes: body.serviceTypes, selected: body.selected };
// Open the folder that holds the current selection.
path = [];
const sel = body.serviceTypes.find((st) => st.id === body.selected);
if (sel) {
let pid = sel.parentId;
while (pid && folders.has(pid)) { path.unshift(pid); pid = folders.get(pid).parentId; }
}
setStatus(els.browseStatus, `${body.serviceTypes.length} service types in ${folders.size} folders (${body.method === 'oauth' ? 'OAuth token' : 'personal access token'})`);
renderTree();
} catch (e) {
els.rows.innerHTML = '';
const msg = document.createElement('div');
msg.className = 'ps-empty';
msg.textContent = e.unconfigured
? 'Add Planning Center credentials for PCO Plan in Admin, then reload this page.'
: (e.status === 401 ? 'Planning Center rejected the credentials — check the Application ID and Secret in Admin.' : e.message);
els.rows.appendChild(msg);
setStatus(els.browseStatus, '', 'error');
}
}
function renderCrumbs() {
els.crumbs.innerHTML = '';
const add = (label, depth) => {
if (els.crumbs.children.length) {
const sep = document.createElement('span');
sep.className = 'sep';
sep.textContent = '›';
els.crumbs.appendChild(sep);
}
const b = document.createElement('button');
b.textContent = label;
b.disabled = depth === path.length;
b.addEventListener('click', () => { path = path.slice(0, depth); renderTree(); });
els.crumbs.appendChild(b);
};
add('All folders', 0);
path.forEach((id, i) => add(tree.folders.get(id)?.name || 'Folder', i + 1));
}
function renderTree() {
if (!tree) return;
renderCrumbs();
els.rows.innerHTML = '';
const here = path.length ? path[path.length - 1] : '';
const folders = Array.from(tree.folders.values()).filter((f) => (f.parentId || '') === here)
.sort((a, b) => a.name.localeCompare(b.name));
const types = tree.serviceTypes.filter((st) => (st.parentId || '') === here)
.sort((a, b) => a.name.localeCompare(b.name));
for (const f of folders) {
const row = document.createElement('div');
row.className = 'ps-row is-folder';
row.innerHTML = '<span class="ps-icon">📁</span>';
const name = document.createElement('span');
name.className = 'ps-name';
name.textContent = f.name;
const count = document.createElement('span');
count.className = 'ps-meta';
const n = countTypes(f.id);
count.textContent = n === 1 ? '1 service type' : `${n} service types`;
row.append(name, count);
row.addEventListener('click', () => { path = [...path, f.id]; renderTree(); });
els.rows.appendChild(row);
}
for (const st of types) {
const row = document.createElement('div');
row.className = 'ps-row' + (st.id === tree.selected ? ' is-selected' : '');
row.innerHTML = '<span class="ps-icon">▤</span>';
const name = document.createElement('span');
name.className = 'ps-name';
name.textContent = st.name;
const meta = document.createElement('span');
meta.className = 'ps-meta';
meta.textContent = st.frequency ? st.frequency : '';
const preview = document.createElement('button');
preview.className = 'ps-btn';
preview.textContent = 'Preview plan';
const pick = document.createElement('button');
pick.className = 'ps-btn primary';
pick.textContent = st.id === tree.selected ? 'Selected' : 'Use this';
pick.disabled = st.id === tree.selected;
row.append(name, meta, preview, pick);
els.rows.appendChild(row);
const previewEl = document.createElement('div');
previewEl.className = 'ps-preview';
previewEl.hidden = true;
els.rows.appendChild(previewEl);
preview.addEventListener('click', async () => {
previewEl.hidden = false;
previewEl.textContent = 'Looking up the plan…';
try {
const { plan } = await api(`/api/modules/${MODULE}/preview?serviceTypeId=${encodeURIComponent(st.id)}`);
if (!plan) {
previewEl.textContent = 'No upcoming or recent plan for this service type.';
return;
}
previewEl.innerHTML = '';
const strong = document.createElement('strong');
strong.textContent = `${plan.dates}${plan.title ? ` — ${plan.title}` : ''}`;
previewEl.appendChild(strong);
previewEl.appendChild(document.createTextNode(
`${plan.seriesTitle ? ` · ${plan.seriesTitle}` : ''} · ${plan.itemsCount} items · ${Math.round(plan.totalLength / 60)} min`
));
} catch (e) {
previewEl.textContent = e.message;
}
});
pick.addEventListener('click', () => save(st));
}
if (!folders.length && !types.length) {
const empty = document.createElement('div');
empty.className = 'ps-empty';
empty.textContent = 'This folder is empty.';
els.rows.appendChild(empty);
}
}
function countTypes(folderId) {
let n = 0;
const stack = [folderId];
const seen = new Set();
while (stack.length) {
const id = stack.pop();
if (seen.has(id)) continue;
seen.add(id);
n += tree.serviceTypes.filter((st) => st.parentId === id).length;
for (const f of tree.folders.values()) if (f.parentId === id) stack.push(f.id);
}
return n;
}
/* — saving through the shell's admin API — */
async function save(st) {
setStatus(els.browseStatus, `Saving ${st.name}…`);
try {
await api(`/api/admin/modules/${MODULE}/config`, {
method: 'PUT',
body: JSON.stringify({ config: { serviceTypeId: st.id } }),
});
tree.selected = st.id;
pendingSave = null;
els.login.hidden = true;
setStatus(els.browseStatus, `Now following ${st.name}. The module re-initialised; tiles update on their own.`, 'ok');
renderTree();
// The module re-reads the plan right after re-init; give it a moment.
setTimeout(loadCurrent, 1500);
setTimeout(loadCurrent, 5000);
} catch (e) {
if (e.authRequired) {
pendingSave = st;
els.login.hidden = false;
els.passcode.focus();
setStatus(els.browseStatus, 'Enter the admin passcode to save.', 'error');
} else {
setStatus(els.browseStatus, e.message, 'error');
}
}
}
async function login() {
setStatus(els.loginStatus, '');
try {
await api('/api/admin/login', { method: 'POST', body: JSON.stringify({ passcode: els.passcode.value }) });
els.passcode.value = '';
els.login.hidden = true;
if (pendingSave) save(pendingSave);
} catch (e) {
setStatus(els.loginStatus, e.message, 'error');
}
}
els.loginBtn.addEventListener('click', login);
els.passcode.addEventListener('keydown', (e) => { if (e.key === 'Enter') login(); });
loadCurrent();
loadTree();
})();
</script>
</body>
</html>