pco-plan: everything configured in Admin; key, section and top-bar restyle
- Drops the separate setup page: the service type is a select grouped by
Planning Center folder path (GET /service-types), the pinned plan a
select of upcoming/recent plans that follows the chosen service type
(GET /plans?serviceTypeId=), credentials sit in a group that folds once
saved.
- Show/hide moves from per-tile settings to a server-wide checklist in
Admin (three columns, folded by default): top bar, series, part, service
type, date, service time, total length, running clock, countdown,
headers, item numbers, start times, length, type badge, key, arrangement,
author/CCLI, description, notes, pre/post-service items, now indicator,
item runtime, dim completed, ProPresenter line. Tiles keep only text
size and auto-scroll.
- Top bar text is a template with placeholders ({series} • {part} |
{serviceType} • {date} by default; • separates droppable segments, |
starts the second line).
- Song key: larger, admin-chosen color (blue by default), always right
after the title and before the type badge. Section headers are
full-width accent bands. Optional background tint per item type.
- tools/pco-mock.js plan titles are part numbers, as in a real series.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
106ce0c1ce
commit
20dc2deb2b
@@ -137,13 +137,15 @@ ignored from then on.
|
|||||||
ProPresenter endpoint (host : port), password.
|
ProPresenter endpoint (host : port), password.
|
||||||
- **PCO Plan** — the order of service from Planning Center Services: every
|
- **PCO Plan** — the order of service from Planning Center Services: every
|
||||||
plan item with its scheduled start time, length, type, song key,
|
plan item with its scheduled start time, length, type, song key,
|
||||||
description and item notes (each detail is a per-tile show/hide setting).
|
description and item notes. When the ProPresenter module is running it
|
||||||
When the ProPresenter module is running it follows that module's live
|
follows that module's live item, highlights the matching plan item, dims
|
||||||
item, highlights the matching plan item, dims what's done and shows the
|
what's done and shows the elapsed vs planned runtime and how far
|
||||||
elapsed vs planned runtime and how far ahead/behind the service is. Admin
|
ahead/behind the service is. Everything is configured in `/admin`:
|
||||||
config: Planning Center credentials (Personal Access Token app ID + secret,
|
Planning Center credentials (Personal Access Token app ID + secret, or an
|
||||||
or an OAuth token) and the service type — browse your Planning Center
|
OAuth token), the service type (a list grouped by your Planning Center
|
||||||
folders and pick it at `/modules/pco-plan/setup.html`.
|
folders), an optional pinned plan, the top-bar text (placeholders such as
|
||||||
|
`{series} • {part} | {serviceType} • {date}`), colors, and a show/hide
|
||||||
|
checklist for every detail. Tiles only choose their text size.
|
||||||
- **Clock** — a big booth clock; also the smallest possible module and the
|
- **Clock** — a big booth clock; also the smallest possible module and the
|
||||||
reference for module authors.
|
reference for module authors.
|
||||||
|
|
||||||
|
|||||||
+147
-84
@@ -2,10 +2,11 @@
|
|||||||
|
|
||||||
The server part reads Planning Center and (optionally) follows the
|
The server part reads Planning Center and (optionally) follows the
|
||||||
ProPresenter module's live item; this tile only renders the plan it is
|
ProPresenter module's live item; this tile only renders the plan it is
|
||||||
streamed. Which details show is a per-tile choice (gear menu). */
|
streamed. What is shown is decided server-wide in /admin (moduleApi.config:
|
||||||
|
show/hide checklist, top-bar template, colors); the tile itself only owns
|
||||||
|
its text size and auto-scroll. */
|
||||||
|
|
||||||
const REFRESH_SVG = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-2.64-6.36"/><path d="M21 3v6h-6"/></svg>';
|
const REFRESH_SVG = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-2.64-6.36"/><path d="M21 3v6h-6"/></svg>';
|
||||||
const SETUP_SVG = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7h18M3 12h18M3 17h12"/><circle cx="19" cy="17" r="2.2"/></svg>';
|
|
||||||
|
|
||||||
export default function create({ root, moduleApi }) {
|
export default function create({ root, moduleApi }) {
|
||||||
let state = null;
|
let state = null;
|
||||||
@@ -42,7 +43,10 @@ export default function create({ root, moduleApi }) {
|
|||||||
const listEl = root.querySelector('.pp-list');
|
const listEl = root.querySelector('.pp-list');
|
||||||
const footEl = root.querySelector('.pp-foot');
|
const footEl = root.querySelector('.pp-foot');
|
||||||
|
|
||||||
const prefs = () => moduleApi.instanceSettings;
|
/** Admin-wide display settings (the show/hide checklist lives there). */
|
||||||
|
const cfg = () => moduleApi.config || {};
|
||||||
|
const on = (key) => cfg()[key] !== false; // unset = shown
|
||||||
|
const tilePrefs = () => moduleApi.instanceSettings;
|
||||||
const serverNow = () => Date.now() + skew;
|
const serverNow = () => Date.now() + skew;
|
||||||
|
|
||||||
/* ── title-bar controls ─────────────────────────────────────────── */
|
/* ── title-bar controls ─────────────────────────────────────────── */
|
||||||
@@ -59,21 +63,13 @@ export default function create({ root, moduleApi }) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
function bumpTextSize(delta) {
|
function bumpTextSize(delta) {
|
||||||
const size = Number(prefs().textSize) || 14;
|
const size = Number(tilePrefs().textSize) || 14;
|
||||||
moduleApi.saveInstanceSettings({ textSize: Math.min(40, Math.max(9, size + delta)) });
|
moduleApi.saveInstanceSettings({ textSize: Math.min(40, Math.max(9, size + delta)) });
|
||||||
applyPrefs();
|
applyPrefs();
|
||||||
}
|
}
|
||||||
moduleApi.header.addButton({ label: 'A−', title: 'Smaller text', onClick: () => bumpTextSize(-1) });
|
moduleApi.header.addButton({ label: 'A−', title: 'Smaller text', onClick: () => bumpTextSize(-1) });
|
||||||
moduleApi.header.addButton({ label: 'A+', title: 'Larger text', onClick: () => bumpTextSize(1) });
|
moduleApi.header.addButton({ label: 'A+', title: 'Larger text', onClick: () => bumpTextSize(1) });
|
||||||
|
|
||||||
moduleApi.header.addButton({
|
|
||||||
icon: SETUP_SVG,
|
|
||||||
title: 'Planning Center setup (choose the service type)',
|
|
||||||
onClick() {
|
|
||||||
window.open(`/modules/${moduleApi.id}/setup.html`, '_blank', 'noopener');
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
/* ── formatting ─────────────────────────────────────────────────── */
|
/* ── formatting ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
function fmtClock(ms) {
|
function fmtClock(ms) {
|
||||||
@@ -103,26 +99,30 @@ export default function create({ root, moduleApi }) {
|
|||||||
switch (itemType) {
|
switch (itemType) {
|
||||||
case 'song': return 'Song';
|
case 'song': return 'Song';
|
||||||
case 'media': return 'Media';
|
case 'media': return 'Media';
|
||||||
case 'header': return '';
|
|
||||||
default: return '';
|
default: return '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function plannedLength() {
|
||||||
|
return (state?.items || [])
|
||||||
|
.filter((it) => it.servicePosition === 'during')
|
||||||
|
.reduce((sum, it) => sum + (it.length || 0), 0);
|
||||||
|
}
|
||||||
|
|
||||||
/* ── which items to show ────────────────────────────────────────── */
|
/* ── which items to show ────────────────────────────────────────── */
|
||||||
|
|
||||||
function visibleItems() {
|
function visibleItems() {
|
||||||
const p = prefs();
|
|
||||||
const items = Array.isArray(state?.items) ? state.items : [];
|
const items = Array.isArray(state?.items) ? state.items : [];
|
||||||
return items.filter((it) => {
|
return items.filter((it) => {
|
||||||
if (it.itemType === 'header' && !p.showHeaders) return false;
|
if (it.itemType === 'header' && !on('showHeaders')) return false;
|
||||||
if (it.servicePosition === 'pre' && !p.showPreService) return false;
|
if (it.servicePosition === 'pre' && !on('showPreService')) return false;
|
||||||
if (it.servicePosition === 'post' && !p.showPostService) return false;
|
if (it.servicePosition === 'post' && !on('showPostService')) return false;
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function noteFilter() {
|
function noteFilter() {
|
||||||
const raw = String(prefs().noteCategories || '');
|
const raw = String(cfg().noteCategories || '');
|
||||||
const wanted = raw.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean);
|
const wanted = raw.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean);
|
||||||
return wanted.length ? (note) => wanted.includes(String(note.category || '').toLowerCase()) : () => true;
|
return wanted.length ? (note) => wanted.includes(String(note.category || '').toLowerCase()) : () => true;
|
||||||
}
|
}
|
||||||
@@ -133,7 +133,7 @@ export default function create({ root, moduleApi }) {
|
|||||||
if (feedOffline) return moduleApi.setStatus('error', 'ProdDash server unreachable — reconnecting…');
|
if (feedOffline) return moduleApi.setStatus('error', 'ProdDash server unreachable — reconnecting…');
|
||||||
if (!state) return moduleApi.setStatus('connecting', 'Connecting…');
|
if (!state) return moduleApi.setStatus('connecting', 'Connecting…');
|
||||||
if (!state.configured) return moduleApi.setStatus('error', 'No Planning Center credentials — add them in Admin');
|
if (!state.configured) return moduleApi.setStatus('error', 'No Planning Center credentials — add them in Admin');
|
||||||
if (!state.serviceTypeSet) return moduleApi.setStatus('error', 'No service type selected — use the setup page');
|
if (!state.serviceTypeSet) return moduleApi.setStatus('error', 'No service type selected — pick one in Admin');
|
||||||
if (!state.reachable) {
|
if (!state.reachable) {
|
||||||
return moduleApi.setStatus(state.lastFetched ? 'error' : 'connecting', state.lastError || 'Reading the plan…');
|
return moduleApi.setStatus(state.lastFetched ? 'error' : 'connecting', state.lastError || 'Reading the plan…');
|
||||||
}
|
}
|
||||||
@@ -147,22 +147,65 @@ export default function create({ root, moduleApi }) {
|
|||||||
moduleApi.setStatus('ok', `Plan read ${new Date(state.lastFetched).toLocaleTimeString()}${extra}`);
|
moduleApi.setStatus('ok', `Plan read ${new Date(state.lastFetched).toLocaleTimeString()}${extra}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── plan header ────────────────────────────────────────────────── */
|
/* ── top bar: admin template with placeholders ──────────────────── */
|
||||||
|
|
||||||
|
const DEFAULT_TEMPLATE = '{series} • {part} | {serviceType} • {date}';
|
||||||
|
|
||||||
|
/** Placeholder → text; a hidden or empty value resolves to '' and drops its segment. */
|
||||||
|
function templateValues() {
|
||||||
|
const plan = state?.plan || {};
|
||||||
|
const items = state?.items || [];
|
||||||
|
const planned = plannedLength();
|
||||||
|
return {
|
||||||
|
series: on('showSeries') ? plan.seriesTitle || '' : '',
|
||||||
|
part: on('showPart') ? plan.title || '' : '',
|
||||||
|
title: on('showPart') ? plan.title || '' : '',
|
||||||
|
servicetype: on('showServiceType') ? state?.serviceType?.name || '' : '',
|
||||||
|
date: on('showDate') ? plan.dates || '' : '',
|
||||||
|
shortdate: on('showDate') ? plan.shortDates || plan.dates || '' : '',
|
||||||
|
time: on('showServiceTime') && plan.serviceStartsAt
|
||||||
|
? `${plan.serviceName && plan.serviceName !== 'Service' ? `${plan.serviceName} ` : ''}${fmtClock(plan.serviceStartsAt)}`
|
||||||
|
: '',
|
||||||
|
length: on('showTotalLength') && planned ? fmtMinutes(planned) : '',
|
||||||
|
items: items.filter((it) => it.itemType !== 'header').length ? String(items.filter((it) => it.itemType !== 'header').length) : '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "{series} • {part} | {serviceType} • {date}" → up to two lines. Within a
|
||||||
|
* line, • (or ·) separates segments; a segment whose placeholders all came
|
||||||
|
* out empty is dropped, so a plan with no series just reads "Wk 2".
|
||||||
|
*/
|
||||||
|
function renderTemplate(template, values) {
|
||||||
|
const lines = String(template || DEFAULT_TEMPLATE).split('|').slice(0, 2);
|
||||||
|
return lines.map((line) => {
|
||||||
|
const segments = line.split(/\s*[•·]\s*/);
|
||||||
|
const kept = [];
|
||||||
|
for (const seg of segments) {
|
||||||
|
let sawVar = false;
|
||||||
|
let allEmpty = true;
|
||||||
|
const text = seg.replace(/\{([a-zA-Z]+)\}/g, (m, name) => {
|
||||||
|
sawVar = true;
|
||||||
|
const v = values[name.toLowerCase()];
|
||||||
|
if (v) allEmpty = false;
|
||||||
|
return v || '';
|
||||||
|
}).trim();
|
||||||
|
if (sawVar && allEmpty) continue;
|
||||||
|
if (text) kept.push(text);
|
||||||
|
}
|
||||||
|
return kept.join(' • ');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function renderHead() {
|
function renderHead() {
|
||||||
const plan = state?.plan;
|
const plan = state?.plan;
|
||||||
const show = prefs().showPlanHeader && plan;
|
const show = on('showTopBar') && plan;
|
||||||
headEl.hidden = !show;
|
headEl.hidden = !show;
|
||||||
if (!show) return;
|
if (!show) return;
|
||||||
const title = [plan.dates, plan.title || plan.seriesTitle].filter(Boolean).join(' · ');
|
const [line1, line2] = renderTemplate(cfg().headerTemplate, templateValues());
|
||||||
planTitleEl.textContent = title || (state.serviceType?.name || 'Plan');
|
planTitleEl.textContent = line1 || line2 || state.serviceType?.name || 'Plan';
|
||||||
const bits = [];
|
planSubEl.textContent = line1 ? line2 || '' : '';
|
||||||
if (state.serviceType?.name) bits.push(state.serviceType.name);
|
planSubEl.hidden = !planSubEl.textContent;
|
||||||
if (plan.title && plan.seriesTitle) bits.push(plan.seriesTitle);
|
|
||||||
if (plan.serviceStartsAt) bits.push(`${plan.serviceName ? `${plan.serviceName} ` : ''}${fmtClock(plan.serviceStartsAt)}`);
|
|
||||||
const planned = state.items.filter((it) => it.servicePosition === 'during').reduce((sum, it) => sum + (it.length || 0), 0);
|
|
||||||
if (planned) bits.push(`planned ${fmtMinutes(planned)}`);
|
|
||||||
planSubEl.textContent = bits.join(' · ');
|
|
||||||
renderClock();
|
renderClock();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,16 +217,13 @@ export default function create({ root, moduleApi }) {
|
|||||||
clockValueEl.className = 'pp-clock-value';
|
clockValueEl.className = 'pp-clock-value';
|
||||||
if (!plan || headEl.hidden) return;
|
if (!plan || headEl.hidden) return;
|
||||||
const now = serverNow();
|
const now = serverNow();
|
||||||
if (prefs().showRuntime && live.serviceStartedAt) {
|
if (on('showRunningClock') && live.following && live.serviceStartedAt) {
|
||||||
clockLabelEl.textContent = 'Running';
|
clockLabelEl.textContent = 'Running';
|
||||||
clockValueEl.textContent = fmtDur((now - live.serviceStartedAt) / 1000);
|
clockValueEl.textContent = fmtDur((now - live.serviceStartedAt) / 1000);
|
||||||
// Ahead of / behind the plan: elapsed minus what the completed items were planned to take.
|
// Ahead of / behind the plan: elapsed minus what the completed items were planned to take.
|
||||||
const planned = state.items
|
const done = state.items.filter((it) => live.history?.[it.id]?.endedAt);
|
||||||
.filter((it) => live.history?.[it.id]?.endedAt)
|
const planned = done.reduce((sum, it) => sum + (it.length || 0), 0);
|
||||||
.reduce((sum, it) => sum + (it.length || 0), 0);
|
const actual = done.reduce((sum, it) => sum + (live.history[it.id].endedAt - live.history[it.id].startedAt) / 1000, 0);
|
||||||
const actual = state.items
|
|
||||||
.filter((it) => live.history?.[it.id]?.endedAt)
|
|
||||||
.reduce((sum, it) => sum + (live.history[it.id].endedAt - live.history[it.id].startedAt) / 1000, 0);
|
|
||||||
const drift = actual - planned;
|
const drift = actual - planned;
|
||||||
if (planned && Math.abs(drift) >= 30) {
|
if (planned && Math.abs(drift) >= 30) {
|
||||||
clockLabelEl.textContent = `Running · ${drift > 0 ? 'behind' : 'ahead'} ${fmtDur(Math.abs(drift))}`;
|
clockLabelEl.textContent = `Running · ${drift > 0 ? 'behind' : 'ahead'} ${fmtDur(Math.abs(drift))}`;
|
||||||
@@ -191,12 +231,12 @@ export default function create({ root, moduleApi }) {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (plan.serviceStartsAt && plan.serviceStartsAt > now) {
|
if (on('showCountdown') && plan.serviceStartsAt && plan.serviceStartsAt > now) {
|
||||||
clockLabelEl.textContent = 'Starts in';
|
clockLabelEl.textContent = 'Starts in';
|
||||||
clockValueEl.textContent = fmtDur((plan.serviceStartsAt - now) / 1000);
|
clockValueEl.textContent = fmtDur((plan.serviceStartsAt - now) / 1000);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (plan.serviceStartsAt) {
|
if (on('showRunningClock') && plan.serviceStartsAt) {
|
||||||
clockLabelEl.textContent = 'Since start';
|
clockLabelEl.textContent = 'Since start';
|
||||||
clockValueEl.textContent = fmtDur((now - plan.serviceStartsAt) / 1000);
|
clockValueEl.textContent = fmtDur((now - plan.serviceStartsAt) / 1000);
|
||||||
}
|
}
|
||||||
@@ -210,15 +250,15 @@ export default function create({ root, moduleApi }) {
|
|||||||
if (!state.configured) {
|
if (!state.configured) {
|
||||||
return {
|
return {
|
||||||
title: 'Planning Center not connected',
|
title: 'Planning Center not connected',
|
||||||
hint: 'Add a Planning Center Application ID and Secret (Personal Access Token) for PCO Plan in Admin.',
|
hint: 'Enter the Planning Center credentials under PCO Plan in Admin.',
|
||||||
link: { href: '/admin', text: 'Open Admin' },
|
link: { href: '/admin', text: 'Open Admin' },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (!state.serviceTypeSet) {
|
if (!state.serviceTypeSet) {
|
||||||
return {
|
return {
|
||||||
title: 'No service type selected',
|
title: 'No service type selected',
|
||||||
hint: 'Browse your Planning Center folders and choose the service type to follow.',
|
hint: 'Pick the service type to follow from the list under PCO Plan in Admin.',
|
||||||
link: { href: `/modules/${moduleApi.id}/setup.html`, text: 'Open setup' },
|
link: { href: '/admin', text: 'Open Admin' },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (!state.reachable && !state.plan) {
|
if (!state.reachable && !state.plan) {
|
||||||
@@ -230,22 +270,32 @@ export default function create({ root, moduleApi }) {
|
|||||||
hint: `${state.serviceType?.name || 'This service type'} has no upcoming or recent plans in Planning Center.`,
|
hint: `${state.serviceType?.name || 'This service type'} has no upcoming or recent plans in Planning Center.`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (!visibleItems().length) return { title: 'Nothing to show', hint: 'Every item is hidden by this tile’s settings.' };
|
if (!visibleItems().length) return { title: 'Nothing to show', hint: 'Every item is hidden by the PCO Plan settings in Admin.' };
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildRow(item, p, notesWanted) {
|
function buildRow(item, number, notesWanted) {
|
||||||
const row = document.createElement('div');
|
const row = document.createElement('div');
|
||||||
row.className = 'pp-row';
|
row.className = 'pp-row';
|
||||||
row.dataset.id = item.id;
|
row.dataset.id = item.id;
|
||||||
if (item.itemType === 'header') row.classList.add('is-header');
|
if (item.itemType === 'header') row.classList.add('is-header');
|
||||||
|
else row.classList.add(`is-${item.itemType === 'song' || item.itemType === 'media' ? item.itemType : 'item'}`);
|
||||||
if (item.servicePosition !== 'during') row.classList.add(`is-${item.servicePosition}`);
|
if (item.servicePosition !== 'during') row.classList.add(`is-${item.servicePosition}`);
|
||||||
|
const isHeader = item.itemType === 'header';
|
||||||
|
|
||||||
|
if (on('showItemNumbers')) {
|
||||||
|
const numCol = document.createElement('div');
|
||||||
|
numCol.className = 'pp-col-num';
|
||||||
|
numCol.textContent = isHeader ? '' : String(number);
|
||||||
|
row.appendChild(numCol);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (on('showStartTimes')) {
|
||||||
const timeCol = document.createElement('div');
|
const timeCol = document.createElement('div');
|
||||||
timeCol.className = 'pp-col-time';
|
timeCol.className = 'pp-col-time';
|
||||||
timeCol.textContent = p.showStartTimes && item.startsAt && item.itemType !== 'header' ? fmtClock(item.startsAt) : '';
|
timeCol.textContent = item.startsAt && !isHeader ? fmtClock(item.startsAt) : '';
|
||||||
timeCol.hidden = !p.showStartTimes;
|
|
||||||
row.appendChild(timeCol);
|
row.appendChild(timeCol);
|
||||||
|
}
|
||||||
|
|
||||||
const main = document.createElement('div');
|
const main = document.createElement('div');
|
||||||
main.className = 'pp-col-main';
|
main.className = 'pp-col-main';
|
||||||
@@ -255,24 +305,27 @@ export default function create({ root, moduleApi }) {
|
|||||||
titleEl.className = 'pp-title';
|
titleEl.className = 'pp-title';
|
||||||
titleEl.textContent = item.title || item.song?.title || '(untitled)';
|
titleEl.textContent = item.title || item.song?.title || '(untitled)';
|
||||||
titleLine.appendChild(titleEl);
|
titleLine.appendChild(titleEl);
|
||||||
if (item.itemType !== 'header') {
|
if (!isHeader) {
|
||||||
const nowTag = document.createElement('span');
|
// Order after the title: key, NOW, type badge, arrangement.
|
||||||
nowTag.className = 'pp-now-tag';
|
if (on('showKey') && item.keyName) {
|
||||||
nowTag.textContent = 'Now';
|
|
||||||
titleLine.appendChild(nowTag);
|
|
||||||
if (p.showItemType && typeLabel(item.itemType)) {
|
|
||||||
const badge = document.createElement('span');
|
|
||||||
badge.className = `pp-badge is-${item.itemType}`;
|
|
||||||
badge.textContent = typeLabel(item.itemType);
|
|
||||||
titleLine.appendChild(badge);
|
|
||||||
}
|
|
||||||
if (p.showKey && item.keyName) {
|
|
||||||
const key = document.createElement('span');
|
const key = document.createElement('span');
|
||||||
key.className = 'pp-key';
|
key.className = 'pp-key';
|
||||||
key.textContent = item.keyName;
|
key.textContent = item.keyName;
|
||||||
titleLine.appendChild(key);
|
titleLine.appendChild(key);
|
||||||
}
|
}
|
||||||
if (p.showArrangement && item.arrangement) {
|
if (on('showNowIndicator')) {
|
||||||
|
const nowTag = document.createElement('span');
|
||||||
|
nowTag.className = 'pp-now-tag';
|
||||||
|
nowTag.textContent = 'Now';
|
||||||
|
titleLine.appendChild(nowTag);
|
||||||
|
}
|
||||||
|
if (on('showItemType') && typeLabel(item.itemType)) {
|
||||||
|
const badge = document.createElement('span');
|
||||||
|
badge.className = `pp-badge is-${item.itemType}`;
|
||||||
|
badge.textContent = typeLabel(item.itemType);
|
||||||
|
titleLine.appendChild(badge);
|
||||||
|
}
|
||||||
|
if (on('showArrangement') && item.arrangement) {
|
||||||
const arr = document.createElement('span');
|
const arr = document.createElement('span');
|
||||||
arr.className = 'pp-arr';
|
arr.className = 'pp-arr';
|
||||||
arr.textContent = item.arrangement;
|
arr.textContent = item.arrangement;
|
||||||
@@ -281,13 +334,19 @@ export default function create({ root, moduleApi }) {
|
|||||||
}
|
}
|
||||||
main.appendChild(titleLine);
|
main.appendChild(titleLine);
|
||||||
|
|
||||||
if (p.showDescription && item.description) {
|
if (!isHeader && on('showSongAuthor') && item.song && (item.song.author || item.song.ccli)) {
|
||||||
|
const author = document.createElement('div');
|
||||||
|
author.className = 'pp-author';
|
||||||
|
author.textContent = [item.song.author, item.song.ccli ? `CCLI ${item.song.ccli}` : ''].filter(Boolean).join(' · ');
|
||||||
|
main.appendChild(author);
|
||||||
|
}
|
||||||
|
if (on('showDescription') && item.description) {
|
||||||
const desc = document.createElement('div');
|
const desc = document.createElement('div');
|
||||||
desc.className = 'pp-desc';
|
desc.className = 'pp-desc';
|
||||||
desc.textContent = item.description;
|
desc.textContent = item.description;
|
||||||
main.appendChild(desc);
|
main.appendChild(desc);
|
||||||
}
|
}
|
||||||
if (p.showNotes && item.notes?.length) {
|
if (on('showNotes') && item.notes?.length) {
|
||||||
const notes = item.notes.filter(notesWanted);
|
const notes = item.notes.filter(notesWanted);
|
||||||
if (notes.length) {
|
if (notes.length) {
|
||||||
const notesEl = document.createElement('div');
|
const notesEl = document.createElement('div');
|
||||||
@@ -309,23 +368,25 @@ export default function create({ root, moduleApi }) {
|
|||||||
}
|
}
|
||||||
row.appendChild(main);
|
row.appendChild(main);
|
||||||
|
|
||||||
|
const wantRuntime = on('showItemRuntime') && state?.live?.following;
|
||||||
|
if (on('showLength') || wantRuntime) {
|
||||||
const lenCol = document.createElement('div');
|
const lenCol = document.createElement('div');
|
||||||
lenCol.className = 'pp-col-len';
|
lenCol.className = 'pp-col-len';
|
||||||
if (item.itemType !== 'header') {
|
if (!isHeader) {
|
||||||
if (p.showLength) {
|
if (on('showLength')) {
|
||||||
const len = document.createElement('div');
|
const len = document.createElement('div');
|
||||||
len.className = 'pp-len';
|
len.className = 'pp-len';
|
||||||
len.textContent = item.length ? fmtDur(item.length) : '';
|
len.textContent = item.length ? fmtDur(item.length) : '';
|
||||||
lenCol.appendChild(len);
|
lenCol.appendChild(len);
|
||||||
}
|
}
|
||||||
if (p.showRuntime && state?.live?.following) {
|
if (wantRuntime) {
|
||||||
const el = document.createElement('div');
|
const el = document.createElement('div');
|
||||||
el.className = 'pp-elapsed';
|
el.className = 'pp-elapsed';
|
||||||
lenCol.appendChild(el);
|
lenCol.appendChild(el);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
lenCol.hidden = !p.showLength && !(p.showRuntime && state?.live?.following);
|
|
||||||
row.appendChild(lenCol);
|
row.appendChild(lenCol);
|
||||||
|
}
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -359,15 +420,18 @@ export default function create({ root, moduleApi }) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rebuild rows only when the plan content or the display prefs changed;
|
// Rebuild rows only when the plan content or the display settings
|
||||||
// live highlighting and timers are applied in place.
|
// changed; live highlighting and timers are applied in place.
|
||||||
const p = prefs();
|
const key = JSON.stringify([state.plan.id, state.items, cfg(), state.live?.following]);
|
||||||
const key = JSON.stringify([state.plan.id, state.items, p, state.live?.following]);
|
|
||||||
if (key !== lastListKey) {
|
if (key !== lastListKey) {
|
||||||
lastListKey = key;
|
lastListKey = key;
|
||||||
listEl.innerHTML = '';
|
listEl.innerHTML = '';
|
||||||
const notesWanted = noteFilter();
|
const notesWanted = noteFilter();
|
||||||
for (const item of visibleItems()) listEl.appendChild(buildRow(item, p, notesWanted));
|
let number = 0;
|
||||||
|
for (const item of visibleItems()) {
|
||||||
|
if (item.itemType !== 'header') number += 1;
|
||||||
|
listEl.appendChild(buildRow(item, number, notesWanted));
|
||||||
|
}
|
||||||
lastCurrentId = null; // force the highlight + scroll pass
|
lastCurrentId = null; // force the highlight + scroll pass
|
||||||
}
|
}
|
||||||
renderLiveMarks();
|
renderLiveMarks();
|
||||||
@@ -376,14 +440,11 @@ export default function create({ root, moduleApi }) {
|
|||||||
/** Current / done classes and the elapsed timers, without rebuilding rows. */
|
/** Current / done classes and the elapsed timers, without rebuilding rows. */
|
||||||
function renderLiveMarks() {
|
function renderLiveMarks() {
|
||||||
const live = state?.live || {};
|
const live = state?.live || {};
|
||||||
const p = prefs();
|
|
||||||
const now = serverNow();
|
const now = serverNow();
|
||||||
const currentId = live.following ? live.currentItemId || '' : '';
|
const currentId = live.following ? live.currentItemId || '' : '';
|
||||||
const history = live.history || {};
|
const history = live.history || {};
|
||||||
const byId = new Map(state.items.map((it) => [it.id, it]));
|
const byId = new Map(state.items.map((it) => [it.id, it]));
|
||||||
const rows = listEl.querySelectorAll('.pp-row');
|
|
||||||
// Everything before the current item counts as done, matched or not.
|
// Everything before the current item counts as done, matched or not.
|
||||||
let seenCurrent = !currentId;
|
|
||||||
const doneIds = new Set();
|
const doneIds = new Set();
|
||||||
if (currentId) {
|
if (currentId) {
|
||||||
for (const it of state.items) {
|
for (const it of state.items) {
|
||||||
@@ -391,13 +452,12 @@ export default function create({ root, moduleApi }) {
|
|||||||
if (it.itemType !== 'header') doneIds.add(it.id);
|
if (it.itemType !== 'header') doneIds.add(it.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const row of rows) {
|
const showNow = on('showNowIndicator');
|
||||||
|
for (const row of listEl.querySelectorAll('.pp-row')) {
|
||||||
const id = row.dataset.id;
|
const id = row.dataset.id;
|
||||||
const item = byId.get(id);
|
const item = byId.get(id);
|
||||||
const isCurrent = id === currentId;
|
row.classList.toggle('is-current', showNow && id === currentId);
|
||||||
row.classList.toggle('is-current', isCurrent);
|
row.classList.toggle('is-done', on('dimCompleted') && doneIds.has(id));
|
||||||
row.classList.toggle('is-done', p.dimCompleted && doneIds.has(id));
|
|
||||||
if (isCurrent) seenCurrent = true;
|
|
||||||
const elapsedEl = row.querySelector('.pp-elapsed');
|
const elapsedEl = row.querySelector('.pp-elapsed');
|
||||||
if (!elapsedEl || !item) continue;
|
if (!elapsedEl || !item) continue;
|
||||||
const h = history[id];
|
const h = history[id];
|
||||||
@@ -415,10 +475,9 @@ export default function create({ root, moduleApi }) {
|
|||||||
}
|
}
|
||||||
if (!h.endedAt) elapsedEl.classList.add('is-running');
|
if (!h.endedAt) elapsedEl.classList.add('is-running');
|
||||||
}
|
}
|
||||||
void seenCurrent;
|
|
||||||
if (currentId !== lastCurrentId) {
|
if (currentId !== lastCurrentId) {
|
||||||
lastCurrentId = currentId;
|
lastCurrentId = currentId;
|
||||||
if (currentId && p.autoScroll && Date.now() - userScrolledAt > 8000) {
|
if (currentId && tilePrefs().autoScroll !== false && Date.now() - userScrolledAt > 8000) {
|
||||||
const row = listEl.querySelector(`.pp-row[data-id="${CSS.escape(currentId)}"]`);
|
const row = listEl.querySelector(`.pp-row[data-id="${CSS.escape(currentId)}"]`);
|
||||||
row?.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
row?.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||||||
}
|
}
|
||||||
@@ -429,7 +488,7 @@ export default function create({ root, moduleApi }) {
|
|||||||
|
|
||||||
function renderFoot() {
|
function renderFoot() {
|
||||||
const live = state?.live;
|
const live = state?.live;
|
||||||
const show = prefs().showLiveFooter && live?.following && state?.plan;
|
const show = on('showLiveFooter') && live?.following && state?.plan;
|
||||||
footEl.hidden = !show;
|
footEl.hidden = !show;
|
||||||
footEl.className = 'pp-foot';
|
footEl.className = 'pp-foot';
|
||||||
if (!show) return;
|
if (!show) return;
|
||||||
@@ -466,6 +525,9 @@ export default function create({ root, moduleApi }) {
|
|||||||
|
|
||||||
function render() {
|
function render() {
|
||||||
wrap.classList.toggle('is-offline', feedOffline);
|
wrap.classList.toggle('is-offline', feedOffline);
|
||||||
|
wrap.classList.toggle('tint-types', Boolean(cfg().tintByType));
|
||||||
|
const keyColor = String(cfg().keyColor || '').trim();
|
||||||
|
wrap.style.setProperty('--pp-key', /^[#a-zA-Z0-9(),.%\s-]+$/.test(keyColor) ? keyColor : '#4ea1ff');
|
||||||
renderStatus();
|
renderStatus();
|
||||||
if (!state) {
|
if (!state) {
|
||||||
headEl.hidden = true;
|
headEl.hidden = true;
|
||||||
@@ -479,7 +541,7 @@ export default function create({ root, moduleApi }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function applyPrefs() {
|
function applyPrefs() {
|
||||||
const size = Math.min(40, Math.max(9, Number(prefs().textSize) || 14));
|
const size = Math.min(40, Math.max(9, Number(tilePrefs().textSize) || 14));
|
||||||
wrap.style.setProperty('--pp-size', `${size}px`);
|
wrap.style.setProperty('--pp-size', `${size}px`);
|
||||||
lastListKey = '';
|
lastListKey = '';
|
||||||
render();
|
render();
|
||||||
@@ -488,7 +550,7 @@ export default function create({ root, moduleApi }) {
|
|||||||
function tick() {
|
function tick() {
|
||||||
if (!state?.plan) return;
|
if (!state?.plan) return;
|
||||||
renderClock();
|
renderClock();
|
||||||
if (state.live?.following && prefs().showRuntime) renderLiveMarks();
|
if (state.live?.following && on('showItemRuntime')) renderLiveMarks();
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── live feed ──────────────────────────────────────────────────── */
|
/* ── live feed ──────────────────────────────────────────────────── */
|
||||||
@@ -550,7 +612,8 @@ export default function create({ root, moduleApi }) {
|
|||||||
root.innerHTML = '';
|
root.innerHTML = '';
|
||||||
},
|
},
|
||||||
onConfigChange() {
|
onConfigChange() {
|
||||||
// The server re-inits and its stream re-sends the state; nothing to cache here.
|
// Display settings changed in Admin: re-render from the new
|
||||||
|
// moduleApi.config. The server re-inits too and re-sends its state.
|
||||||
lastListKey = '';
|
lastListKey = '';
|
||||||
render();
|
render();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,38 +1,58 @@
|
|||||||
{
|
{
|
||||||
"id": "pco-plan",
|
"id": "pco-plan",
|
||||||
"name": "PCO Plan",
|
"name": "PCO Plan",
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"description": "Order of service from Planning Center Services, following ProPresenter live. Pick the service type at /modules/pco-plan/setup.html",
|
"description": "Order of service from Planning Center Services, following ProPresenter live",
|
||||||
"client": "client.js",
|
"client": "client.js",
|
||||||
"server": "server.js",
|
"server": "server.js",
|
||||||
"style": "style.css",
|
"style": "style.css",
|
||||||
"minSize": { "w": 3, "h": 3 },
|
"minSize": { "w": 3, "h": 3 },
|
||||||
"defaultSize": { "w": 5, "h": 6 },
|
"defaultSize": { "w": 5, "h": 6 },
|
||||||
|
"configGroups": {
|
||||||
|
"Planning Center credentials": {
|
||||||
|
"collapsed": "whenSet",
|
||||||
|
"help": "Create a Personal Access Token at api.planningcenteronline.com (Application ID + Secret), or paste an OAuth 2.0 access token instead. Saved secrets never reach browsers."
|
||||||
|
},
|
||||||
|
"Show / hide": {
|
||||||
|
"collapsed": true,
|
||||||
|
"columns": 3,
|
||||||
|
"help": "Untick anything the tiles should not show. Applies to every PCO Plan tile."
|
||||||
|
}
|
||||||
|
},
|
||||||
"configSchema": {
|
"configSchema": {
|
||||||
"appId": {
|
"appId": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"label": "Planning Center Application ID (Personal Access Token)",
|
"label": "Application ID",
|
||||||
"default": ""
|
"default": "",
|
||||||
|
"group": "Planning Center credentials"
|
||||||
},
|
},
|
||||||
"secret": {
|
"secret": {
|
||||||
"type": "password",
|
"type": "password",
|
||||||
"label": "Planning Center Secret (Personal Access Token)",
|
"label": "Secret",
|
||||||
"default": ""
|
"default": "",
|
||||||
|
"group": "Planning Center credentials"
|
||||||
},
|
},
|
||||||
"accessToken": {
|
"accessToken": {
|
||||||
"type": "password",
|
"type": "password",
|
||||||
"label": "OAuth 2.0 access token (alternative to App ID + Secret)",
|
"label": "OAuth 2.0 access token (instead of Application ID + Secret)",
|
||||||
"default": ""
|
"default": "",
|
||||||
|
"group": "Planning Center credentials"
|
||||||
},
|
},
|
||||||
"serviceTypeId": {
|
"serviceTypeId": {
|
||||||
"type": "string",
|
"type": "select",
|
||||||
"label": "Service type ID — browse folders and pick one at /modules/pco-plan/setup.html",
|
"label": "Service type",
|
||||||
"default": ""
|
"default": "",
|
||||||
|
"optionsRoute": "/service-types",
|
||||||
|
"help": "Your Planning Center Services folders appear as groups in this list; pick the service type whose plans the tiles follow. Save the credentials first if the list is empty."
|
||||||
},
|
},
|
||||||
"planId": {
|
"planId": {
|
||||||
"type": "string",
|
"type": "select",
|
||||||
"label": "Pin a plan ID (blank = today's plan, else the next upcoming)",
|
"label": "Plan",
|
||||||
"default": ""
|
"default": "",
|
||||||
|
"options": [{ "value": "", "label": "Automatic — today's plan, else the next upcoming" }],
|
||||||
|
"optionsRoute": "/plans",
|
||||||
|
"optionsDependsOn": "serviceTypeId",
|
||||||
|
"help": "Leave on Automatic for services. Pin a specific plan for rehearsals or to review a past service."
|
||||||
},
|
},
|
||||||
"pollSeconds": {
|
"pollSeconds": {
|
||||||
"type": "number",
|
"type": "number",
|
||||||
@@ -40,28 +60,63 @@
|
|||||||
"default": 30
|
"default": 30
|
||||||
},
|
},
|
||||||
"followProPresenter": {
|
"followProPresenter": {
|
||||||
"type": "boolean",
|
"type": "switch",
|
||||||
"label": "Follow the ProPresenter module's live item to highlight the current plan item",
|
"label": "Follow the ProPresenter module's live item to highlight the current plan item and time it",
|
||||||
"default": true
|
"default": true
|
||||||
}
|
},
|
||||||
|
"noteCategories": {
|
||||||
|
"type": "string",
|
||||||
|
"label": "Item note categories to show (comma-separated, blank = all)",
|
||||||
|
"default": ""
|
||||||
|
},
|
||||||
|
"headerTemplate": {
|
||||||
|
"type": "string",
|
||||||
|
"label": "Top bar text",
|
||||||
|
"default": "{series} • {part} | {serviceType} • {date}",
|
||||||
|
"group": "Top bar",
|
||||||
|
"help": "Placeholders: {series} series name · {part} plan title / part number · {serviceType} · {date} · {shortDate} · {time} service start time · {length} planned length · {items} item count. Use • between items (an item whose placeholders are empty or hidden is dropped) and | to start a second line."
|
||||||
|
},
|
||||||
|
"tintByType": {
|
||||||
|
"type": "boolean",
|
||||||
|
"label": "Background color per item type (song / media / other)",
|
||||||
|
"default": false,
|
||||||
|
"group": "Top bar"
|
||||||
|
},
|
||||||
|
"keyColor": {
|
||||||
|
"type": "string",
|
||||||
|
"label": "Song key color",
|
||||||
|
"default": "#4ea1ff",
|
||||||
|
"group": "Top bar",
|
||||||
|
"help": "Any CSS color, e.g. #4ea1ff or dodgerblue."
|
||||||
|
},
|
||||||
|
"showTopBar": { "type": "boolean", "label": "Top bar", "default": true, "group": "Show / hide" },
|
||||||
|
"showSeries": { "type": "boolean", "label": "Series name", "default": true, "group": "Show / hide" },
|
||||||
|
"showPart": { "type": "boolean", "label": "Part # / plan title", "default": true, "group": "Show / hide" },
|
||||||
|
"showServiceType": { "type": "boolean", "label": "Service type", "default": true, "group": "Show / hide" },
|
||||||
|
"showDate": { "type": "boolean", "label": "Plan date", "default": true, "group": "Show / hide" },
|
||||||
|
"showServiceTime": { "type": "boolean", "label": "Service time", "default": true, "group": "Show / hide" },
|
||||||
|
"showTotalLength": { "type": "boolean", "label": "Planned total length", "default": true, "group": "Show / hide" },
|
||||||
|
"showRunningClock": { "type": "boolean", "label": "Running clock (ahead / behind)", "default": true, "group": "Show / hide" },
|
||||||
|
"showCountdown": { "type": "boolean", "label": "Countdown to service start", "default": true, "group": "Show / hide" },
|
||||||
|
"showHeaders": { "type": "boolean", "label": "Section headers", "default": true, "group": "Show / hide" },
|
||||||
|
"showItemNumbers": { "type": "boolean", "label": "Item numbers", "default": false, "group": "Show / hide" },
|
||||||
|
"showStartTimes": { "type": "boolean", "label": "Scheduled start times", "default": true, "group": "Show / hide" },
|
||||||
|
"showLength": { "type": "boolean", "label": "Item length", "default": true, "group": "Show / hide" },
|
||||||
|
"showItemType": { "type": "boolean", "label": "Item type badge", "default": true, "group": "Show / hide" },
|
||||||
|
"showKey": { "type": "boolean", "label": "Song key", "default": true, "group": "Show / hide" },
|
||||||
|
"showArrangement": { "type": "boolean", "label": "Song arrangement", "default": false, "group": "Show / hide" },
|
||||||
|
"showSongAuthor": { "type": "boolean", "label": "Song author / CCLI", "default": false, "group": "Show / hide" },
|
||||||
|
"showDescription": { "type": "boolean", "label": "Item description", "default": true, "group": "Show / hide" },
|
||||||
|
"showNotes": { "type": "boolean", "label": "Item notes", "default": true, "group": "Show / hide" },
|
||||||
|
"showPreService": { "type": "boolean", "label": "Pre-service items", "default": true, "group": "Show / hide" },
|
||||||
|
"showPostService": { "type": "boolean", "label": "Post-service items", "default": true, "group": "Show / hide" },
|
||||||
|
"showNowIndicator": { "type": "boolean", "label": "Now indicator", "default": true, "group": "Show / hide" },
|
||||||
|
"showItemRuntime": { "type": "boolean", "label": "Item runtime (elapsed vs planned)", "default": true, "group": "Show / hide" },
|
||||||
|
"dimCompleted": { "type": "boolean", "label": "Dim completed items", "default": true, "group": "Show / hide" },
|
||||||
|
"showLiveFooter": { "type": "boolean", "label": "ProPresenter match line", "default": true, "group": "Show / hide" }
|
||||||
},
|
},
|
||||||
"instanceSchema": {
|
"instanceSchema": {
|
||||||
"showPlanHeader": { "type": "boolean", "label": "Show plan title, date and service time", "default": true },
|
"textSize": { "type": "number", "label": "Text size (px)", "default": 14 },
|
||||||
"showStartTimes": { "type": "boolean", "label": "Show scheduled start time of each item", "default": true },
|
"autoScroll": { "type": "boolean", "label": "Keep the current item in view", "default": true }
|
||||||
"showLength": { "type": "boolean", "label": "Show planned length of each item", "default": true },
|
|
||||||
"showItemType": { "type": "boolean", "label": "Show item type badge (song / media)", "default": true },
|
|
||||||
"showKey": { "type": "boolean", "label": "Show song key", "default": true },
|
|
||||||
"showArrangement": { "type": "boolean", "label": "Show song arrangement", "default": false },
|
|
||||||
"showDescription": { "type": "boolean", "label": "Show item description", "default": true },
|
|
||||||
"showNotes": { "type": "boolean", "label": "Show item notes", "default": true },
|
|
||||||
"noteCategories": { "type": "string", "label": "Only these note categories (comma-separated, blank = all)", "default": "" },
|
|
||||||
"showHeaders": { "type": "boolean", "label": "Show section headers", "default": true },
|
|
||||||
"showPreService": { "type": "boolean", "label": "Show pre-service items", "default": true },
|
|
||||||
"showPostService": { "type": "boolean", "label": "Show post-service items", "default": true },
|
|
||||||
"showRuntime": { "type": "boolean", "label": "Show live runtime (elapsed vs planned) when following ProPresenter", "default": true },
|
|
||||||
"showLiveFooter": { "type": "boolean", "label": "Show the ProPresenter match line at the bottom", "default": true },
|
|
||||||
"dimCompleted": { "type": "boolean", "label": "Dim items that are already done", "default": true },
|
|
||||||
"autoScroll": { "type": "boolean", "label": "Keep the current item in view", "default": true },
|
|
||||||
"textSize": { "type": "number", "label": "Text size (px)", "default": 14 }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+75
-45
@@ -10,8 +10,9 @@
|
|||||||
* GET /state → { state, now } (instant paint for new tiles)
|
* GET /state → { state, now } (instant paint for new tiles)
|
||||||
* GET /stream → SSE, `state` events (change-detected, heartbeated)
|
* GET /stream → SSE, `state` events (change-detected, heartbeated)
|
||||||
* POST /refresh → re-read the plan now
|
* POST /refresh → re-read the plan now
|
||||||
* GET /browse → folders + service types, for the setup page
|
* GET /service-types → { options } for the admin "Service type" select,
|
||||||
* GET /preview?serviceTypeId=… → which plan the module would pick
|
* grouped by Planning Center folder path
|
||||||
|
* GET /plans?serviceTypeId=… → { options } for the admin "Plan" select
|
||||||
*
|
*
|
||||||
* Planning Center API v2 (https://api.planningcenteronline.com/services/v2):
|
* Planning Center API v2 (https://api.planningcenteronline.com/services/v2):
|
||||||
* Auth: Personal Access Token (HTTP Basic app_id:secret) or an OAuth 2.0
|
* Auth: Personal Access Token (HTTP Basic app_id:secret) or an OAuth 2.0
|
||||||
@@ -392,20 +393,24 @@ module.exports = {
|
|||||||
return `${SERVICES}/service_types/${encodeURIComponent(serviceTypeId)}/plans/${encodeURIComponent(planId)}`;
|
return `${SERVICES}/service_types/${encodeURIComponent(serviceTypeId)}/plans/${encodeURIComponent(planId)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Today's plan, else the next upcoming one, else the most recent past one. */
|
/** The plans around now for a service type: a few upcoming, a few recent, oldest first. */
|
||||||
async function choosePlanId(stId) {
|
async function listPlans(stId, { future = 5, past = 3 } = {}) {
|
||||||
const base = `${SERVICES}/service_types/${encodeURIComponent(stId)}/plans`;
|
const base = `${SERVICES}/service_types/${encodeURIComponent(stId)}/plans`;
|
||||||
const [future, past] = await Promise.all([
|
const [fut, pst] = await Promise.all([
|
||||||
pco.get(base, { filter: 'future', order: 'sort_date', per_page: 5 }),
|
pco.get(base, { filter: 'future', order: 'sort_date', per_page: future }),
|
||||||
pco.get(base, { filter: 'past', order: '-sort_date', per_page: 3 }),
|
pco.get(base, { filter: 'past', order: '-sort_date', per_page: past }),
|
||||||
]);
|
]);
|
||||||
const byId = new Map();
|
const byId = new Map();
|
||||||
for (const rec of [...(future?.data || []), ...(past?.data || [])]) {
|
for (const rec of [...(fut?.data || []), ...(pst?.data || [])]) {
|
||||||
const sortDate = Date.parse(rec?.attributes?.sort_date || '');
|
const sortDate = Date.parse(rec?.attributes?.sort_date || '');
|
||||||
if (!rec?.id || !Number.isFinite(sortDate)) continue;
|
if (!rec?.id || !Number.isFinite(sortDate)) continue;
|
||||||
byId.set(String(rec.id), { rec, sortDate });
|
byId.set(String(rec.id), { rec, sortDate });
|
||||||
}
|
}
|
||||||
const plans = Array.from(byId.values()).sort((x, y) => x.sortDate - y.sortDate);
|
return Array.from(byId.values()).sort((x, y) => x.sortDate - y.sortDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Today's plan, else the next upcoming one, else the most recent past one. */
|
||||||
|
function pickPlan(plans) {
|
||||||
if (!plans.length) return null;
|
if (!plans.length) return null;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const today = localYmd(new Date(now));
|
const today = localYmd(new Date(now));
|
||||||
@@ -416,6 +421,10 @@ module.exports = {
|
|||||||
return plans[plans.length - 1].rec;
|
return plans[plans.length - 1].rec;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function choosePlanId(stId) {
|
||||||
|
return pickPlan(await listPlans(stId));
|
||||||
|
}
|
||||||
|
|
||||||
async function readPlanTimes(planId) {
|
async function readPlanTimes(planId) {
|
||||||
const body = await pco.getAll(`${planPath(planId)}/plan_times`);
|
const body = await pco.getAll(`${planPath(planId)}/plan_times`);
|
||||||
return (body.data || [])
|
return (body.data || [])
|
||||||
@@ -694,7 +703,7 @@ module.exports = {
|
|||||||
state.lastError = 'No Planning Center credentials — add an Application ID + Secret (or OAuth token) in /admin';
|
state.lastError = 'No Planning Center credentials — add an Application ID + Secret (or OAuth token) in /admin';
|
||||||
log(state.lastError);
|
log(state.lastError);
|
||||||
} else if (!serviceTypeId) {
|
} else if (!serviceTypeId) {
|
||||||
state.lastError = 'No service type selected — open /modules/pco-plan/setup.html';
|
state.lastError = 'No service type selected — choose one under PCO Plan in /admin';
|
||||||
log(state.lastError);
|
log(state.lastError);
|
||||||
} else {
|
} else {
|
||||||
log(`reading service type ${serviceTypeId} from ${API_BASE} (${pco.method})${pinnedPlanId ? `, plan ${pinnedPlanId} pinned` : ''}`);
|
log(`reading service type ${serviceTypeId} from ${API_BASE} (${pco.method})${pinnedPlanId ? `, plan ${pinnedPlanId} pinned` : ''}`);
|
||||||
@@ -725,7 +734,8 @@ module.exports = {
|
|||||||
clearTimeout(pollTimer);
|
clearTimeout(pollTimer);
|
||||||
return poll();
|
return poll();
|
||||||
},
|
},
|
||||||
choosePlanId,
|
listPlans,
|
||||||
|
pickPlan,
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -743,7 +753,7 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
health() {
|
health() {
|
||||||
if (!pco.configured) return { status: 'error', message: 'No Planning Center credentials configured' };
|
if (!pco.configured) return { status: 'error', message: 'No Planning Center credentials configured' };
|
||||||
if (!serviceTypeId) return { status: 'error', message: 'No service type selected — open /modules/pco-plan/setup.html' };
|
if (!serviceTypeId) return { status: 'error', message: 'No service type selected — pick one in the Service type list below' };
|
||||||
if (!state.reachable) {
|
if (!state.reachable) {
|
||||||
return { status: state.lastFetched ? 'error' : 'connecting', message: state.lastError || 'Reading the plan…' };
|
return { status: state.lastFetched ? 'error' : 'connecting', message: state.lastError || 'Reading the plan…' };
|
||||||
}
|
}
|
||||||
@@ -793,13 +803,15 @@ module.exports = {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Folders + service types as flat lists; the setup page builds the tree. */
|
/**
|
||||||
'GET /browse': async (req, res) => {
|
* Options for the admin page's "Service type" select: every service
|
||||||
|
* type, grouped by its Planning Center folder path — so the folders
|
||||||
|
* are browsable as <optgroup>s right in the admin form.
|
||||||
|
*/
|
||||||
|
'GET /service-types': async (req, res) => {
|
||||||
if (!current) return json(res, 503, { error: 'Module not running.' });
|
if (!current) return json(res, 503, { error: 'Module not running.' });
|
||||||
const { pco } = current;
|
const { pco } = current;
|
||||||
if (!pco.configured) {
|
if (!pco.configured) return json(res, 200, { options: [], error: 'No Planning Center credentials yet.' });
|
||||||
return json(res, 400, { error: 'No Planning Center credentials — add them in /admin first.', unconfigured: true });
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
const [folderBody, stBody] = await Promise.all([
|
const [folderBody, stBody] = await Promise.all([
|
||||||
pco.getAll(`${SERVICES}/folders`),
|
pco.getAll(`${SERVICES}/folders`),
|
||||||
@@ -838,44 +850,62 @@ module.exports = {
|
|||||||
folders.set(id, { id, name: `Folder ${id}`, parentId: '' });
|
folders.set(id, { id, name: `Folder ${id}`, parentId: '' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const state = current.getState();
|
// "Campuses › North Campus" — the folder path from the root down.
|
||||||
json(res, 200, {
|
const pathOf = (parentId) => {
|
||||||
folders: Array.from(folders.values()),
|
const names = [];
|
||||||
serviceTypes,
|
let id = parentId;
|
||||||
selected: state.serviceType.id,
|
const seen = new Set();
|
||||||
selectedName: state.serviceType.name,
|
while (id && folders.has(id) && !seen.has(id)) {
|
||||||
apiBase: API_BASE,
|
seen.add(id);
|
||||||
method: pco.method,
|
names.unshift(folders.get(id).name);
|
||||||
});
|
id = folders.get(id).parentId;
|
||||||
|
}
|
||||||
|
return names.join(' › ');
|
||||||
|
};
|
||||||
|
const options = serviceTypes
|
||||||
|
.map((st) => ({ value: st.id, label: st.name, group: pathOf(st.parentId) }))
|
||||||
|
.sort((a, b) => a.group.localeCompare(b.group) || a.label.localeCompare(b.label));
|
||||||
|
json(res, 200, { options });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log(`browse failed: ${err?.message || err}`);
|
log(`service type list failed: ${err?.message || err}`);
|
||||||
json(res, err?.status === 401 ? 401 : 502, { error: err?.message || String(err) });
|
json(res, err?.status === 401 ? 401 : 502, { options: [], error: err?.message || String(err) });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Which plan would be followed for a service type — shown before saving. */
|
/**
|
||||||
'GET /preview': async (req, res) => {
|
* Options for the admin page's "Plan" select, for the service type the
|
||||||
|
* admin currently has picked (?serviceTypeId=…): the upcoming and recent
|
||||||
|
* plans, with the one Automatic would choose marked.
|
||||||
|
*/
|
||||||
|
'GET /plans': async (req, res) => {
|
||||||
if (!current) return json(res, 503, { error: 'Module not running.' });
|
if (!current) return json(res, 503, { error: 'Module not running.' });
|
||||||
|
const { pco } = current;
|
||||||
const params = new URLSearchParams(req.search || '');
|
const params = new URLSearchParams(req.search || '');
|
||||||
const stId = String(params.get('serviceTypeId') || '').trim();
|
const stId = String(params.get('serviceTypeId') || '').trim();
|
||||||
if (!stId) return json(res, 400, { error: 'serviceTypeId is required.' });
|
const auto = { value: '', label: 'Automatic — today\'s plan, else the next upcoming' };
|
||||||
|
if (!pco.configured || !stId) return json(res, 200, { options: [auto] });
|
||||||
try {
|
try {
|
||||||
const rec = await current.choosePlanId(stId);
|
const plans = await current.listPlans(stId, { future: 8, past: 6 });
|
||||||
if (!rec) return json(res, 200, { plan: null });
|
const picked = current.pickPlan(plans);
|
||||||
|
const now = Date.now();
|
||||||
|
const options = [auto];
|
||||||
|
// Upcoming first (soonest at the top), then the recent ones.
|
||||||
|
const ordered = [
|
||||||
|
...plans.filter((p) => p.sortDate >= now - 12 * 3600000),
|
||||||
|
...plans.filter((p) => p.sortDate < now - 12 * 3600000).reverse(),
|
||||||
|
];
|
||||||
|
for (const { rec, sortDate } of ordered) {
|
||||||
const a = rec.attributes || {};
|
const a = rec.attributes || {};
|
||||||
json(res, 200, {
|
const bits = [String(a.dates || a.short_dates || new Date(sortDate).toLocaleDateString())];
|
||||||
plan: {
|
if (a.series_title) bits.push(String(a.series_title));
|
||||||
id: String(rec.id),
|
if (a.title) bits.push(String(a.title));
|
||||||
title: String(a.title || ''),
|
let label = bits.join(' — ');
|
||||||
seriesTitle: String(a.series_title || ''),
|
if (picked && rec.id === picked.id) label += ' (Automatic picks this)';
|
||||||
dates: String(a.dates || ''),
|
options.push({ value: String(rec.id), label, group: sortDate >= now - 12 * 3600000 ? 'Upcoming' : 'Recent' });
|
||||||
itemsCount: Number(a.items_count) || 0,
|
}
|
||||||
totalLength: Number(a.total_length) || 0,
|
json(res, 200, { options });
|
||||||
sortDate: a.sort_date || '',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
json(res, err?.status === 401 ? 401 : 502, { error: err?.message || String(err) });
|
json(res, err?.status === 401 ? 401 : 502, { options: [auto], error: err?.message || String(err) });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
+70
-26
@@ -1,8 +1,10 @@
|
|||||||
/* PCO Plan module — scoped to its tiles, shell variables only.
|
/* PCO Plan module — scoped to its tiles, shell variables only (plus the
|
||||||
Everything scales from --pp-size (set from the tile's text-size setting). */
|
admin-chosen --pp-key color for song keys). Everything scales from
|
||||||
|
--pp-size (set from the tile's text-size setting). */
|
||||||
|
|
||||||
[data-module="pco-plan"] .pp-wrap {
|
[data-module="pco-plan"] .pp-wrap {
|
||||||
--pp-size: 14px;
|
--pp-size: 14px;
|
||||||
|
--pp-key: #4ea1ff;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -11,7 +13,7 @@
|
|||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── plan header ── */
|
/* ── top bar ── */
|
||||||
|
|
||||||
[data-module="pco-plan"] .pp-head {
|
[data-module="pco-plan"] .pp-head {
|
||||||
flex: none;
|
flex: none;
|
||||||
@@ -28,8 +30,9 @@
|
|||||||
[data-module="pco-plan"] .pp-plan { flex: 1; min-width: 0; }
|
[data-module="pco-plan"] .pp-plan { flex: 1; min-width: 0; }
|
||||||
|
|
||||||
[data-module="pco-plan"] .pp-plan-title {
|
[data-module="pco-plan"] .pp-plan-title {
|
||||||
font-size: 1.05em;
|
font-size: 1.1em;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
line-height: 1.25;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
@@ -37,12 +40,13 @@
|
|||||||
|
|
||||||
[data-module="pco-plan"] .pp-plan-sub {
|
[data-module="pco-plan"] .pp-plan-sub {
|
||||||
margin-top: 0.15em;
|
margin-top: 0.15em;
|
||||||
font-size: 0.8em;
|
font-size: 0.82em;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
[data-module="pco-plan"] .pp-plan-sub[hidden] { display: none; }
|
||||||
|
|
||||||
[data-module="pco-plan"] .pp-clock {
|
[data-module="pco-plan"] .pp-clock {
|
||||||
flex: none;
|
flex: none;
|
||||||
@@ -91,22 +95,36 @@
|
|||||||
[data-module="pco-plan"] .pp-row + .pp-row { margin-top: 0.15em; }
|
[data-module="pco-plan"] .pp-row + .pp-row { margin-top: 0.15em; }
|
||||||
[data-module="pco-plan"] .pp-row:hover { background: color-mix(in srgb, var(--text) 4%, transparent); }
|
[data-module="pco-plan"] .pp-row:hover { background: color-mix(in srgb, var(--text) 4%, transparent); }
|
||||||
|
|
||||||
|
/* Optional background tint per item type (admin "Background color per item type"). */
|
||||||
|
[data-module="pco-plan"] .tint-types .pp-row.is-song { background: color-mix(in srgb, var(--accent) 9%, transparent); }
|
||||||
|
[data-module="pco-plan"] .tint-types .pp-row.is-media { background: color-mix(in srgb, var(--warn) 10%, transparent); }
|
||||||
|
[data-module="pco-plan"] .tint-types .pp-row.is-item { background: color-mix(in srgb, var(--text) 5%, transparent); }
|
||||||
|
[data-module="pco-plan"] .tint-types .pp-row.is-song:hover { background: color-mix(in srgb, var(--accent) 14%, transparent); }
|
||||||
|
[data-module="pco-plan"] .tint-types .pp-row.is-media:hover { background: color-mix(in srgb, var(--warn) 15%, transparent); }
|
||||||
|
[data-module="pco-plan"] .tint-types .pp-row.is-item:hover { background: color-mix(in srgb, var(--text) 8%, transparent); }
|
||||||
|
|
||||||
|
/* Section headers: a full-width band so a new part of the service is
|
||||||
|
unmistakable at a glance — accent bar, tinted ground, spaced capitals. */
|
||||||
[data-module="pco-plan"] .pp-row.is-header {
|
[data-module="pco-plan"] .pp-row.is-header {
|
||||||
margin-top: 0.6em;
|
margin-top: 0.9em;
|
||||||
padding-top: 0.5em;
|
padding: 0.5em 0.7em 0.45em;
|
||||||
padding-bottom: 0.15em;
|
align-items: center;
|
||||||
border-top: 1px solid var(--border);
|
border-left: 3px solid var(--accent);
|
||||||
border-radius: 0;
|
border-radius: 0.4em;
|
||||||
background: none;
|
background: linear-gradient(90deg, color-mix(in srgb, var(--accent) 22%, transparent), color-mix(in srgb, var(--accent) 5%, transparent) 70%, transparent);
|
||||||
}
|
}
|
||||||
[data-module="pco-plan"] .pp-row.is-header:first-child { margin-top: 0; border-top: none; }
|
[data-module="pco-plan"] .pp-row.is-header:first-child { margin-top: 0.15em; }
|
||||||
|
[data-module="pco-plan"] .pp-row.is-header:hover { background: linear-gradient(90deg, color-mix(in srgb, var(--accent) 26%, transparent), color-mix(in srgb, var(--accent) 7%, transparent) 70%, transparent); }
|
||||||
[data-module="pco-plan"] .pp-row.is-header .pp-title {
|
[data-module="pco-plan"] .pp-row.is-header .pp-title {
|
||||||
font-size: 0.78em;
|
font-size: 0.85em;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
letter-spacing: 0.12em;
|
letter-spacing: 0.16em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--muted);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
[data-module="pco-plan"] .pp-row.is-header .pp-col-time,
|
||||||
|
[data-module="pco-plan"] .pp-row.is-header .pp-col-num,
|
||||||
|
[data-module="pco-plan"] .pp-row.is-header .pp-col-len { visibility: hidden; }
|
||||||
|
|
||||||
[data-module="pco-plan"] .pp-row.is-pre .pp-title,
|
[data-module="pco-plan"] .pp-row.is-pre .pp-title,
|
||||||
[data-module="pco-plan"] .pp-row.is-post .pp-title { color: color-mix(in srgb, var(--text) 75%, var(--muted)); }
|
[data-module="pco-plan"] .pp-row.is-post .pp-title { color: color-mix(in srgb, var(--text) 75%, var(--muted)); }
|
||||||
@@ -116,10 +134,21 @@
|
|||||||
|
|
||||||
[data-module="pco-plan"] .pp-row.is-current {
|
[data-module="pco-plan"] .pp-row.is-current {
|
||||||
border-left-color: var(--accent);
|
border-left-color: var(--accent);
|
||||||
background: color-mix(in srgb, var(--accent) 14%, transparent);
|
background: color-mix(in srgb, var(--accent) 16%, transparent);
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-module="pco-plan"] .pp-col-num {
|
||||||
|
flex: none;
|
||||||
|
width: 1.6em;
|
||||||
|
padding-top: 0.1em;
|
||||||
|
font-size: 0.8em;
|
||||||
|
font-weight: 700;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--muted);
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
[data-module="pco-plan"] .pp-col-time {
|
[data-module="pco-plan"] .pp-col-time {
|
||||||
flex: none;
|
flex: none;
|
||||||
width: 4.6em;
|
width: 4.6em;
|
||||||
@@ -130,14 +159,13 @@
|
|||||||
text-align: right;
|
text-align: right;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
[data-module="pco-plan"] .pp-col-time[hidden] { display: none; }
|
|
||||||
[data-module="pco-plan"] .pp-row.is-current .pp-col-time { color: var(--accent); font-weight: 700; }
|
[data-module="pco-plan"] .pp-row.is-current .pp-col-time { color: var(--accent); font-weight: 700; }
|
||||||
|
|
||||||
[data-module="pco-plan"] .pp-col-main { flex: 1; min-width: 0; }
|
[data-module="pco-plan"] .pp-col-main { flex: 1; min-width: 0; }
|
||||||
|
|
||||||
[data-module="pco-plan"] .pp-title-line {
|
[data-module="pco-plan"] .pp-title-line {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: center;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 0.2em 0.55em;
|
gap: 0.2em 0.55em;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -149,6 +177,21 @@
|
|||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The song key: right after the title, larger and in the admin-chosen
|
||||||
|
color (blue by default) so the band can read it from across the booth. */
|
||||||
|
[data-module="pco-plan"] .pp-key {
|
||||||
|
font-size: 1.15em;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.15;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
padding: 0.05em 0.5em;
|
||||||
|
border-radius: 0.4em;
|
||||||
|
color: var(--pp-key);
|
||||||
|
background: color-mix(in srgb, var(--pp-key) 16%, transparent);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--pp-key) 45%, transparent);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
[data-module="pco-plan"] .pp-now-tag {
|
[data-module="pco-plan"] .pp-now-tag {
|
||||||
display: none;
|
display: none;
|
||||||
padding: 0.05em 0.45em;
|
padding: 0.05em 0.45em;
|
||||||
@@ -159,12 +202,10 @@
|
|||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
letter-spacing: 0.1em;
|
letter-spacing: 0.1em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
vertical-align: middle;
|
|
||||||
}
|
}
|
||||||
[data-module="pco-plan"] .pp-row.is-current .pp-now-tag { display: inline-block; }
|
[data-module="pco-plan"] .pp-row.is-current .pp-now-tag { display: inline-block; }
|
||||||
|
|
||||||
[data-module="pco-plan"] .pp-badge,
|
[data-module="pco-plan"] .pp-badge,
|
||||||
[data-module="pco-plan"] .pp-key,
|
|
||||||
[data-module="pco-plan"] .pp-arr {
|
[data-module="pco-plan"] .pp-arr {
|
||||||
font-size: 0.7em;
|
font-size: 0.7em;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -180,13 +221,17 @@
|
|||||||
}
|
}
|
||||||
[data-module="pco-plan"] .pp-badge.is-song { color: color-mix(in srgb, var(--accent) 80%, var(--text)); border-color: color-mix(in srgb, var(--accent) 40%, transparent); }
|
[data-module="pco-plan"] .pp-badge.is-song { color: color-mix(in srgb, var(--accent) 80%, var(--text)); border-color: color-mix(in srgb, var(--accent) 40%, transparent); }
|
||||||
[data-module="pco-plan"] .pp-badge.is-media { color: color-mix(in srgb, var(--warn) 85%, var(--text)); border-color: color-mix(in srgb, var(--warn) 40%, transparent); }
|
[data-module="pco-plan"] .pp-badge.is-media { color: color-mix(in srgb, var(--warn) 85%, var(--text)); border-color: color-mix(in srgb, var(--warn) 40%, transparent); }
|
||||||
[data-module="pco-plan"] .pp-key {
|
|
||||||
background: color-mix(in srgb, var(--text) 10%, transparent);
|
|
||||||
color: var(--text);
|
|
||||||
text-transform: none;
|
|
||||||
}
|
|
||||||
[data-module="pco-plan"] .pp-arr { color: var(--muted); text-transform: none; letter-spacing: 0; font-weight: 600; }
|
[data-module="pco-plan"] .pp-arr { color: var(--muted); text-transform: none; letter-spacing: 0; font-weight: 600; }
|
||||||
|
|
||||||
|
[data-module="pco-plan"] .pp-author {
|
||||||
|
margin-top: 0.1em;
|
||||||
|
font-size: 0.78em;
|
||||||
|
color: var(--muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
[data-module="pco-plan"] .pp-desc {
|
[data-module="pco-plan"] .pp-desc {
|
||||||
margin-top: 0.15em;
|
margin-top: 0.15em;
|
||||||
font-size: 0.86em;
|
font-size: 0.86em;
|
||||||
@@ -230,7 +275,6 @@
|
|||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
[data-module="pco-plan"] .pp-col-len[hidden] { display: none; }
|
|
||||||
[data-module="pco-plan"] .pp-len {
|
[data-module="pco-plan"] .pp-len {
|
||||||
font-size: 0.85em;
|
font-size: 0.85em;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
|
|||||||
+3
-3
@@ -84,9 +84,9 @@ function plansFor(stId) {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
return [
|
return [
|
||||||
mk(`${stId}-P1`, firstServiceStart - 7 * DAY, 'The Table', 'Living Hope'),
|
mk(`${stId}-P1`, firstServiceStart - 7 * DAY, 'Part 1', 'Living Hope'),
|
||||||
mk(`${stId}-P2`, firstServiceStart, 'Living Hope Wk 2', 'Living Hope'),
|
mk(`${stId}-P2`, firstServiceStart, 'Part 2', 'Living Hope'),
|
||||||
mk(`${stId}-P3`, firstServiceStart + 7 * DAY, 'Living Hope Wk 3', 'Living Hope'),
|
mk(`${stId}-P3`, firstServiceStart + 7 * DAY, 'Part 3', 'Living Hope'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user