diff --git a/frontend/js/api.js b/frontend/js/api.js index 790bc9e..9e821e3 100644 --- a/frontend/js/api.js +++ b/frontend/js/api.js @@ -28,6 +28,7 @@ async function request(url, options = {}) { export const api = { // Devices getDevices: () => request('/devices'), + reorderDevices: (order) => request('/devices/reorder', { method: 'POST', body: JSON.stringify({ order }) }), getDevice: (id) => request(`/devices/${id}`), updateDevice: (id, data) => request(`/devices/${id}`, { method: 'PUT', body: JSON.stringify(data) }), deleteDevice: (id) => request(`/devices/${id}`, { method: 'DELETE' }), diff --git a/frontend/js/views/dashboard.js b/frontend/js/views/dashboard.js index e5b7282..52f2e2f 100644 --- a/frontend/js/views/dashboard.js +++ b/frontend/js/views/dashboard.js @@ -622,11 +622,64 @@ function attachGroupHandlers(groupsWithDevices, allDevices) { }); } + // #106: within-section drag-to-reorder. Tracks the in-flight drag (which device, + // and which section it started in) so a CARD-level drop can tell reorder (same + // section) from group-assign (different section / section background). + let dragDeviceId = null; + let dragSectionKey = null; + const sectionKeyOf = (el) => { + const g = el.closest('.group-section'); + if (g && g.dataset.groupId) return 'g:' + g.dataset.groupId; + if (el.closest('[data-ungrouped="1"]')) return 'ungrouped'; + return null; + }; + const clearDropIndicators = () => document.querySelectorAll('.device-card').forEach(c => { c.style.boxShadow = ''; }); + document.querySelectorAll('.device-card').forEach(card => { card.addEventListener('dragstart', (e) => { e.dataTransfer.setData('text/device-id', card.dataset.deviceId); e.dataTransfer.setData('text/device-name', card.dataset.deviceName || ''); e.dataTransfer.effectAllowed = 'move'; + dragDeviceId = card.dataset.deviceId; // #106 + dragSectionKey = sectionKeyOf(card); // #106 + }); + card.addEventListener('dragend', () => { dragDeviceId = null; dragSectionKey = null; clearDropIndicators(); }); + + // #106 within-section reorder. Engages ONLY when the target is another card in the + // SAME section; otherwise it no-ops and the event bubbles to the section handler + // (group-assign), leaving the existing behavior untouched. + card.addEventListener('dragover', (e) => { + if (!dragDeviceId || dragDeviceId === card.dataset.deviceId) return; + if (sectionKeyOf(card) !== dragSectionKey) return; // cross-section -> section handles (assign) + e.preventDefault(); + e.stopPropagation(); // suppress the section's group-assign dragover/highlight + e.dataTransfer.dropEffect = 'move'; + const r = card.getBoundingClientRect(); + const before = e.clientX < r.left + r.width / 2; + card.style.boxShadow = before ? 'inset 3px 0 0 var(--primary)' : 'inset -3px 0 0 var(--primary)'; + }); + card.addEventListener('dragleave', () => { card.style.boxShadow = ''; }); + card.addEventListener('drop', async (e) => { + if (!dragDeviceId || dragDeviceId === card.dataset.deviceId) return; + if (sectionKeyOf(card) !== dragSectionKey) return; // cross-section -> bubble to section (assign) + e.preventDefault(); + e.stopPropagation(); // CRITICAL: stop the section's group-assign drop also firing + const r = card.getBoundingClientRect(); + const before = e.clientX < r.left + r.width / 2; + clearDropIndicators(); + const grid = card.closest('.device-grid'); + if (!grid) return; + const ids = Array.from(grid.querySelectorAll('.device-card')).map(c => c.dataset.deviceId).filter(Boolean); + const from = ids.indexOf(dragDeviceId); + if (from === -1) return; + ids.splice(from, 1); + let to = ids.indexOf(card.dataset.deviceId); + if (!before) to += 1; + ids.splice(to, 0, dragDeviceId); + try { + await api.reorderDevices(ids); + loadDashboard(); + } catch (err) { showToast(err.message, 'error'); } }); }); diff --git a/server/db/database.js b/server/db/database.js index 4780e37..6e0d10c 100644 --- a/server/db/database.js +++ b/server/db/database.js @@ -203,6 +203,9 @@ const migrations = [ // #73: zone-binding was reverted (placement belongs to the device, not the playlist - see // the agency-tokens history). Drop the table on DBs where the short-lived migration ran. "DROP TABLE IF EXISTS api_token_target_zones", + // #106: cosmetic per-workspace display ordering for the Displays view (drag-to- + // reorder). Default 0 -> existing devices fall back to the created_at tiebreak. + "ALTER TABLE devices ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0", ]; // Apply each ALTER idempotently. A "duplicate column name" / "already exists" // error means the column is already present (expected on a migrated DB) - benign. diff --git a/server/routes/devices.js b/server/routes/devices.js index 5312a47..d72944c 100644 --- a/server/routes/devices.js +++ b/server/routes/devices.js @@ -37,12 +37,34 @@ router.get('/', (req, res) => { ON sc.device_id = latest.device_id AND sc.captured_at = latest.max_at ) s ON d.id = s.device_id WHERE d.workspace_id = ? - ORDER BY d.created_at ASC + ORDER BY d.sort_order ASC, d.created_at ASC LIMIT ? OFFSET ? `).all(req.workspaceId, limit, offset); res.json(devices.map(stripDeviceSecrets)); }); +// #106: reorder display tiles (cosmetic, within-section). Writes devices.sort_order +// = position in the given id array. Workspace-scoped: the UPDATE matches WHERE +// workspace_id = the caller's current workspace, so a forged id from another +// workspace is silently a no-op (can't reorder or probe devices you can't see). +// Write-gated: workspace_viewer (non-acting) is read-only. Ordering affects ONLY the +// dashboard listing — nothing the device/player reads (grouping/pairing/playback +// are independent). Mirrors the playlist items reorder. +router.post('/reorder', (req, res) => { + if (!req.workspaceId) return res.status(403).json({ error: 'No workspace' }); + if (!req.actingAs && req.workspaceRole === 'workspace_viewer') { + return res.status(403).json({ error: 'Read-only access' }); + } + const { order } = req.body; + if (!Array.isArray(order)) return res.status(400).json({ error: 'order must be an array of device IDs' }); + const stmt = db.prepare("UPDATE devices SET sort_order = ?, updated_at = strftime('%s','now') WHERE id = ? AND workspace_id = ?"); + const tx = db.transaction(() => { + order.forEach((id, index) => stmt.run(index, id, req.workspaceId)); + }); + tx(); + res.json({ success: true }); +}); + // List unclaimed provisioning devices (admin only). // #13: read-only, so platform_operator may view the pool too (cross-org staff // troubleshooting). Claiming a device is a separate workspace-scoped mutation.