Files
ProdDash/modules/band-lineup/client.js
T
christian 684f06abed Band Lineup module, as it runs in North Attleboro
The backstage stage board as a ProdDash tile: everyone scheduled on the
band, vocal and production teams for the upcoming Planning Center plan,
placed into their mic slot with photo, name and mic/pack label. Birthdays
in the service week get confetti. The display half of the standalone Band
Lineup Electron app, ported to a module — imported here exactly as
installed on NA-Ingest (v1.10.1), so the repo is where it lives from now on.
2026-09-22 09:15:22 -04:00

300 lines
17 KiB
JavaScript
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.
// Band Lineup — client. Renders the shared stage board from /state + /stream.
// The Edit button opens an editor: an uploaded-photo library you drag (or click)
// into slots, plus rename / clear / upload / delete. Edits go to the server and
// come back over SSE, so every Band Lineup screen stays in sync. Birthday week
// (Wed→Tue) rains confetti.
const CONFETTI_COLORS = ['#f5c451', '#ff6b81', '#4fd1c5', '#a78bfa', '#ffffff'];
export default function create({ root, moduleApi }) {
let stream = null;
let state = null;
let editing = false;
let choirBtn = null;
let selectedLibId = ''; // click-to-place fallback for touch
const canvases = new Set();
let raf = null;
// Auto-scroll the editor while dragging a photo (native DnD won't do it reliably).
let dragScrolling = false;
let dragY = 0;
let dragBody = null;
let dragRaf = null;
function startDragAutoScroll() {
if (dragRaf) return;
const tick = () => {
if (!dragScrolling || !dragBody || !dragBody.isConnected) { dragRaf = null; return; }
const r = dragBody.getBoundingClientRect();
const zone = 90; // generous edge band so you don't have to be pixel-accurate
let dy = 0;
if (dragY < r.top + zone) dy = -Math.ceil((r.top + zone - dragY) / 4) - 3;
else if (dragY > r.bottom - zone) dy = Math.ceil((dragY - (r.bottom - zone)) / 4) + 3;
if (dy) dragBody.scrollTop += dy;
dragRaf = requestAnimationFrame(tick);
};
dragRaf = requestAnimationFrame(tick);
}
function stopDragAutoScroll() { dragScrolling = false; if (dragRaf) { cancelAnimationFrame(dragRaf); dragRaf = null; } }
/* ---------- confetti (always animates) ---------- */
function makeParticle(w, h, intensity, seedY) {
const ribbon = Math.random() < 0.55;
return {
x: Math.random() * w, y: seedY != null ? seedY : Math.random() * h,
w: ribbon ? 4 + Math.random() * 4 : 5 + Math.random() * 5, h: ribbon ? 9 + Math.random() * 8 : 0, ribbon,
color: CONFETTI_COLORS[(Math.random() * CONFETTI_COLORS.length) | 0],
rot: Math.random() * Math.PI * 2, vr: -0.14 + Math.random() * 0.28,
sway: 0.6 + Math.random() * 1.1, phase: Math.random() * Math.PI * 2,
vy: (intensity === 'subtle' ? 0.6 : 1.0) * (1.1 + Math.random() * 1.5),
};
}
function seed(c, w, h) { const base = c._intensity === 'subtle' ? 26 : 64; const n = Math.max(10, Math.round((base * (w * h)) / (300 * 500))); c._parts = Array.from({ length: n }, () => makeParticle(w, h, c._intensity)); }
function step(c) {
const w = c.clientWidth, h = c.clientHeight; if (!w || !h) return;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
if (c._w !== w || c._h !== h) { c.width = w * dpr; c.height = h * dpr; c._ctx = c.getContext('2d'); c._ctx.setTransform(dpr, 0, 0, dpr, 0, 0); c._w = w; c._h = h; seed(c, w, h); }
const ctx = c._ctx; ctx.clearRect(0, 0, w, h);
for (const p of c._parts) {
p.phase += 0.03; p.y += p.vy; p.x += Math.sin(p.phase) * p.sway; p.rot += p.vr;
if (p.y > h + 16) Object.assign(p, makeParticle(w, h, c._intensity, -16));
ctx.save(); ctx.translate(p.x, p.y); ctx.rotate(p.rot); ctx.fillStyle = p.color;
if (p.ribbon) ctx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h); else { ctx.beginPath(); ctx.arc(0, 0, p.w / 2, 0, Math.PI * 2); ctx.fill(); }
ctx.restore();
}
}
function loop() { for (const c of [...canvases]) { if (!c.isConnected) { canvases.delete(c); continue; } step(c); } raf = canvases.size ? requestAnimationFrame(loop) : null; }
function addConfetti(host, intensity) { const c = document.createElement('canvas'); c.className = 'bl-confetti'; c._intensity = intensity; host.appendChild(c); canvases.add(c); if (!raf) raf = requestAnimationFrame(loop); }
/* ---------- helpers ---------- */
function initials(name) { const p = String(name || '').trim().split(/\s+/).filter(Boolean).slice(0, 2); return p.length ? p.map((x) => x[0]).join('').toUpperCase() : ''; }
// Per-tile Columns wins; 0 falls back to the admin default.
function resolveCols() { const per = Number(moduleApi.instanceSettings.columns) || 0; return Math.max(1, per > 0 ? per : (Number(state && state.columns) || 7)); }
function el(tag, cls, text) { const e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; }
function photoNode(person, cls) {
if (person && person.photo) { const img = el('img', cls || 'bl-img'); img.alt = ''; img.src = person.photo; img.loading = 'lazy'; return img; }
return el('div', 'bl-initials', initials(person && person.name));
}
async function api(pathStr, opts) { try { return await moduleApi.fetch(pathStr, opts); } catch { return null; } }
/* ---------- display board ---------- */
function stopConfetti() { canvases.clear(); if (raf) { cancelAnimationFrame(raf); raf = null; } }
function render() {
stopConfetti();
stopParallax();
root.innerHTML = '';
if (choirBtn) choirBtn.classList.toggle('active', !!(state && state.choir));
if (editing) { renderEditor(); return; }
const board = el('div', 'bl-board');
if (!state) { board.classList.add('bl-message'); board.textContent = 'Connecting…'; root.appendChild(board); return; }
const s = moduleApi.instanceSettings;
const birthdays = s.birthdays !== false;
const intensity = s.birthdayIntensity === 'full' ? 'full' : 'subtle';
const testIndex = birthdays ? String(s.birthdayTest || '') : '';
const cols = resolveCols();
const rows = Math.max(1, Math.ceil((state.slots ? state.slots.length : 1) / cols));
board.style.setProperty('--bl-cols', cols);
board.style.setProperty('--bl-rows', rows);
for (const slot of state.slots || []) {
const cell = el('div', 'bl-slot');
const person = slot.person;
if (person) {
const tile = el('div', 'bl-tile');
const photo = el('div', 'bl-photo');
photo.appendChild(photoNode(person));
const celebrating = birthdays && (person.celebrating || (testIndex !== '' && testIndex === String(slot.index)));
if (celebrating) { tile.classList.add('bl-birthday', `bl-birthday-${intensity}`); addConfetti(photo, intensity); }
if (s.showNames !== false && person.name) photo.appendChild(el('div', 'bl-name', person.name));
tile.appendChild(photo);
if (s.showLabels !== false && slot.label) tile.appendChild(el('div', 'bl-label', slot.label));
cell.appendChild(tile);
} else {
const empty = el('div', 'bl-tile bl-empty');
empty.appendChild(el('div', 'bl-label bl-label-empty', slot.label || `Slot ${slot.index + 1}`));
cell.appendChild(empty);
}
board.appendChild(cell);
}
root.appendChild(board);
applyParallax(board, s.parallax !== false);
reportStatus();
}
/* ---------- parallax: each photo drifts on its own, automatically ---------- */
let parallaxRaf = null;
let parallaxImgs = [];
function stopParallax() { if (parallaxRaf) { cancelAnimationFrame(parallaxRaf); parallaxRaf = null; } parallaxImgs = []; }
function applyParallax(board, on) {
stopParallax();
const imgs = [...board.querySelectorAll('.bl-img')];
if (!on) { imgs.forEach((i) => { i.style.transform = ''; }); return; }
// Give each image its own gentle, endless drift — different amplitude, speed and phase,
// so the wall never looks frozen and no two photos move together. No mouse needed.
parallaxImgs = imgs.map((img) => ({
img,
ax: 4 + Math.random() * 5, ay: 4 + Math.random() * 5,
// Very slow: a full drift cycle takes roughly a minute, so the motion only
// registers if you actually watch for it.
sx: 0.03 + Math.random() * 0.05, sy: 0.03 + Math.random() * 0.05,
px: Math.random() * Math.PI * 2, py: Math.random() * Math.PI * 2,
}));
const tick = () => {
const t = performance.now() / 1000;
for (const p of parallaxImgs) {
if (!p.img.isConnected) continue;
p.img.style.transform = `scale(1.08) translate(${(Math.sin(t * p.sx + p.px) * p.ax).toFixed(2)}px, ${(Math.cos(t * p.sy + p.py) * p.ay).toFixed(2)}px)`;
}
parallaxRaf = requestAnimationFrame(tick);
};
parallaxRaf = requestAnimationFrame(tick);
}
function reportStatus() {
if (!state) return moduleApi.setStatus('connecting', 'Connecting…');
if (state.configured && !state.reachable) return moduleApi.setStatus('error', state.lastError || 'Planning Center unreachable');
return moduleApi.setStatus('ok', state.plan?.dates ? `Plan: ${state.plan.dates}` : 'Ready');
}
/* ---------- editor ---------- */
function renderEditor() {
// No top bar: the photo source is managed in Admin (Google Drive link). Edit mode is just the
// board on top and the picture picker (library) below it.
const wrap = el('div', 'bl-editor bl-editor-nobar');
const body = el('div', 'bl-editor-body');
// Feed the drag auto-scroller the pointer position anywhere over the editor.
body.addEventListener('dragover', (e) => { dragY = e.clientY; });
// Slots (drop targets)
const boardEl = el('div', 'bl-edit-board');
boardEl.style.setProperty('--bl-cols', resolveCols());
for (const slot of state?.slots || []) {
const cell = el('div', 'bl-edit-slot');
cell.dataset.index = String(slot.index);
const photo = el('div', 'bl-edit-photo');
photo.appendChild(slot.person ? photoNode(slot.person) : el('div', 'bl-edit-drop', '+'));
cell.appendChild(photo);
// An occupied card is a drag SOURCE too — move/swap it to another slot.
if (slot.person) {
cell.draggable = true;
cell.addEventListener('dragstart', (e) => {
e.dataTransfer.setData('text/plain', 'slot:' + slot.index); e.dataTransfer.effectAllowed = 'move';
dragBody = body; dragScrolling = true; startDragAutoScroll();
});
cell.addEventListener('dragend', stopDragAutoScroll);
}
const nameRow = el('div', 'bl-edit-namerow');
const nameBtn = el('button', 'bl-edit-name', (slot.person && slot.person.name) || 'Name…');
nameBtn.addEventListener('click', () => renameSlot(slot.index, slot.person ? slot.person.name : ''));
nameRow.appendChild(nameBtn);
if (slot.person) { const clr = el('button', 'bl-edit-clear', '×'); clr.title = 'Clear'; clr.addEventListener('click', () => api(`/slot/${slot.index}/clear`, { method: 'POST' })); nameRow.appendChild(clr); }
cell.appendChild(nameRow);
cell.appendChild(el('div', 'bl-edit-label', slot.label || `Slot ${slot.index + 1}`));
// drop
cell.addEventListener('dragover', (e) => { e.preventDefault(); cell.classList.add('bl-drop-over'); });
cell.addEventListener('dragleave', () => cell.classList.remove('bl-drop-over'));
cell.addEventListener('drop', (e) => {
e.preventDefault(); stopDragAutoScroll(); cell.classList.remove('bl-drop-over');
const data = e.dataTransfer.getData('text/plain') || '';
if (data.startsWith('lib:')) assign(slot.index, data.slice(4));
else if (data.startsWith('slot:')) { const from = data.slice(5); if (from !== String(slot.index)) api(`/move?from=${from}&to=${slot.index}`, { method: 'POST' }); }
});
cell.addEventListener('click', (e) => { if (e.target.closest('.bl-edit-name') || e.target.closest('.bl-edit-clear')) return; if (selectedLibId) assign(slot.index, selectedLibId); });
boardEl.appendChild(cell);
}
body.appendChild(boardEl);
// Library
const lib = el('div', 'bl-library');
const list = state?.library || [];
if (!list.length) lib.appendChild(el('div', 'bl-lib-empty', 'No photos yet — set a Google Drive folder link in Admin, or hit Sync. Then drag a photo onto a slot.'));
for (const person of list) {
const item = el('div', 'bl-lib-item'); item.draggable = true; item.dataset.id = person.id;
if (person.id === selectedLibId) item.classList.add('bl-lib-selected');
const ph = el('div', 'bl-lib-photo'); ph.appendChild(photoNode(person, 'bl-lib-img')); item.appendChild(ph);
const nm = el('button', 'bl-lib-name', person.name || 'Name…');
nm.addEventListener('click', (e) => { e.stopPropagation(); renameLibrary(person.id, person.name); });
item.appendChild(nm);
const del = el('button', 'bl-lib-del', '×'); del.title = 'Delete photo';
del.addEventListener('click', (e) => { e.stopPropagation(); if (confirm(`Delete ${person.name || 'this photo'} from the library?`)) api(`/library/${person.id}/delete`, { method: 'POST' }); });
item.appendChild(del);
item.addEventListener('dragstart', (e) => {
e.dataTransfer.setData('text/plain', 'lib:' + person.id); e.dataTransfer.effectAllowed = 'copy';
dragBody = body; dragScrolling = true; startDragAutoScroll();
});
item.addEventListener('dragend', stopDragAutoScroll);
item.addEventListener('click', () => { selectedLibId = selectedLibId === person.id ? '' : person.id; renderEditor(); });
lib.appendChild(item);
}
body.appendChild(lib);
wrap.appendChild(body);
root.innerHTML = '';
root.appendChild(wrap);
}
function assign(slotIndex, personId) {
selectedLibId = '';
api(`/assign?slot=${slotIndex}&personId=${encodeURIComponent(personId)}`, { method: 'POST' });
}
function renameSlot(slotIndex, currentName) {
const name = prompt('Name for this spot (leave blank to remove a name-only spot):', currentName || '');
if (name === null) return;
api(`/slot/${slotIndex}/name?name=${encodeURIComponent(name.trim())}`, { method: 'POST' });
}
function renameLibrary(id, currentName) {
const name = prompt('Rename this photo:', currentName || '');
if (name === null) return;
api(`/library/${id}?name=${encodeURIComponent(name.trim())}`, { method: 'POST' });
}
/* ---------- lifecycle ---------- */
return {
start() {
moduleApi.setStatus('connecting', 'Connecting…');
const SYNC_ICON = '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M23 4v6h-6"/><path d="M1 20v-6h6"/><path d="M3.5 9a9 9 0 0 1 14.9-3.4L23 10M1 14l4.6 4.4A9 9 0 0 0 20.5 15"/></svg>';
const EDIT_ICON = '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z"/></svg>';
moduleApi.header.addButton({
icon: SYNC_ICON,
title: 'Sync',
onClick() { moduleApi.setStatus('connecting', 'Syncing with Planning Center…'); api('/sync', { method: 'POST' }); },
});
const editBtn = moduleApi.header.addButton({ icon: EDIT_ICON, title: 'Edit board', onClick() { editing = !editing; editBtn.classList.toggle('active', editing); render(); } });
// Columns −/+ (touch-friendly; replaces the gear number field).
const MINUS_ICON = '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"><path d="M5 12h14"/></svg>';
const PLUS_ICON = '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>';
const bumpCols = (d) => {
const max = Math.max(1, state && state.slots ? state.slots.length : 12);
const next = Math.max(1, Math.min(max, resolveCols() + d));
moduleApi.saveInstanceSettings({ columns: next });
render();
};
moduleApi.header.addButton({ icon: MINUS_ICON, title: 'Fewer columns', onClick() { bumpCols(-1); } });
moduleApi.header.addButton({ icon: PLUS_ICON, title: 'More columns', onClick() { bumpCols(1); } });
const CHOIR_ICON = '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="18" r="2.5"/><circle cx="17" cy="16" r="2.5"/><path d="M8.5 18V5l11-2v13"/><path d="M8.5 8.5l11-2"/></svg>';
choirBtn = moduleApi.header.addButton({ icon: CHOIR_ICON, title: 'Choir', onClick() { api('/choir', { method: 'POST' }); } });
moduleApi.fetch('/state').then((r) => r.json()).then((d) => { state = d.state; render(); }).catch(() => {});
stream = moduleApi.sse('/stream', {
error() { reportStatus(); },
events: {
state(e) {
try { state = JSON.parse(e.data); } catch { return; }
// While editing, don't yank a rename prompt out from under the user:
// re-render the editor only (state carries board + library).
render();
},
},
});
},
onResize() {},
stop() {
if (stream) { stream.close(); stream = null; }
stopConfetti();
stopParallax();
stopDragAutoScroll();
root.innerHTML = '';
},
};
}