373 lines
16 KiB
HTML
373 lines
16 KiB
HTML
<!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>
|