2026-09-22 09:15:22 -04:00
// Band Lineup — client. Renders the shared stage board from /state + /stream.
2026-10-20 13:06:57 -04:00
// The Edit button opens an editor: an uploaded-photo library you drag (or tap)
2026-09-22 09:15:22 -04:00
// 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 ;
2026-10-20 13:06:57 -04:00
/* ---------- 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 ;
2026-09-22 09:15:22 -04:00
}
2026-10-20 13:06:57 -04:00
function beginDrag ( p ) {
const ghost = el ( 'div' , 'bl-drag-ghost' );
ghost . dataset . module = 'band-lineup' ; // lives on <body> (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 ); }
2026-09-22 09:15:22 -04:00
/* ---------- 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 () {
2026-10-20 13:06:57 -04:00
if ( drag ) { renderHeld = true ; return ; }
2026-09-22 09:15:22 -04:00
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.
2026-10-20 13:06:57 -04:00
if ( slot . person ) makeDraggable ( cell , 'slot' , String ( slot . index ), body , slot . person . name );
2026-09-22 09:15:22 -04:00
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 } ` ));
2026-10-20 13:06:57 -04:00
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 ); });
2026-09-22 09:15:22 -04:00
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 ) {
2026-10-20 13:06:57 -04:00
const item = el ( 'div' , 'bl-lib-item' ); item . dataset . id = person . id ;
2026-09-22 09:15:22 -04:00
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 );
2026-10-20 13:06:57 -04:00
makeDraggable ( item , 'lib' , person . id , body , person . name );
item . addEventListener ( 'click' , () => { if ( suppressClick ) return ; selectedLibId = selectedLibId === person . id ? '' : person . id ; renderEditor (); });
2026-09-22 09:15:22 -04:00
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 ();
2026-10-20 13:06:57 -04:00
document . removeEventListener ( 'pointermove' , onPointerMove );
document . removeEventListener ( 'pointerup' , onPointerUp );
document . removeEventListener ( 'pointercancel' , onPointerUp );
document . removeEventListener ( 'touchmove' , onTouchMove );
2026-09-22 09:15:22 -04:00
root . innerHTML = '' ;
},
};
}