import { api } from '../api.js'; import { on, off, requestScreenshot } from '../socket.js'; import { showToast } from '../components/toast.js'; import { esc } from '../utils.js'; const DESTRUCTIVE_COMMANDS = ['reboot', 'shutdown']; const GROUP_COMMANDS = [ { type: 'screen_on', label: 'Screen On' }, { type: 'screen_off', label: 'Screen Off' }, { type: 'launch', label: 'Restart App' }, { type: 'update', label: 'Check Update' }, { type: 'reboot', label: 'Reboot', destructive: true }, { type: 'shutdown', label: 'Shutdown', destructive: true }, ]; let statusHandler = null; let screenshotHandler = null; let refreshInterval = null; function formatTimeAgo(timestamp) { if (!timestamp) return 'Never'; const seconds = Math.floor(Date.now() / 1000 - timestamp); if (seconds < 60) return 'Just now'; if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`; if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`; return `${Math.floor(seconds / 86400)}d ago`; } function formatBytes(mb) { if (mb === null || mb === undefined) return '--'; if (mb >= 1024) return `${(mb / 1024).toFixed(1)} GB`; return `${mb} MB`; } function renderDeviceCard(device) { const token = localStorage.getItem('token'); const screenshotUrl = device.screenshot_path ? `/api/devices/${device.id}/screenshot?t=${device.screenshot_at || ''}&token=${token}` : null; return `
${screenshotUrl ? `Screenshot` : `
No preview available
` }
${device.status === 'provisioning' ? 'Awaiting Pairing' : device.status}
${device.status === 'provisioning' && device.pairing_code ? `
${device.pairing_code}
` : ''}
${esc(device.name)}
${device.owner_name || device.owner_email ? `
${esc(device.owner_name || device.owner_email)}
` : ''}
${formatTimeAgo(device.last_heartbeat)}
${device.battery_level !== null && device.battery_level !== undefined ? `
${device.battery_level}%
` : ''} ${device.wifi_rssi ? `
${device.wifi_rssi} dBm
` : ''} ${device.storage_free_mb ? `
${formatBytes(device.storage_free_mb)} free
` : ''}
`; } function getGroupPlaylistLabel(devices, playlists) { const playlistMap = new Map((playlists || []).map(p => [p.id, p])); const assigned = devices.filter(d => d.playlist_id).map(d => d.playlist_id); if (assigned.length === 0) return ''; const unique = [...new Set(assigned)]; if (unique.length === 1) { const pl = playlistMap.get(unique[0]); return pl ? esc(pl.name) : 'Unknown playlist'; } return 'Mixed playlists'; } function renderGroupSection(group, devices, playlists) { const onlineCount = devices.filter(d => d.status === 'online').length; const playlistLabel = getGroupPlaylistLabel(devices, playlists); return `
${esc(group.name)} ${devices.length} device${devices.length !== 1 ? 's' : ''} · ${onlineCount} online ${playlistLabel ? `Playlist: ${playlistLabel}` : ''}
${devices.length > 0 ? ` ` : ''}
${devices.length > 0 ? devices.map(renderDeviceCard).join('') : '
No devices in this group. Click Manage to add some.
'}
`; } export function render(container) { container.innerHTML = `
`; const addBtn = container.querySelector('#addDeviceBtn'); addBtn.addEventListener('click', () => { document.getElementById('addDeviceModal').style.display = 'flex'; document.getElementById('pairingCodeInput').value = ''; document.getElementById('deviceNameInput').value = ''; document.getElementById('pairingCodeInput').focus(); }); // Search and filter document.getElementById('deviceSearch').oninput = () => filterDevices(); document.getElementById('deviceFilter').onchange = () => filterDevices(); function filterDevices() { const search = document.getElementById('deviceSearch').value.toLowerCase(); const status = document.getElementById('deviceFilter').value; document.querySelectorAll('.device-card').forEach(card => { const name = card.querySelector('.device-card-name')?.textContent.toLowerCase() || ''; const deviceStatus = card.querySelector('.device-card-status span:last-child')?.textContent || ''; const matchSearch = !search || name.includes(search); const matchStatus = !status || deviceStatus === status; card.style.display = (matchSearch && matchStatus) ? '' : 'none'; }); } // Setup pairing const pairBtn = document.getElementById('pairDeviceBtn'); pairBtn.onclick = async () => { const code = document.getElementById('pairingCodeInput').value.trim(); const name = document.getElementById('deviceNameInput').value.trim(); if (!code || code.length !== 6) { showToast('Enter a valid 6-digit pairing code', 'error'); return; } try { await api.pairDevice(code, name || undefined); document.getElementById('addDeviceModal').style.display = 'none'; showToast('Display paired successfully!', 'success'); loadDashboard(); } catch (err) { showToast(err.message, 'error'); } }; // Create group container.querySelector('#createGroupBtn').addEventListener('click', async () => { const name = prompt('Group name:'); if (!name) return; try { await api.createGroup(name); showToast('Group created', 'success'); loadDashboard(); } catch (e) { showToast(e.message, 'error'); } }); // Load everything loadDashboard(); // Real-time updates statusHandler = (data) => { const cards = document.querySelectorAll(`[data-device-id="${data.device_id}"]`); cards.forEach(card => { const statusEl = card.querySelector('.device-card-status'); if (statusEl) statusEl.innerHTML = `${data.status}`; }); }; screenshotHandler = (data) => { document.querySelectorAll(`#preview-${data.device_id}`).forEach(preview => { const imgSrc = data.image_data || (data.url + '&token=' + localStorage.getItem('token')); const img = preview.querySelector('img'); if (img) { img.src = imgSrc; } else { const statusHtml = preview.querySelector('.device-card-status')?.outerHTML || ''; preview.innerHTML = `Screenshot${statusHtml}`; } }); }; const deviceAddedHandler = () => loadDashboard(); const deviceRemovedHandler = () => loadDashboard(); on('device-status', statusHandler); on('screenshot-ready', screenshotHandler); on('device-added', deviceAddedHandler); on('device-removed', deviceRemovedHandler); // Request fresh screenshots on load setTimeout(() => { document.querySelectorAll('.device-card').forEach(card => { requestScreenshot(card.dataset.deviceId); }); }, 2000); refreshInterval = setInterval(() => { document.querySelectorAll('.device-card').forEach(card => { requestScreenshot(card.dataset.deviceId); }); }, 30000); } async function loadDashboard() { const main = document.getElementById('groupedDevices'); if (!main) return; try { const [rawDevices, groups, playlists] = await Promise.all([api.getDevices(), api.getGroups(), api.getPlaylists()]); // Deduplicate devices by id — a stale reconnect race can briefly cause the same // device to appear twice in the list. Last-write-wins keeps the freshest state. const seen = new Map(); for (const d of rawDevices) seen.set(d.id, d); const devices = Array.from(seen.values()); // Stats const online = devices.filter(d => d.status === 'online').length; const offline = devices.filter(d => d.status === 'offline').length; const provisioning = devices.filter(d => d.status === 'provisioning').length; const statsEl = document.getElementById('dashStats'); if (statsEl) { statsEl.innerHTML = `
Total Displays
${devices.length}
Online
${online}
Offline
${offline}
${provisioning > 0 ? `
Awaiting Pairing
${provisioning}
` : ''} `; } if (devices.length === 0 && groups.length === 0) { main.innerHTML = `

No displays yet

Install the ScreenTinker app on your Apolosign TV and pair it using the button above.

`; return; } // Fetch group memberships const groupsWithDevices = await Promise.all(groups.map(async g => { const members = await api.getGroupDevices(g.id); const memberIds = new Set(members.map(m => m.id)); // Use full device data from the main devices list (has telemetry/screenshots) const fullDevices = devices.filter(d => memberIds.has(d.id)); return { ...g, devices: fullDevices, memberIds }; })); // Render each device exactly once: the first group it belongs to wins. // memberIds is preserved for the Manage modal so multi-group membership info stays accurate. const renderedIds = new Set(); for (const g of groupsWithDevices) { g.devices = g.devices.filter(d => { if (renderedIds.has(d.id)) return false; renderedIds.add(d.id); return true; }); } const ungrouped = devices.filter(d => !renderedIds.has(d.id)); let html = ''; // Render each group with its devices for (const g of groupsWithDevices) { html += renderGroupSection(g, g.devices, playlists); } // 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 ${ungrouped.length} device${ungrouped.length !== 1 ? 's' : ''}
` : ''}
${ungrouped.map(renderDeviceCard).join('')}
`; } main.innerHTML = html; attachGroupHandlers(groupsWithDevices, devices); } catch (err) { main.innerHTML = `

