diff --git a/README.md b/README.md index 614b85c..53cb9f7 100644 --- a/README.md +++ b/README.md @@ -521,11 +521,16 @@ below. Nothing is installed without someone clicking. A photo library fills it: point the module at a Google Drive folder shared "anyone with the link" (file name → person's name) or at a folder on the ProdDash machine, and photos added or removed there follow. **Edit** in the - title bar opens that library — drag a photo onto a slot, drag between slots - to swap, rename or clear. Planning Center is optional auto-fill on top, not - the source of truth: **Sync** reads the upcoming plan's team members and - drops each into the slot matching their position, filling only slots nobody - placed by hand, so a guest pinned on Thursday survives Sunday's sync. + title bar opens that library — drag a photo onto a slot (mouse, or press and + hold on a touchscreen), drag between slots to swap, rename or clear. + Planning Center is optional auto-fill on top, not the source of truth: + **Sync** reads the upcoming plan's team members and places each one. A + person whose Planning Center note names their mic ("Mix 8, VOX 5") goes to + that slot — this is how the leader and vocalists, whose position is just + "Vocalist", land on the right mic — and everyone else goes to the slot + matching their position, one slot per person. Anything placed or moved by + hand for that plan stays put through later syncs; last week's people are + replaced. Birthdays in the service week (Wednesday→Tuesday) bring a gold glow and raining confetti, and a Choir button adds the extra spot for the weeks there is one. Admin config: Planning Center credentials, service type and plan, the diff --git a/modules/band-lineup/client.js b/modules/band-lineup/client.js index ab86bc8..1e0b2d8 100644 --- a/modules/band-lineup/client.js +++ b/modules/band-lineup/client.js @@ -1,5 +1,5 @@ // 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 // come back over SSE, so every Band Lineup screen stays in sync. Birthday week // (Wed→Tue) rains confetti. @@ -14,26 +14,107 @@ export default function create({ root, moduleApi }) { 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); + /* ---------- 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 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 (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) { @@ -79,6 +160,7 @@ export default function create({ root, moduleApi }) { function stopConfetti() { canvases.clear(); if (raf) { cancelAnimationFrame(raf); raf = null; } } function render() { + if (drag) { renderHeld = true; return; } stopConfetti(); stopParallax(); root.innerHTML = ''; @@ -163,8 +245,6 @@ export default function create({ root, moduleApi }) { // 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'); @@ -176,14 +256,7 @@ export default function create({ root, moduleApi }) { 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); - } + 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 : '')); @@ -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); } 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); }); + 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); @@ -210,7 +274,7 @@ export default function create({ root, moduleApi }) { 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; + 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…'); @@ -219,12 +283,8 @@ export default function create({ root, moduleApi }) { 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(); }); + 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); @@ -293,6 +353,10 @@ export default function create({ root, moduleApi }) { stopConfetti(); stopParallax(); stopDragAutoScroll(); + document.removeEventListener('pointermove', onPointerMove); + document.removeEventListener('pointerup', onPointerUp); + document.removeEventListener('pointercancel', onPointerUp); + document.removeEventListener('touchmove', onTouchMove); root.innerHTML = ''; }, }; diff --git a/modules/band-lineup/matching.js b/modules/band-lineup/matching.js index cfea022..e5e1180 100644 --- a/modules/band-lineup/matching.js +++ b/modules/band-lineup/matching.js @@ -117,9 +117,61 @@ function scoreMatch(slotLabel, positionLabel) { * member or null. `MIN_SCORE` keeps a weak coincidence from claiming a slot. */ const MIN_SCORE = 60; + +/** The mix numbers a label names ("[VOX 5] MIX 8" → {"8"}; "Mix 8, VOX 5" → {"8"}). */ +function mixNumbers(value) { + const out = new Set(); const re = /\bmix\s*(\d+)\b/gi; let m; + while ((m = re.exec(String(value || '')))) out.add(m[1]); + return out; +} + +/** + * How well a person's Planning Center note names a spot. The booth writes the mic/role into each + * vocalist's and the leader's note every week ("Mix 8, VOX 5", "DP48, Drums"), because those + * change week to week and the position name ("Vocalist") doesn't say which mic. A note that names a + * different mix than the spot is never a match; the role part ("VOX 5") is scored like a position. + * 0 means the note says nothing usable about this spot ("NA", blank, or no overlap). + */ +function noteScore(slotLabel, note) { + const text = String(note || '').trim(); + if (!text || /^n\/?a$/i.test(text)) return 0; + const slotMix = mixNumbers(slotLabel); const noteMix = mixNumbers(text); + let mixBonus = 0; + if (noteMix.size && slotMix.size) { + if (![...noteMix].some((n) => slotMix.has(n))) return 0; + mixBonus = 100; + } + const role = text.replace(/\bmix\s*\d+\b/gi, ' '); + const roleScore = scoreMatch(slotRoleText(slotLabel), role); + if (!roleScore && !mixBonus) return 0; + if (noteMix.size && slotMix.size && roleScore === 0 && role.replace(/[^a-z0-9]/gi, '')) { + // The mix agrees but the role contradicts it (e.g. "Mix 8, Keys" on a VOX spot) — trust neither. + const roleTokens = labelTokens(role); + if (roleTokens.length) return 0; + } + return mixBonus + roleScore; +} + function assignMembersToSlots(slots, members) { const pool = members.slice(); - return slots.map((slotLabel) => { + const result = slots.map(() => null); + const take = (chosen) => { + // One spot per person: someone scheduled twice (Leader + Acoustic) must not fill a second spot. + const key = chosen.personId || ''; + for (let i = pool.length - 1; i >= 0; i -= 1) if (pool[i] === chosen || (key && pool[i].personId === key)) pool.splice(i, 1); + }; + // Pass 1 — people whose note names their spot go exactly there, best-fitting pairs first. + const pairs = []; + slots.forEach((slotLabel, s) => pool.forEach((m) => { const sc = noteScore(slotLabel, m.note); if (sc >= MIN_SCORE) pairs.push({ s, m, sc }); })); + pairs.sort((a, b) => b.sc - a.sc); + for (const { s, m } of pairs) { + if (result[s] || !pool.includes(m)) continue; + result[s] = m; take(m); + } + // Pass 2 — everyone else by Planning Center position, into the spots still open. + return result.map((placed, s) => { + if (placed) return placed; + const slotLabel = slots[s]; const role = slotRoleText(slotLabel); let bestIndex = -1; let bestScore = MIN_SCORE - 1; @@ -128,7 +180,9 @@ function assignMembersToSlots(slots, members) { if (score > bestScore) { bestScore = score; bestIndex = i; } } if (bestIndex < 0) return null; - return pool.splice(bestIndex, 1)[0]; + const chosen = pool[bestIndex]; + take(chosen); + return chosen; }); } @@ -194,5 +248,7 @@ module.exports = { numbersIn, scoreMatch, assignMembersToSlots, + noteScore, + mixNumbers, MIN_SCORE, }; diff --git a/modules/band-lineup/matching.test.js b/modules/band-lineup/matching.test.js index 516574c..d68e859 100644 --- a/modules/band-lineup/matching.test.js +++ b/modules/band-lineup/matching.test.js @@ -65,3 +65,41 @@ console.log('assignment:', JSON.stringify(out)); assert.strictEqual(bestLibraryMatch('Chris Smith', [{ id: '1', name: 'Chris' }, { id: '2', name: 'Chris' }]), null, 'ambiguous tie → null'); console.log('bestLibraryMatch: all assertions passed ✓'); } + +// --- one spot per person: someone scheduled twice (Leader + Acoustic) fills one spot only --- +{ + const P = (label, name, personId) => ({ label, name, personId }); + const slots = ['[LEADER, VOX 1, ACOUSTIC] MIX 1', '[GUITAR, 2] MIX 7']; + const got = assignMembersToSlots(slots, [P('[Leader]', 'Mike', 'mp'), P('Guitar [Acoustic]', 'Mike', 'mp'), P('Guitar [Rhythm]', 'Craig', 'cs')]).map((m) => (m ? m.name : null)); + assert.deepStrictEqual(got, ['Mike', 'Craig'], 'Mike once, Craig gets the guitar spot'); + console.log('one spot per person: all assertions passed ✓'); +} + +// --- Planning Center notes place the leader and vocalists (real notes from Oct 25 / Nov 1, 2026) --- +{ + const { noteScore } = require('./matching'); + const NA_SLOTS = [ + '[LEADER, VOX 1, ACOUSTIC] MIX 1', '[VOX 2] MIX 2', '[VOX 3] MIX 3', '[VOX 4] MIX 4', '[GUITAR, LEAD] MIX 5', + '[BASS] MIX 6', '[GUITAR, 2] MIX 7', '[VOX 5] MIX 8', '[PIANO]', '[DRUMS]', '[KEYS 2]', + ]; + const P = (label, name, personId, note) => ({ label, name, personId, note }); + // Listed in the order Planning Center returned them — vocalists alphabetical, not by mic. + const oct25 = [ + P('Vocalist', 'DJ', 'dj', 'Mix 3, VOX 3'), P('[Leader]', 'Mike', 'mp', 'Mix 1, VOX 1'), P('Guitar [Lead]', 'Yeremis', 'yc'), + P('Piano/Pad', 'Kevin', 'km'), P('Vocalist', 'Genesis', 'gg', 'Mix 4, VOX 4'), P('Guitar [Rhythm]', 'Craig', 'cs'), + P('Guitar [Acoustic]', 'Mike', 'mp'), P('Vocalist', 'Rosaliz', 'rg', 'Mix 2, VOX 2'), P('Drums', 'Luis', 'ld'), + P('Vocalist', 'Bianca', 'bb', 'Mix 8, VOX 5'), P('Guitar [Bass]', 'Joram', 'jl'), + ]; + const got = assignMembersToSlots(NA_SLOTS, oct25).map((m) => (m ? m.name : null)); + assert.deepStrictEqual(got, ['Mike', 'Rosaliz', 'DJ', 'Genesis', 'Yeremis', 'Joram', 'Craig', 'Bianca', 'Kevin', 'Luis', null], 'Oct 25 by notes'); + // Nov 1: leader note in different case, a drum note with no mix, an "NA" note. + const nov1 = [P('Guitar [Acoustic]', 'Mike', 'mp', 'NA'), P('Drums', 'Alex', 'ag', 'DP48, Drums'), P('[Leader]', 'Mike', 'mp', 'Mix 1, Vox 1')]; + assert.deepStrictEqual(assignMembersToSlots(NA_SLOTS, nov1).map((m) => (m ? m.name : null)).filter(Boolean), ['Mike', 'Alex'], 'Nov 1: Mike once, Alex on drums'); + assert.strictEqual(assignMembersToSlots(NA_SLOTS, nov1)[9].name, 'Alex', 'DP48, Drums → [DRUMS]'); + // A note never pulls someone onto a different mix. + assert.strictEqual(noteScore('[VOX 2] MIX 2', 'Mix 8, VOX 5'), 0, 'Mix 8 note must not match MIX 2'); + assert.strictEqual(noteScore('[VOX 5] MIX 8', 'Mix 8, VOX 5') > 0, true, 'Mix 8, VOX 5 matches [VOX 5] MIX 8'); + assert.strictEqual(noteScore('[VOX 5] MIX 8', 'NA'), 0, 'NA says nothing'); + assert.strictEqual(noteScore('[VOX 1] MIX 10', 'Mix 1, VOX 1') , 0, 'Mix 1 must not match MIX 10'); + console.log('notes: all assertions passed ✓'); +} diff --git a/modules/band-lineup/module.json b/modules/band-lineup/module.json index df2cc60..56e431f 100644 --- a/modules/band-lineup/module.json +++ b/modules/band-lineup/module.json @@ -1,7 +1,7 @@ { "id": "band-lineup", "name": "Band Lineup", - "version": "1.11.1", + "version": "1.12.0", "proddash": ">=1.4.0", "description": "Stage lineup photos with mic/pack labels — a drag-in photo library plus optional Planning Center auto-fill", "client": "client.js", diff --git a/modules/band-lineup/server.js b/modules/band-lineup/server.js index 92bcf63..5c3b641 100644 --- a/modules/band-lineup/server.js +++ b/modules/band-lineup/server.js @@ -512,6 +512,7 @@ module.exports = { personId, name: String(person?.attributes?.full_name || person?.attributes?.name || m.attributes?.name || 'Unknown').trim() || 'Unknown', label: String(m.attributes?.team_position_name || '').trim(), + note: String(m.attributes?.notes || '').trim(), // "Mix 8, VOX 5" — where the booth wants them this week photoUrl: String(person?.attributes?.photo_url || person?.attributes?.photo_thumbnail_url || '').trim(), }); if (personId) seen.add(personId); @@ -530,8 +531,8 @@ module.exports = { const openIdx = []; const manualNames = new Set(); for (let i = 0; i < eff.length; i += 1) { const e = board[String(i)]; - if (e && (e.personId || e.name) && e.plan === planId) { - const nm = e.personId ? (library[e.personId] && library[e.personId].name) : e.name; + if (e && (e.personId || e.name || e.pco) && e.plan === planId) { + const nm = e.pco ? e.pco.name : e.personId ? (library[e.personId] && library[e.personId].name) : e.name; if (nm) manualNames.add(normName(nm)); } else openIdx.push(i); } @@ -675,6 +676,10 @@ module.exports = { const from = String(p.get('from') || ''); const to = String(p.get('to') || ''); if (!/^\d+$/.test(from) || !/^\d+$/.test(to) || from === to) return json(res, 400, { error: 'Bad move.' }); const a = current.board[from]; const b = current.board[to]; + // Moving a spot is a hand placement for this plan: Sync must not move it back. + const plan = current.planId(); + if (a) a.plan = plan; + if (b) b.plan = plan; if (a === undefined) delete current.board[to]; else current.board[to] = a; if (b === undefined) delete current.board[from]; else current.board[from] = b; current.saveBoard(); current.rebuildLocal(); diff --git a/modules/band-lineup/style.css b/modules/band-lineup/style.css index 68fe5bf..55214f1 100644 --- a/modules/band-lineup/style.css +++ b/modules/band-lineup/style.css @@ -230,3 +230,20 @@ background: color-mix(in srgb, var(--bg) 60%, transparent); color: var(--text); cursor: pointer; font-size: 0.85em; line-height: 1; } [data-module="band-lineup"] .bl-lib-del:hover { background: var(--danger); color: #fff; } + +/* Drag: the floating photo that follows the pointer (appended to , so it scopes itself). */ +[data-module="band-lineup"].bl-drag-ghost { + position: fixed; left: 0; top: 0; z-index: 10000; pointer-events: none; + width: 72px; aspect-ratio: 3 / 4; margin: -48px 0 0 -36px; border-radius: 8px; overflow: hidden; + border: 2px solid var(--accent); background: var(--panel, #222); color: var(--text); font-size: 11px; + display: flex; align-items: center; justify-content: center; text-align: center; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45); opacity: 0.92; +} +[data-module="band-lineup"].bl-drag-ghost img { width: 100%; height: 100%; object-fit: cover; object-position: center top; } +[data-module="band-lineup"] .bl-lib-item, +[data-module="band-lineup"] .bl-edit-slot { -webkit-user-select: none; user-select: none; -webkit-touch-callout: none; } +[data-module="band-lineup"] .bl-edit-slot img, +[data-module="band-lineup"] .bl-lib-item img { -webkit-user-drag: none; pointer-events: none; } +[data-module="band-lineup"] .bl-dragging { opacity: 0.35; } +[data-module="band-lineup"].bl-is-dragging, [data-module="band-lineup"] .bl-is-dragging { cursor: grabbing; } +[data-module="band-lineup"] .bl-is-dragging .bl-edit-slot { transition: box-shadow 0.08s; }