| |
|
|
| const SESSION_KEY = 'dispatch_session_token'; |
|
|
| |
| 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; |
|
|
| |
| 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'); |
|
|
| |
| 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'; |
| } |
|
|
| |
| function connect() { |
| clearTimeout(reconnectTimer); |
| const proto = location.protocol === 'https:' ? 'wss' : 'ws'; |
| ws = new WebSocket(`${proto}://${location.host}/admin-ws`); |
|
|
| ws.addEventListener('open', () => { |
| |
| }); |
|
|
| ws.addEventListener('message', (e) => { |
| let msg; |
| try { msg = JSON.parse(e.data); } catch { return; } |
| handleMessage(msg); |
| }); |
|
|
| ws.addEventListener('close', () => { |
| if (authenticated) { |
| setIndicator(false); |
| |
| reconnectTimer = setTimeout(connect, 3000); |
| } |
| }); |
|
|
| ws.addEventListener('error', () => ws.close()); |
| } |
|
|
| function send(msg) { |
| if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg)); |
| } |
|
|
| |
| function handleMessage(msg) { |
| switch (msg.type) { |
|
|
| case 'auth_required': { |
| |
| const token = getToken(); |
| if (token) { |
| |
| setLoginLoading(true); |
| send({ type: 'auth_resume', token }); |
| } else { |
| |
| 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; |
| } |
| } |
|
|
| |
| 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 || ws.readyState !== WebSocket.OPEN) { |
| connect(); |
| |
| |
| ws.addEventListener('open', () => { |
| |
| |
| }, { once: true }); |
| |
| |
| 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 }); |
| } |
| } |
|
|
| |
| document.getElementById('logout-btn').addEventListener('click', () => { |
| clearToken(); |
| authenticated = false; |
| clearTimeout(reconnectTimer); |
| if (ws) ws.close(); |
| showLogin(); |
| }); |
|
|
| |
| 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); |
| } |
| } |
|
|
| |
| function renderNotifList() { |
| const list = document.getElementById('notif-list'); |
| if (!state.notifications.length) { |
| list.innerHTML = '<div class="empty-state">No notifications yet.</div>'; |
| 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 ` |
| <div class="notif-item ${sel}" data-id="${n.id}"> |
| <div class="notif-item-name">${esc(n.name)}</div> |
| <div class="notif-item-heading">${esc(n.heading)}</div> |
| <div class="notif-item-body">${esc(n.body)}</div> |
| ${sound ? `<span class="notif-item-sound">βͺ ${esc(sound.name)}</span>` : ''} |
| </div>`; |
| }).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 = '<option value="">β No Sound β</option>' + |
| state.sounds.map(s => `<option value="${s.id}">${esc(s.name)}</option>`).join(''); |
| if (current) sel.value = current; |
| } |
|
|
| |
| 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); |
| } |
|
|
| |
| function renderDeviceList() { |
| const list = document.getElementById('device-list'); |
| if (!state.devices.length) { |
| list.innerHTML = '<div class="empty-state">No devices yet.</div>'; |
| 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 ` |
| <div class="device-item ${sel}" data-uuid="${d.uuid}"> |
| <span class="indicator ${d.online ? 'online' : 'offline'}"></span> |
| <div class="device-item-info"> |
| <div class="device-item-name">${esc(d.name)}</div> |
| <div class="device-item-uuid">${d.uuid.slice(0,18)}β¦</div> |
| <div class="device-item-last">${lastConn}</div> |
| </div> |
| </div>`; |
| }).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'}`; |
|
|
| |
| const soundsEl = document.getElementById('detail-sounds'); |
| const cached = device.cachedSounds || []; |
| soundsEl.innerHTML = !cached.length |
| ? '<span style="color:var(--text-dimmer);font-size:12px">None cached</span>' |
| : cached.map(sid => { |
| const sound = state.sounds.find(s => s.id === sid); |
| return `<span class="tag"><span class="tag-dot"></span>${esc(sound ? sound.name : sid.slice(0,8))}</span>`; |
| }).join(''); |
|
|
| |
| const notifsEl = document.getElementById('detail-notifications'); |
| const deviceNotifs = device.notifications || []; |
| notifsEl.innerHTML = !deviceNotifs.length |
| ? '<div class="empty-state" style="padding:20px 0">No notifications</div>' |
| : `<table class="data-table"><thead><tr><th>NAME</th><th>HEADING</th><th>DISPLAYED</th></tr></thead><tbody> |
| ${deviceNotifs.map(n => `<tr> |
| <td>${esc(n.name)}</td><td>${esc(n.heading)}</td> |
| <td style="color:${n.displayed ? 'var(--green)' : 'var(--text-dimmer)'}">${n.displayed ? 'β YES' : 'β NO'}</td> |
| </tr>`).join('')} |
| </tbody></table>`; |
|
|
| |
| const schedEl = document.getElementById('detail-schedule'); |
| const schedule = device.schedule || []; |
| if (!schedule.length) { |
| schedEl.innerHTML = '<div class="empty-state" style="padding:20px 0">No scheduled notifications</div>'; |
| } else { |
| schedEl.innerHTML = `<table class="data-table"><thead><tr><th>NOTIFICATION</th><th>SCHEDULED FOR</th><th>ACTION</th></tr></thead><tbody> |
| ${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 `<tr> |
| <td>${esc(name)}</td> |
| <td>${new Date(entry.scheduledAt).toLocaleString()}</td> |
| <td><button class="action-btn" data-uuid="${device.uuid}" data-nid="${entry.notificationId}">REMOVE</button></td> |
| </tr>`; |
| }).join('')} |
| </tbody></table>`; |
| 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'); |
| }); |
| }); |
| } |
| } |
|
|
| |
| 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(); |
| }); |
|
|
| |
| 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'); |
| }); |
|
|
| |
| 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 = ''; |
| } |
| }); |
|
|
| |
| 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'); |
| }); |
|
|
| |
| 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'); |
| }); |
| }); |
|
|
| |
| 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'); |
| }); |
|
|
| |
| 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'; |
| } |
|
|
| |
| 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); |
| } |
|
|
| |
| function esc(str) { |
| return String(str || '').replace(/&/g,'&').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`; |
| } |
|
|
| |
| setDateTimeToNow(); |
| connect(); |