// 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 = ''; 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(); root.innerHTML = ''; }, }; }