Failed to load displays

${esc(err.message)}

`; } } 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) => { const playlistId = e.target.value; if (!playlistId) return; const groupId = e.target.dataset.groupId; const groupName = e.target.dataset.groupName; const playlistName = e.target.options[e.target.selectedIndex].textContent; if (!confirm(`Assign playlist "${playlistName}" to all devices in "${groupName}"?`)) { e.target.value = ''; return; } try { const result = await api.groupAssignPlaylist(groupId, playlistId); showToast(`Playlist assigned to ${result.devices_updated} device${result.devices_updated !== 1 ? 's' : ''}`, 'success'); } catch (err) { showToast(err.message, 'error'); } e.target.value = ''; }); }); // Command select handlers document.querySelectorAll('.group-cmd-select').forEach(select => { select.addEventListener('change', async (e) => { const type = e.target.value; if (!type) return; const groupId = e.target.dataset.groupId; const groupName = e.target.dataset.groupName; const count = e.target.dataset.deviceCount; if (DESTRUCTIVE_COMMANDS.includes(type)) { if (!confirm(`${type.toUpperCase()} all ${count} device${count !== '1' ? 's' : ''} in "${groupName}"?\n\nThis cannot be undone.`)) { e.target.value = ''; return; } } try { const result = await api.sendGroupCommand(groupId, type); showToast(`${type} sent to ${result.sent}/${result.total} devices${result.offline > 0 ? ` (${result.offline} offline)` : ''}`, result.offline > 0 ? 'warning' : 'success'); } catch (err) { showToast(err.message, 'error'); } e.target.value = ''; }); }); // Delete group document.querySelectorAll('[data-group-delete]').forEach(btn => { btn.addEventListener('click', async (e) => { e.stopPropagation(); const id = btn.dataset.groupDelete; if (!confirm('Delete this group? Devices will not be affected.')) return; try { await api.deleteGroup(id); showToast('Group deleted', 'success'); loadDashboard(); } catch (e) { showToast(e.message, 'error'); } }); }); // Manage group (add/remove devices) document.querySelectorAll('[data-group-manage]').forEach(btn => { btn.addEventListener('click', async (e) => { e.stopPropagation(); const groupId = btn.dataset.groupManage; const group = groupsWithDevices.find(g => g.id === groupId); const memberIds = new Set(group.devices.map(d => d.id)); // Get all groups for multi-group warning const otherGroups = groupsWithDevices.filter(g => g.id !== groupId); const modal = document.createElement('div'); modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;z-index:1000'; modal.innerHTML = `

