${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) => {