// Band Lineup — client. Renders the shared stage board from /state + /stream. // The Edit button opens an editor: an uploaded-photo library you drag (or tap) // 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; /* ---------- drag (pointer events: one path for mouse, pen and touch) ---------- * Native HTML drag-and-drop never fires for a finger, drops onto a card's child elements * flicker, and a board update arriving mid-drag rebuilt the editor out from under it. This * drives the drag by hand instead: a mouse drags as soon as it moves; a finger holds still for * a moment first (so a quick swipe still scrolls the editor). A floating copy of the photo * follows the pointer, the spot under it lights up, and the editor auto-scrolls near its * edges. Board updates that arrive during a drag wait until it ends. */ const DRAG_MOVE_PX = 6; const TOUCH_HOLD_MS = 220; let drag = null; // { kind: 'lib'|'slot', id, body, ghost, over, x, y, raf } let pending = null; // a pointerdown that may become a drag let renderHeld = false; // a state update arrived mid-drag; render when it ends function slotUnder(x, y) { const hit = document.elementFromPoint(x, y); const cell = hit && hit.closest ? hit.closest('.bl-edit-slot') : null; return cell && root.contains(cell) ? cell : null; } function beginDrag(p) { const ghost = el('div', 'bl-drag-ghost'); ghost.dataset.module = 'band-lineup'; // lives on
(above every tile), so it carries its own scope const src = p.source.querySelector('img'); if (src) { const img = document.createElement('img'); img.src = src.src; img.alt = ''; ghost.appendChild(img); } else ghost.textContent = p.label || ''; document.body.appendChild(ghost); drag = { kind: p.kind, id: p.id, body: p.body, ghost, over: null, x: p.x, y: p.y, raf: null, source: p.source }; p.source.classList.add('bl-dragging'); root.classList.add('bl-is-dragging'); moveDrag(p.x, p.y); const tick = () => { if (!drag) return; const b = drag.body; if (b && b.isConnected) { const r = b.getBoundingClientRect(); const zone = 70; let dy = 0; if (drag.y < r.top + zone) dy = -Math.ceil((r.top + zone - drag.y) / 4) - 2; else if (drag.y > r.bottom - zone) dy = Math.ceil((drag.y - (r.bottom - zone)) / 4) + 2; if (dy) { b.scrollTop += dy; moveDrag(drag.x, drag.y); } } drag.raf = requestAnimationFrame(tick); }; drag.raf = requestAnimationFrame(tick); } function moveDrag(x, y) { drag.x = x; drag.y = y; drag.ghost.style.transform = `translate(${x}px, ${y}px)`; const cell = slotUnder(x, y); if (cell !== drag.over) { if (drag.over) drag.over.classList.remove('bl-drop-over'); drag.over = cell; if (cell) cell.classList.add('bl-drop-over'); } } function endDrag(drop) { const d = drag; drag = null; if (!d) return; if (d.raf) cancelAnimationFrame(d.raf); d.ghost.remove(); d.source.classList.remove('bl-dragging'); root.classList.remove('bl-is-dragging'); if (d.over) d.over.classList.remove('bl-drop-over'); if (drop && d.over) { const to = d.over.dataset.index; if (d.kind === 'lib') assign(to, d.id); else if (d.id !== to) api(`/move?from=${d.id}&to=${to}`, { method: 'POST' }); } if (renderHeld) { renderHeld = false; render(); } } function cancelPending() { if (pending && pending.timer) clearTimeout(pending.timer); pending = null; } // Make `node` draggable. `kind`/`id` say what is being dragged. function makeDraggable(node, kind, id, body, label) { node.addEventListener('pointerdown', (e) => { if (e.button !== 0 || drag) return; if (e.target.closest('button')) return; // name / clear / delete buttons keep working cancelPending(); pending = { kind, id, body, label, source: node, x: e.clientX, y: e.clientY, sx: e.clientX, sy: e.clientY, pointerId: e.pointerId, touch: e.pointerType !== 'mouse', armed: e.pointerType === 'mouse', timer: null }; if (pending.touch) { const p = pending; p.timer = setTimeout(() => { if (pending === p) { p.armed = true; beginDrag(p); try { node.setPointerCapture(p.pointerId); } catch { /* gone */ } } }, TOUCH_HOLD_MS); } }); } // Document-level move/up so the drag survives leaving the card it started on. function onPointerMove(e) { if (drag) { e.preventDefault(); moveDrag(e.clientX, e.clientY); return; } if (!pending || e.pointerId !== pending.pointerId) return; const dist = Math.hypot(e.clientX - pending.sx, e.clientY - pending.sy); if (pending.touch) { if (dist > 10) cancelPending(); return; } // moved before the hold: it's a scroll if (dist >= DRAG_MOVE_PX) { const p = pending; pending = null; p.x = e.clientX; p.y = e.clientY; beginDrag(p); } } function onPointerUp(e) { if (drag) { e.preventDefault(); suppressClick = true; setTimeout(() => { suppressClick = false; }, 0); endDrag(e.type === 'pointerup'); } cancelPending(); } let suppressClick = false; // the click that follows a drop must not also select / place // While a finger drag is live, stop the page from scrolling under it. function onTouchMove(e) { if (drag) e.preventDefault(); } document.addEventListener('pointermove', onPointerMove, { passive: false }); document.addEventListener('pointerup', onPointerUp); document.addEventListener('pointercancel', onPointerUp); document.addEventListener('touchmove', onTouchMove, { passive: false }); function stopDragAutoScroll() { cancelPending(); endDrag(false); } /* ---------- 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() { if (drag) { renderHeld = true; return; } 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'); // 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) makeDraggable(cell, 'slot', String(slot.index), body, slot.person.name); 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}`)); cell.addEventListener('click', (e) => { if (suppressClick) return; 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.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); makeDraggable(item, 'lib', person.id, body, person.name); item.addEventListener('click', () => { if (suppressClick) return; 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 = ''; const EDIT_ICON = ''; 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 = ''; const PLUS_ICON = ''; 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 = ''; 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(); document.removeEventListener('pointermove', onPointerMove); document.removeEventListener('pointerup', onPointerUp); document.removeEventListener('pointercancel', onPointerUp); document.removeEventListener('touchmove', onTouchMove); root.innerHTML = ''; }, }; }