${esc(group.name)}

Check devices to add them to this group

${allDevices.filter(d => d.status !== 'provisioning').map(d => { const inOther = otherGroups.filter(g => g.memberIds.has(d.id)).map(g => g.name); return ` `; }).join('')}
`; document.body.appendChild(modal); modal.querySelector('#manageGroupClose').onclick = () => { modal.remove(); loadDashboard(); }; modal.addEventListener('click', (ev) => { if (ev.target === modal) { modal.remove(); loadDashboard(); } }); modal.querySelectorAll('input[type="checkbox"]').forEach(cb => { cb.addEventListener('change', async () => { const deviceId = cb.dataset.deviceId; const existingGroups = cb.dataset.inGroups; try { if (cb.checked && existingGroups) { if (!confirm(`This device is already in: ${existingGroups}\n\nAdd it to "${group.name}" too?`)) { cb.checked = false; return; } } if (cb.checked) { await api.addDeviceToGroup(groupId, deviceId); } else { await api.removeDeviceFromGroup(groupId, deviceId); } } catch (err) { showToast(err.message, 'error'); cb.checked = !cb.checked; } }); }); }); }); } export function cleanup() { if (statusHandler) off('device-status', statusHandler); if (screenshotHandler) off('screenshot-ready', screenshotHandler); off('device-added', () => {}); off('device-removed', () => {}); if (refreshInterval) clearInterval(refreshInterval); statusHandler = null; screenshotHandler = null; refreshInterval = null; }