From 63dcc2b656f606c78217bc78192fcfd913647f22 Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Tue, 28 Apr 2026 15:54:33 -0500 Subject: [PATCH] Drag-and-drop devices into groups on the dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Device cards are now draggable. Group sections accept drops to add membership (mirroring the Manage modal — same confirmation if the device is already in another group). The Ungrouped section also accepts drops to remove the device from every group it's in. The existing Manage modal still works for bulk add/remove and for finding devices not currently visible. Click-to-open on a card still works; drag is only triggered on actual mouse movement. Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/js/views/dashboard.js | 96 ++++++++++++++++++++++++++++++++-- 1 file changed, 93 insertions(+), 3 deletions(-) diff --git a/frontend/js/views/dashboard.js b/frontend/js/views/dashboard.js index 94b01dc..2d06248 100644 --- a/frontend/js/views/dashboard.js +++ b/frontend/js/views/dashboard.js @@ -39,7 +39,7 @@ function renderDeviceCard(device) { : null; return ` -
+
${screenshotUrl ? `Screenshot` @@ -363,10 +363,12 @@ async function loadDashboard() { html += renderGroupSection(g, g.devices, playlists); } - // Render ungrouped devices + // Render ungrouped devices. The wrapper is tagged data-ungrouped="1" so + // attachGroupHandlers can wire it as a drop target — dropping a device here + // removes it from every group it currently belongs to. if (ungrouped.length > 0) { html += ` -
+
${groups.length > 0 ? `
Ungrouped @@ -388,6 +390,94 @@ async function loadDashboard() { } function attachGroupHandlers(groupsWithDevices, allDevices) { + // Drag-and-drop: device cards are draggable; group sections + the Ungrouped + // wrapper are drop targets. Drop on a group adds membership (mirrors the + // Manage modal). Drop on Ungrouped removes the device from every group it's + // currently a member of. + const groupsByDeviceId = new Map(); + for (const g of groupsWithDevices) { + g.memberIds.forEach(id => { + if (!groupsByDeviceId.has(id)) groupsByDeviceId.set(id, []); + groupsByDeviceId.get(id).push({ id: g.id, name: g.name }); + }); + } + + 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'; + }); + }); + + function highlightOn(el) { el.style.outline = '2px solid var(--primary)'; el.style.outlineOffset = '2px'; } + function highlightOff(el) { el.style.outline = ''; el.style.outlineOffset = ''; } + + document.querySelectorAll('.group-section').forEach(section => { + section.addEventListener('dragover', (e) => { + if (!e.dataTransfer.types.includes('text/device-id')) return; + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + highlightOn(section); + }); + section.addEventListener('dragleave', (e) => { + // Avoid flicker when moving across child elements + if (e.target === section) highlightOff(section); + }); + section.addEventListener('drop', async (e) => { + e.preventDefault(); + highlightOff(section); + const deviceId = e.dataTransfer.getData('text/device-id'); + const deviceName = e.dataTransfer.getData('text/device-name') || 'this device'; + if (!deviceId) return; + const groupId = section.dataset.groupId; + const targetGroup = groupsWithDevices.find(g => g.id === groupId); + if (!targetGroup) return; + // Already in this group — no-op. + if (targetGroup.memberIds.has(deviceId)) { + showToast(`${deviceName} is already in ${targetGroup.name}`, 'info'); + return; + } + // If the device is in another group, mirror the Manage modal's confirm. + const others = (groupsByDeviceId.get(deviceId) || []).map(g => g.name); + if (others.length > 0) { + if (!confirm(`${deviceName} is already in: ${others.join(', ')}\n\nAdd it to "${targetGroup.name}" too?`)) return; + } + try { + await api.addDeviceToGroup(groupId, deviceId); + showToast(`Moved ${deviceName} to ${targetGroup.name}`, 'success'); + loadDashboard(); + } catch (err) { showToast(err.message, 'error'); } + }); + }); + + // Ungrouped wrapper: remove device from every group it's in. + document.querySelectorAll('[data-ungrouped="1"]').forEach(section => { + section.addEventListener('dragover', (e) => { + if (!e.dataTransfer.types.includes('text/device-id')) return; + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + highlightOn(section); + }); + section.addEventListener('dragleave', (e) => { + if (e.target === section) highlightOff(section); + }); + section.addEventListener('drop', async (e) => { + e.preventDefault(); + highlightOff(section); + const deviceId = e.dataTransfer.getData('text/device-id'); + const deviceName = e.dataTransfer.getData('text/device-name') || 'this device'; + if (!deviceId) return; + const memberships = groupsByDeviceId.get(deviceId) || []; + if (memberships.length === 0) return; // already ungrouped + try { + await Promise.all(memberships.map(m => api.removeDeviceFromGroup(m.id, deviceId))); + showToast(`Removed ${deviceName} from ${memberships.length} group${memberships.length !== 1 ? 's' : ''}`, 'success'); + loadDashboard(); + } catch (err) { showToast(err.message, 'error'); } + }); + }); + // Playlist assignment handlers document.querySelectorAll('.group-playlist-select').forEach(select => { select.addEventListener('change', async (e) => {