@@ -1,5 +1,5 @@
// Band Lineup — client. Renders the shared stage board from /state + /stream.
// 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 )
// 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
// 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
// come back over SSE, so every Band Lineup screen stays in sync. Birthday week
// (Wed→Tue) rains confetti.
// (Wed→Tue) rains confetti.
@@ -14,26 +14,107 @@ export default function create({ root, moduleApi }) {
let selectedLibId = '' ; // click-to-place fallback for touch
let selectedLibId = '' ; // click-to-place fallback for touch
const canvases = new Set ( ) ;
const canvases = new Set ( ) ;
let raf = null ;
let raf = null ;
// Auto-scroll the editor while dragging a photo (native DnD won't do it reliably).
/* ---------- drag (pointer events: one path for mouse, pen and touch) ----------
let dragScrolling = false ;
* Native HTML drag-and-drop never fires for a finger, drops onto a card's child elements
let dragY = 0 ;
* flicker, and a board update arriving mid-drag rebuilt the editor out from under it. This
let dragBody = null ;
* drives the drag by hand instead: a mouse drags as soon as it moves; a finger holds still for
let dragRaf = null ;
* a moment first (so a quick swipe still scrolls the editor). A floating copy of the photo
function startDragAutoScroll ( ) {
* follows the pointer, the spot under it lights up, and the editor auto-scrolls near its
if ( dragRaf ) return ;
* edges. Board updates that arrive during a drag wait until it ends. */
const tick = ( ) => {
const DRAG _MOVE _PX = 6 ;
if ( ! dragScrolling || ! dragBody || ! dragBody . isConnected ) { dragRaf = null ; return ; }
const TOUCH _HOLD _MS = 220 ;
const r = dragBody . getBoundingClientRect ( ) ;
let drag = null ; // { kind: 'lib'|'slot', id, body, ghost, over, x, y, raf }
const zone = 90 ; // generous edge band so you don't have to be pixel-accurate
let pending = null ; // a pointerdown that may become a drag
let dy = 0 ;
let renderHeld = false ; // a state update arrived mid-drag; render when it ends
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 ;
function slotUnder ( x , y ) {
if ( dy ) dragBody . scrollTop += dy ;
const hit = document . elementFromPoint ( x , y ) ;
dragRaf = requestAnimationFrame ( tick ) ;
const cell = hit && hit . closest ? hit . closest ( '.bl-edit-slot' ) : null ;
} ;
return cell && root . contains ( cell ) ? cell : null ;
dragRaf = requestAnimationFrame ( tick ) ;
}
}
function stopDragAutoScroll ( ) { dragScrolling = false ; if ( dragRaf ) { cancelAnimationFrame ( dragRaf ) ; dragRaf = null ; } }
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 ) ; }
/* ---------- confetti (always animates) ---------- */
/* ---------- confetti (always animates) ---------- */
function makeParticle ( w , h , intensity , seedY ) {
function makeParticle ( w , h , intensity , seedY ) {
@@ -79,6 +160,7 @@ export default function create({ root, moduleApi }) {
function stopConfetti ( ) { canvases . clear ( ) ; if ( raf ) { cancelAnimationFrame ( raf ) ; raf = null ; } }
function stopConfetti ( ) { canvases . clear ( ) ; if ( raf ) { cancelAnimationFrame ( raf ) ; raf = null ; } }
function render ( ) {
function render ( ) {
if ( drag ) { renderHeld = true ; return ; }
stopConfetti ( ) ;
stopConfetti ( ) ;
stopParallax ( ) ;
stopParallax ( ) ;
root . innerHTML = '' ;
root . innerHTML = '' ;
@@ -163,8 +245,6 @@ export default function create({ root, moduleApi }) {
// board on top and the picture picker (library) below it.
// board on top and the picture picker (library) below it.
const wrap = el ( 'div' , 'bl-editor bl-editor-nobar' ) ;
const wrap = el ( 'div' , 'bl-editor bl-editor-nobar' ) ;
const body = el ( 'div' , 'bl-editor-body' ) ;
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)
// Slots (drop targets)
const boardEl = el ( 'div' , 'bl-edit-board' ) ;
const boardEl = el ( 'div' , 'bl-edit-board' ) ;
@@ -176,14 +256,7 @@ export default function create({ root, moduleApi }) {
photo . appendChild ( slot . person ? photoNode ( slot . person ) : el ( 'div' , 'bl-edit-drop' , '+ ' ) ) ;
photo . appendChild ( slot . person ? photoNode ( slot . person ) : el ( 'div' , 'bl-edit-drop' , '+ ' ) ) ;
cell . appendChild ( photo ) ;
cell . appendChild ( photo ) ;
// An occupied card is a drag SOURCE too — move/swap it to another slot.
// An occupied card is a drag SOURCE too — move/swap it to another slot.
if ( slot . person ) {
if ( slot . person ) makeDraggable ( cell , 'slot' , String ( slot . index ) , body , slot . person . name ) ;
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 nameRow = el ( 'div' , 'bl-edit-namerow' ) ;
const nameBtn = el ( 'button' , 'bl-edit-name' , ( slot . person && slot . person . name ) || 'Name…' ) ;
const nameBtn = el ( 'button' , 'bl-edit-name' , ( slot . person && slot . person . name ) || 'Name…' ) ;
nameBtn . addEventListener ( 'click' , ( ) => renameSlot ( slot . index , slot . person ? slot . person . name : '' ) ) ;
nameBtn . addEventListener ( 'click' , ( ) => renameSlot ( slot . index , slot . person ? slot . person . name : '' ) ) ;
@@ -191,16 +264,7 @@ export default function create({ root, moduleApi }) {
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 ) ; }
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 ( nameRow ) ;
cell . appendChild ( el ( 'div' , 'bl-edit-label' , slot . label || ` Slot ${ slot . index + 1 } ` ) ) ;
cell . appendChild ( el ( 'div' , 'bl-edit-label' , slot . label || ` Slot ${ slot . index + 1 } ` ) ) ;
// drop
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 ) ; } ) ;
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 ) ;
boardEl . appendChild ( cell ) ;
}
}
body . appendChild ( boardEl ) ;
body . appendChild ( boardEl ) ;
@@ -210,7 +274,7 @@ export default function create({ root, moduleApi }) {
const list = state ? . 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.' ) ) ;
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 ) {
for ( const person of list ) {
const item = el ( 'div' , 'bl-lib-item' ) ; item . draggable = true ; item . dataset . id = person . id ;
const item = el ( 'div' , 'bl-lib-item' ) ; item . dataset . id = person . id ;
if ( person . id === selectedLibId ) item . classList . add ( 'bl-lib-selected' ) ;
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 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…' ) ;
const nm = el ( 'button' , 'bl-lib-name' , person . name || 'Name…' ) ;
@@ -219,12 +283,8 @@ export default function create({ root, moduleApi }) {
const del = el ( 'button' , 'bl-lib-del' , '× ' ) ; del . title = 'Delete photo' ;
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' } ) ; } ) ;
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 . appendChild ( del ) ;
item . addEventListener ( 'dragstart' , ( e ) => {
makeDraggable ( item , 'lib' , person . id , body , person . name ) ;
e . dataTransfer . setData ( 'text/plain' , 'lib:' + person . id ) ; e . dataTransfer . effectAllowed = 'copy' ;
item . addEventListener ( 'click' , ( ) => { if ( suppressClick ) return ; selectedLibId = selectedLibId === person . id ? '' : person . id ; renderEditor ( ) ; } ) ;
dragBody = body ; dragScrolling = true ; startDragAutoScroll ( ) ;
} ) ;
item . addEventListener ( 'dragend' , stopDragAutoScroll ) ;
item . addEventListener ( 'click' , ( ) => { selectedLibId = selectedLibId === person . id ? '' : person . id ; renderEditor ( ) ; } ) ;
lib . appendChild ( item ) ;
lib . appendChild ( item ) ;
}
}
body . appendChild ( lib ) ;
body . appendChild ( lib ) ;
@@ -293,6 +353,10 @@ export default function create({ root, moduleApi }) {
stopConfetti ( ) ;
stopConfetti ( ) ;
stopParallax ( ) ;
stopParallax ( ) ;
stopDragAutoScroll ( ) ;
stopDragAutoScroll ( ) ;
document . removeEventListener ( 'pointermove' , onPointerMove ) ;
document . removeEventListener ( 'pointerup' , onPointerUp ) ;
document . removeEventListener ( 'pointercancel' , onPointerUp ) ;
document . removeEventListener ( 'touchmove' , onTouchMove ) ;
root . innerHTML = '' ;
root . innerHTML = '' ;
} ,
} ,
} ;
} ;