System / public /admin /script.js
Sebebeb's picture
Update public/admin/script.js
9bfc2c5 verified
Raw
History Blame Contribute Delete
22.8 kB
// ─── 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 = '<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;
}
// ─── 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 = '<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'}`;
// Cached sounds
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('');
// Notifications table
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>`;
// Schedule 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');
});
});
}
}
// ─── 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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
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();