// ─── DISPATCH Admin Script ──────────────────────────────────────────────────── const SESSION_KEY = 'dispatch_session_token'; // ─── State ──────────────────────────────────────────────────────────────────── let state = { sounds: [], notifications: [], devices: [], }; let selectedNotifId = null; let selectedDeviceUUID = null; let nowIntervalId = null; let pendingSoundFile = null; let ws = null; let reconnectTimer = null; let authenticated = false; // ─── UI Refs ────────────────────────────────────────────────────────────────── const loginScreen = document.getElementById('login-screen'); const appEl = document.getElementById('app'); const loginBtn = document.getElementById('login-btn'); const loginPwInput = document.getElementById('login-password'); const loginError = document.getElementById('login-error'); const loginForm = document.getElementById('login-form'); const loginConnecting = document.getElementById('login-connecting'); // ─── Auth Helpers ───────────────────────────────────────────────────────────── function getToken() { return sessionStorage.getItem(SESSION_KEY); } function setToken(t) { sessionStorage.setItem(SESSION_KEY, t); } function clearToken() { sessionStorage.removeItem(SESSION_KEY); } function showApp() { loginScreen.style.display = 'none'; appEl.style.display = 'block'; authenticated = true; } function showLogin(errorMsg) { appEl.style.display = 'none'; loginScreen.style.display = 'flex'; authenticated = false; loginForm.style.display = 'block'; loginConnecting.style.display = 'none'; loginPwInput.value = ''; loginError.textContent = errorMsg || ''; } function setLoginLoading(loading) { loginForm.style.display = loading ? 'none' : 'block'; loginConnecting.style.display = loading ? 'flex' : 'none'; } // ─── WebSocket ──────────────────────────────────────────────────────────────── function connect() { clearTimeout(reconnectTimer); const proto = location.protocol === 'https:' ? 'wss' : 'ws'; ws = new WebSocket(`${proto}://${location.host}/admin-ws`); ws.addEventListener('open', () => { // Server will immediately send auth_required; we respond appropriately }); ws.addEventListener('message', (e) => { let msg; try { msg = JSON.parse(e.data); } catch { return; } handleMessage(msg); }); ws.addEventListener('close', () => { if (authenticated) { setIndicator(false); // Try to reconnect silently — session token is still in sessionStorage reconnectTimer = setTimeout(connect, 3000); } }); ws.addEventListener('error', () => ws.close()); } function send(msg) { if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg)); } // ─── Message Handling ───────────────────────────────────────────────────────── function handleMessage(msg) { switch (msg.type) { case 'auth_required': { // Server is asking us to authenticate const token = getToken(); if (token) { // Try to resume existing session setLoginLoading(true); send({ type: 'auth_resume', token }); } else { // Show login form showLogin(); } break; } case 'auth_ok': { setToken(msg.token); showApp(); setIndicator(true); break; } case 'auth_error': { clearToken(); showLogin(msg.reason || 'Authentication failed.'); break; } case 'auth_timeout': { clearToken(); showLogin('Connection timed out. Please try again.'); break; } case 'full_state': { state.sounds = msg.sounds || []; state.notifications = msg.notifications || []; state.devices = msg.devices || []; renderAll(); break; } case 'sound_added': { if (!state.sounds.find(s => s.id === msg.sound.id)) state.sounds.push(msg.sound); updateSoundSelect(); break; } case 'notification_added': { if (!state.notifications.find(n => n.id === msg.notification.id)) state.notifications.push(msg.notification); renderNotifList(); updateNotifCount(); toast('Notification created', 'success'); break; } case 'devices_updated': { state.devices = msg.devices || []; renderDeviceList(); updateDeviceCount(); if (selectedDeviceUUID) { const d = state.devices.find(d => d.uuid === selectedDeviceUUID); if (d) renderDeviceDetail(d); } break; } case 'sound_data': { const existing = state.sounds.find(s => s.id === msg.sound.id); if (existing) { existing.data = msg.sound.data; } else { state.sounds.push(msg.sound); } break; } default: break; } } // ─── Login Form ─────────────────────────────────────────────────────────────── loginBtn.addEventListener('click', doLogin); loginPwInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') doLogin(); }); function doLogin() { const password = loginPwInput.value; if (!password) { loginError.textContent = 'Enter a password.'; return; } loginError.textContent = ''; setLoginLoading(true); // If WS is not open yet, connect first then send on open if (!ws || ws.readyState !== WebSocket.OPEN) { connect(); // wait for auth_required, then it will show login — but we want to auto-submit // Store the password to auto-submit after connection ws.addEventListener('open', () => { // auth_required will trigger from server, which will show login again // Instead, wait for auth_required then send login directly }, { once: true }); // Override: on auth_required from a fresh connect after user clicked login, // send the login immediately const originalHandler = ws.onmessage; ws.addEventListener('message', function onFirstMsg(e) { let msg; try { msg = JSON.parse(e.data); } catch { return; } if (msg.type === 'auth_required') { ws.removeEventListener('message', onFirstMsg); send({ type: 'auth_login', password }); } }); } else { send({ type: 'auth_login', password }); } } // ─── Logout ─────────────────────────────────────────────────────────────────── document.getElementById('logout-btn').addEventListener('click', () => { clearToken(); authenticated = false; clearTimeout(reconnectTimer); if (ws) ws.close(); showLogin(); }); // ─── Render All ─────────────────────────────────────────────────────────────── function renderAll() { updateSoundSelect(); renderNotifList(); updateNotifCount(); renderDeviceList(); updateDeviceCount(); if (selectedNotifId) { const n = state.notifications.find(n => n.id === selectedNotifId); if (!n) selectedNotifId = null; else showSchedulePanel(n); } } // ─── Notifications ──────────────────────────────────────────────────────────── function renderNotifList() { const list = document.getElementById('notif-list'); if (!state.notifications.length) { list.innerHTML = '
No notifications yet.
'; return; } list.innerHTML = state.notifications.map(n => { const sound = n.soundId ? state.sounds.find(s => s.id === n.soundId) : null; const sel = n.id === selectedNotifId ? 'selected' : ''; return `
${esc(n.name)}
${esc(n.heading)}
${esc(n.body)}
${sound ? `♪ ${esc(sound.name)}` : ''}
`; }).join(''); list.querySelectorAll('.notif-item').forEach(el => { el.addEventListener('click', () => { selectedNotifId = el.dataset.id; list.querySelectorAll('.notif-item').forEach(e => e.classList.remove('selected')); el.classList.add('selected'); const notif = state.notifications.find(n => n.id === selectedNotifId); if (notif) showSchedulePanel(notif); }); }); } function updateNotifCount() { document.getElementById('notif-count').textContent = state.notifications.length; } function updateSoundSelect() { const sel = document.getElementById('f-sound'); const current = sel.value; sel.innerHTML = '' + state.sounds.map(s => ``).join(''); if (current) sel.value = current; } // ─── Schedule Panel ─────────────────────────────────────────────────────────── function showSchedulePanel(notif) { document.getElementById('schedule-empty-state').style.display = 'none'; const active = document.getElementById('schedule-active'); active.style.display = 'flex'; active.style.flexDirection = 'column'; document.getElementById('selected-notif-name').textContent = notif.heading || notif.name; setDateTimeToNow(); } function setDateTimeToNow() { const now = new Date(); document.getElementById('sched-date').value = now.toISOString().slice(0, 10); document.getElementById('sched-time').value = now.toTimeString().slice(0, 5); } // ─── Devices ────────────────────────────────────────────────────────────────── function renderDeviceList() { const list = document.getElementById('device-list'); if (!state.devices.length) { list.innerHTML = '
No devices yet.
'; return; } list.innerHTML = state.devices.map(d => { const sel = d.uuid === selectedDeviceUUID ? 'selected' : ''; const lastConn = d.lastConnection ? `Last seen ${timeSince(d.lastConnection)}` : 'Active now'; return `
${esc(d.name)}
${d.uuid.slice(0,18)}…
${lastConn}
`; }).join(''); list.querySelectorAll('.device-item').forEach(el => { el.addEventListener('click', () => { selectedDeviceUUID = el.dataset.uuid; list.querySelectorAll('.device-item').forEach(e => e.classList.remove('selected')); el.classList.add('selected'); const d = state.devices.find(d => d.uuid === selectedDeviceUUID); if (d) renderDeviceDetail(d); }); }); } function updateDeviceCount() { document.getElementById('device-count').textContent = state.devices.length; } function renderDeviceDetail(device) { document.getElementById('device-empty-state').style.display = 'none'; const detail = document.getElementById('device-detail'); detail.style.display = 'block'; document.getElementById('detail-device-name-display').textContent = device.name; document.getElementById('detail-device-uuid').textContent = device.uuid; document.getElementById('device-name-input').value = device.name; document.getElementById('device-status-indicator').className = `indicator ${device.online ? 'online' : 'offline'}`; // Cached sounds const soundsEl = document.getElementById('detail-sounds'); const cached = device.cachedSounds || []; soundsEl.innerHTML = !cached.length ? 'None cached' : cached.map(sid => { const sound = state.sounds.find(s => s.id === sid); return `${esc(sound ? sound.name : sid.slice(0,8))}`; }).join(''); // Notifications table const notifsEl = document.getElementById('detail-notifications'); const deviceNotifs = device.notifications || []; notifsEl.innerHTML = !deviceNotifs.length ? '
No notifications
' : ` ${deviceNotifs.map(n => ``).join('')}
NAMEHEADINGDISPLAYED
${esc(n.name)}${esc(n.heading)} ${n.displayed ? '✓ YES' : '— NO'}
`; // Schedule table const schedEl = document.getElementById('detail-schedule'); const schedule = device.schedule || []; if (!schedule.length) { schedEl.innerHTML = '
No scheduled notifications
'; } else { schedEl.innerHTML = ` ${schedule.map(entry => { const notif = state.notifications.find(n => n.id === entry.notificationId); const name = notif ? (notif.heading || notif.name) : entry.notificationId.slice(0,8); return ``; }).join('')}
NOTIFICATIONSCHEDULED FORACTION
${esc(name)} ${new Date(entry.scheduledAt).toLocaleString()}
`; schedEl.querySelectorAll('.action-btn').forEach(btn => { btn.addEventListener('click', () => { send({ type: 'remove_schedule', uuid: btn.dataset.uuid, notificationId: btn.dataset.nid }); toast('Schedule entry removed'); }); }); } } // ─── Create Notification ────────────────────────────────────────────────────── document.getElementById('create-notif-btn').addEventListener('click', () => { const name = document.getElementById('f-name').value.trim(); const heading = document.getElementById('f-heading').value.trim(); const body = document.getElementById('f-body').value.trim(); const hyperlink = document.getElementById('f-hyperlink').value.trim(); const soundId = document.getElementById('f-sound').value; if (!name || !heading) { toast('Name and Heading are required', 'error'); return; } if (pendingSoundFile) { const { soundName, data } = pendingSoundFile; if (!soundName) { toast('Enter a name for the sound', 'error'); return; } send({ type: 'create_sound', name: soundName, data }); pendingSoundFile = null; document.getElementById('sound-file-name').textContent = 'No file chosen'; document.getElementById('f-sound-name').value = ''; toast('Sound uploaded — select it in the dropdown, then create the notification.', 'success'); return; } send({ type: 'create_notification', name, heading, body, hyperlink, soundId: soundId || null }); document.getElementById('f-name').value = ''; document.getElementById('f-heading').value = ''; document.getElementById('f-body').value = ''; document.getElementById('f-hyperlink').value = ''; document.getElementById('f-sound').value = ''; }); document.getElementById('sound-upload-btn').addEventListener('click', () => { document.getElementById('f-sound-file').click(); }); document.getElementById('f-sound-file').addEventListener('change', (e) => { const file = e.target.files[0]; if (!file) return; document.getElementById('sound-file-name').textContent = file.name; const reader = new FileReader(); reader.onload = (evt) => { const base64 = evt.target.result.split(',')[1]; const soundName = document.getElementById('f-sound-name').value.trim() || file.name; pendingSoundFile = { soundName, data: base64 }; }; reader.readAsDataURL(file); }); document.getElementById('f-sound-name').addEventListener('input', () => { if (pendingSoundFile) pendingSoundFile.soundName = document.getElementById('f-sound-name').value.trim(); }); // ─── Play Now ───────────────────────────────────────────────────────────────── document.getElementById('play-now-btn').addEventListener('click', () => { if (!selectedNotifId) return; for (const device of state.devices) { send({ type: 'play_now', uuid: device.uuid, notificationId: selectedNotifId }); } toast('▶ Sent to all devices', 'success'); }); // ─── Now Toggle ─────────────────────────────────────────────────────────────── document.getElementById('now-toggle').addEventListener('change', (e) => { if (e.target.checked) { setDateTimeToNow(); nowIntervalId = setInterval(setDateTimeToNow, 1000); document.getElementById('datetime-pickers').style.opacity = '0.5'; document.getElementById('datetime-pickers').style.pointerEvents = 'none'; } else { clearInterval(nowIntervalId); document.getElementById('datetime-pickers').style.opacity = ''; document.getElementById('datetime-pickers').style.pointerEvents = ''; } }); // ─── Schedule ───────────────────────────────────────────────────────────────── document.getElementById('schedule-btn').addEventListener('click', () => { if (!selectedNotifId) return; const dateVal = document.getElementById('sched-date').value; const timeVal = document.getElementById('sched-time').value; if (!dateVal || !timeVal) { toast('Select a date and time', 'error'); return; } const scheduledAt = new Date(`${dateVal}T${timeVal}`).getTime(); if (isNaN(scheduledAt)) { toast('Invalid date/time', 'error'); return; } const targets = selectedDeviceUUID ? state.devices.filter(d => d.uuid === selectedDeviceUUID) : state.devices; if (!targets.length) { toast('No devices to schedule on', 'error'); return; } for (const device of targets) { send({ type: 'schedule_notification', uuid: device.uuid, notificationId: selectedNotifId, scheduledAt }); } toast(`Scheduled on ${targets.length} device(s)`, 'success'); }); // ─── Tabs ───────────────────────────────────────────────────────────────────── document.querySelectorAll('.tab-btn').forEach(btn => { btn.addEventListener('click', () => { const tab = btn.dataset.tab; document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active')); document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active')); btn.classList.add('active'); document.getElementById(`tab-${tab}`).classList.add('active'); }); }); // ─── Device Name ───────────────────────────────────────────────────────────── document.getElementById('device-name-save').addEventListener('click', () => { if (!selectedDeviceUUID) return; const name = document.getElementById('device-name-input').value.trim(); if (!name) { toast('Enter a name', 'error'); return; } send({ type: 'update_device_name', uuid: selectedDeviceUUID, name }); toast('Name updated', 'success'); }); // ─── WS Indicator ───────────────────────────────────────────────────────────── function setIndicator(online) { const dot = document.getElementById('ws-indicator'); const label = document.getElementById('ws-label'); dot.className = `indicator ${online ? 'online' : 'offline'}`; label.textContent = online ? 'LIVE' : 'OFFLINE'; } // ─── Toast ──────────────────────────────────────────────────────────────────── let toastTimer; function toast(msg, type = '') { let el = document.getElementById('toast'); if (!el) { el = document.createElement('div'); el.id = 'toast'; document.body.appendChild(el); } el.textContent = msg; el.className = type ? `show ${type}` : 'show'; clearTimeout(toastTimer); toastTimer = setTimeout(() => { el.className = el.className.replace('show', '').trim(); }, 3000); } // ─── Helpers ────────────────────────────────────────────────────────────────── function esc(str) { return String(str || '').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); } function timeSince(ts) { const d = Date.now() - ts; if (d < 60000) return `${Math.floor(d/1000)}s ago`; if (d < 3600000) return `${Math.floor(d/60000)}m ago`; if (d < 86400000) return `${Math.floor(d/3600000)}h ago`; return `${Math.floor(d/86400000)}d ago`; } // ─── Boot ───────────────────────────────────────────────────────────────────── setDateTimeToNow(); connect();