Sebebeb commited on
Commit
7eab36a
Β·
verified Β·
1 Parent(s): 9962ce3

Upload script.js

Browse files
Files changed (1) hide show
  1. public/admin/script.js +501 -0
public/admin/script.js ADDED
@@ -0,0 +1,501 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // ─── DISPATCH Admin Script ────────────────────────────────────────────────────
2
+ // All state is received from and pushed to the server via WebSocket only.
3
+ // No GET/POST for data; only file loading uses HTTP.
4
+
5
+ // ─── State ────────────────────────────────────────────────────────────────────
6
+ let state = {
7
+ sounds: [], // [{ id, name, data }]
8
+ notifications: [], // [{ id, name, heading, body, hyperlink, displayed, soundId }]
9
+ devices: [], // [{ uuid, name, notifications, lastConnection, schedule, pendingChanges, online }]
10
+ };
11
+
12
+ let selectedNotifId = null;
13
+ let selectedDeviceUUID = null;
14
+ let nowIntervalId = null;
15
+ let pendingSoundFile = null; // { name: str, data: base64 }
16
+
17
+ let ws = null;
18
+ let reconnectTimer = null;
19
+
20
+ // ─── WebSocket ────────────────────────────────────────────────────────────────
21
+ function connect() {
22
+ const proto = location.protocol === 'https:' ? 'wss' : 'wss';
23
+ ws = new WebSocket(`${proto}://${location.host}/admin-ws`);
24
+
25
+ ws.addEventListener('open', () => {
26
+ setIndicator(true);
27
+ clearTimeout(reconnectTimer);
28
+ });
29
+
30
+ ws.addEventListener('message', (e) => {
31
+ let msg;
32
+ try { msg = JSON.parse(e.data); } catch { return; }
33
+ handleMessage(msg);
34
+ });
35
+
36
+ ws.addEventListener('close', () => {
37
+ setIndicator(false);
38
+ reconnectTimer = setTimeout(connect, 3000);
39
+ });
40
+
41
+ ws.addEventListener('error', () => ws.close());
42
+ }
43
+
44
+ function send(msg) {
45
+ if (ws && ws.readyState === WebSocket.OPEN) {
46
+ ws.send(JSON.stringify(msg));
47
+ }
48
+ }
49
+
50
+ function setIndicator(online) {
51
+ const dot = document.getElementById('ws-indicator');
52
+ const label = document.getElementById('ws-label');
53
+ dot.className = `indicator ${online ? 'online' : 'offline'}`;
54
+ label.textContent = online ? 'LIVE' : 'OFFLINE';
55
+ }
56
+
57
+ // ─── Message Handling ─────────────────────────────────────────────────────────
58
+ function handleMessage(msg) {
59
+ switch (msg.type) {
60
+
61
+ case 'full_state': {
62
+ state.sounds = msg.sounds || [];
63
+ state.notifications = msg.notifications || [];
64
+ state.devices = msg.devices || [];
65
+ renderAll();
66
+ break;
67
+ }
68
+
69
+ case 'sound_added': {
70
+ if (!state.sounds.find(s => s.id === msg.sound.id)) {
71
+ state.sounds.push(msg.sound);
72
+ }
73
+ updateSoundSelect();
74
+ break;
75
+ }
76
+
77
+ case 'notification_added': {
78
+ if (!state.notifications.find(n => n.id === msg.notification.id)) {
79
+ state.notifications.push(msg.notification);
80
+ }
81
+ renderNotifList();
82
+ updateNotifCount();
83
+ toast('Notification created', 'success');
84
+ break;
85
+ }
86
+
87
+ case 'devices_updated': {
88
+ state.devices = msg.devices || [];
89
+ renderDeviceList();
90
+ updateDeviceCount();
91
+ // Refresh device detail if open
92
+ if (selectedDeviceUUID) {
93
+ const d = state.devices.find(d => d.uuid === selectedDeviceUUID);
94
+ if (d) renderDeviceDetail(d);
95
+ }
96
+ break;
97
+ }
98
+
99
+ case 'sound_data': {
100
+ // A sound's full data returned (for preview, etc.)
101
+ const existing = state.sounds.find(s => s.id === msg.sound.id);
102
+ if (existing) { existing.data = msg.sound.data; }
103
+ else { state.sounds.push(msg.sound); }
104
+ break;
105
+ }
106
+
107
+ default: break;
108
+ }
109
+ }
110
+
111
+ // ─── Render All ───────────────────────────────────────────────────────────────
112
+ function renderAll() {
113
+ updateSoundSelect();
114
+ renderNotifList();
115
+ updateNotifCount();
116
+ renderDeviceList();
117
+ updateDeviceCount();
118
+ if (selectedNotifId) {
119
+ const n = state.notifications.find(n => n.id === selectedNotifId);
120
+ if (!n) selectedNotifId = null;
121
+ else showSchedulePanel(n);
122
+ }
123
+ }
124
+
125
+ // ─── Notifications ────────────────────────────────────────────────────────────
126
+ function renderNotifList() {
127
+ const list = document.getElementById('notif-list');
128
+ if (!state.notifications.length) {
129
+ list.innerHTML = '<div class="empty-state">No notifications yet.</div>';
130
+ return;
131
+ }
132
+ list.innerHTML = state.notifications.map(n => {
133
+ const sound = n.soundId ? state.sounds.find(s => s.id === n.soundId) : null;
134
+ const sel = n.id === selectedNotifId ? 'selected' : '';
135
+ return `
136
+ <div class="notif-item ${sel}" data-id="${n.id}">
137
+ <div class="notif-item-name">${esc(n.name)}</div>
138
+ <div class="notif-item-heading">${esc(n.heading)}</div>
139
+ <div class="notif-item-body">${esc(n.body)}</div>
140
+ ${sound ? `<span class="notif-item-sound">β™ͺ ${esc(sound.name)}</span>` : ''}
141
+ </div>
142
+ `;
143
+ }).join('');
144
+
145
+ list.querySelectorAll('.notif-item').forEach(el => {
146
+ el.addEventListener('click', () => {
147
+ selectedNotifId = el.dataset.id;
148
+ list.querySelectorAll('.notif-item').forEach(e => e.classList.remove('selected'));
149
+ el.classList.add('selected');
150
+ const notif = state.notifications.find(n => n.id === selectedNotifId);
151
+ if (notif) showSchedulePanel(notif);
152
+ });
153
+ });
154
+ }
155
+
156
+ function updateNotifCount() {
157
+ document.getElementById('notif-count').textContent = state.notifications.length;
158
+ }
159
+
160
+ function updateSoundSelect() {
161
+ const sel = document.getElementById('f-sound');
162
+ const current = sel.value;
163
+ sel.innerHTML = '<option value="">β€” No Sound β€”</option>' +
164
+ state.sounds.map(s => `<option value="${s.id}">${esc(s.name)}</option>`).join('');
165
+ if (current) sel.value = current;
166
+ }
167
+
168
+ // ─── Schedule Panel ───────────────────────────────────────────────────────────
169
+ function showSchedulePanel(notif) {
170
+ document.getElementById('schedule-empty-state').style.display = 'none';
171
+ const active = document.getElementById('schedule-active');
172
+ active.style.display = 'flex';
173
+ active.style.flexDirection = 'column';
174
+
175
+ document.getElementById('selected-notif-name').textContent = notif.heading || notif.name;
176
+
177
+ // Set datetime pickers to now
178
+ setDateTimeToNow();
179
+ }
180
+
181
+ function setDateTimeToNow() {
182
+ const now = new Date();
183
+ const dateStr = now.toISOString().slice(0, 10);
184
+ const timeStr = now.toTimeString().slice(0, 5);
185
+ document.getElementById('sched-date').value = dateStr;
186
+ document.getElementById('sched-time').value = timeStr;
187
+ }
188
+
189
+ // ─── Devices ──────────────────────────────────────────────────────────────────
190
+ function renderDeviceList() {
191
+ const list = document.getElementById('device-list');
192
+ if (!state.devices.length) {
193
+ list.innerHTML = '<div class="empty-state">No devices yet.</div>';
194
+ return;
195
+ }
196
+ list.innerHTML = state.devices.map(d => {
197
+ const sel = d.uuid === selectedDeviceUUID ? 'selected' : '';
198
+ const lastConn = d.lastConnection
199
+ ? `Last seen ${timeSince(d.lastConnection)}`
200
+ : 'Active now';
201
+ return `
202
+ <div class="device-item ${sel}" data-uuid="${d.uuid}">
203
+ <span class="indicator ${d.online ? 'online' : 'offline'}"></span>
204
+ <div class="device-item-info">
205
+ <div class="device-item-name">${esc(d.name)}</div>
206
+ <div class="device-item-uuid">${d.uuid.slice(0,18)}…</div>
207
+ <div class="device-item-last">${lastConn}</div>
208
+ </div>
209
+ </div>
210
+ `;
211
+ }).join('');
212
+
213
+ list.querySelectorAll('.device-item').forEach(el => {
214
+ el.addEventListener('click', () => {
215
+ selectedDeviceUUID = el.dataset.uuid;
216
+ list.querySelectorAll('.device-item').forEach(e => e.classList.remove('selected'));
217
+ el.classList.add('selected');
218
+ const d = state.devices.find(d => d.uuid === selectedDeviceUUID);
219
+ if (d) renderDeviceDetail(d);
220
+ });
221
+ });
222
+ }
223
+
224
+ function updateDeviceCount() {
225
+ document.getElementById('device-count').textContent = state.devices.length;
226
+ }
227
+
228
+ function renderDeviceDetail(device) {
229
+ document.getElementById('device-empty-state').style.display = 'none';
230
+ const detail = document.getElementById('device-detail');
231
+ detail.style.display = 'block';
232
+
233
+ // Header
234
+ document.getElementById('detail-device-name-display').textContent = device.name;
235
+ document.getElementById('detail-device-uuid').textContent = device.uuid;
236
+ document.getElementById('device-name-input').value = device.name;
237
+
238
+ const statusEl = document.getElementById('device-status-indicator');
239
+ statusEl.className = `indicator ${device.online ? 'online' : 'offline'}`;
240
+
241
+ // Cached sounds
242
+ const soundsEl = document.getElementById('detail-sounds');
243
+ const cached = device.cachedSounds || [];
244
+ if (!cached.length) {
245
+ soundsEl.innerHTML = '<span style="color:var(--text-dimmer);font-size:12px">None cached</span>';
246
+ } else {
247
+ soundsEl.innerHTML = cached.map(sid => {
248
+ const sound = state.sounds.find(s => s.id === sid);
249
+ return `<span class="tag"><span class="tag-dot"></span>${esc(sound ? sound.name : sid.slice(0,8))}</span>`;
250
+ }).join('');
251
+ }
252
+
253
+ // Notifications table
254
+ const notifsEl = document.getElementById('detail-notifications');
255
+ const deviceNotifs = device.notifications || [];
256
+ if (!deviceNotifs.length) {
257
+ notifsEl.innerHTML = '<div class="empty-state" style="padding:20px 0">No notifications</div>';
258
+ } else {
259
+ notifsEl.innerHTML = `
260
+ <table class="data-table">
261
+ <thead>
262
+ <tr>
263
+ <th>NAME</th>
264
+ <th>HEADING</th>
265
+ <th>DISPLAYED</th>
266
+ </tr>
267
+ </thead>
268
+ <tbody>
269
+ ${deviceNotifs.map(n => `
270
+ <tr>
271
+ <td>${esc(n.name)}</td>
272
+ <td>${esc(n.heading)}</td>
273
+ <td style="color:${n.displayed ? 'var(--green)' : 'var(--text-dimmer)'}">${n.displayed ? 'βœ“ YES' : 'β€” NO'}</td>
274
+ </tr>
275
+ `).join('')}
276
+ </tbody>
277
+ </table>
278
+ `;
279
+ }
280
+
281
+ // Schedule table
282
+ const schedEl = document.getElementById('detail-schedule');
283
+ const schedule = device.schedule || [];
284
+ if (!schedule.length) {
285
+ schedEl.innerHTML = '<div class="empty-state" style="padding:20px 0">No scheduled notifications</div>';
286
+ } else {
287
+ schedEl.innerHTML = `
288
+ <table class="data-table">
289
+ <thead>
290
+ <tr>
291
+ <th>NOTIFICATION</th>
292
+ <th>SCHEDULED FOR</th>
293
+ <th>ACTION</th>
294
+ </tr>
295
+ </thead>
296
+ <tbody>
297
+ ${schedule.map(entry => {
298
+ const notif = state.notifications.find(n => n.id === entry.notificationId);
299
+ const name = notif ? (notif.heading || notif.name) : entry.notificationId.slice(0,8);
300
+ const when = new Date(entry.scheduledAt).toLocaleString();
301
+ return `
302
+ <tr>
303
+ <td>${esc(name)}</td>
304
+ <td>${when}</td>
305
+ <td>
306
+ <button class="action-btn" data-uuid="${device.uuid}" data-nid="${entry.notificationId}">REMOVE</button>
307
+ </td>
308
+ </tr>
309
+ `;
310
+ }).join('')}
311
+ </tbody>
312
+ </table>
313
+ `;
314
+ schedEl.querySelectorAll('.action-btn').forEach(btn => {
315
+ btn.addEventListener('click', () => {
316
+ send({
317
+ type: 'remove_schedule',
318
+ uuid: btn.dataset.uuid,
319
+ notificationId: btn.dataset.nid,
320
+ });
321
+ toast('Schedule entry removed');
322
+ });
323
+ });
324
+ }
325
+ }
326
+
327
+ // ─── Form: Create Notification ────────────────────────────────────────────────
328
+ document.getElementById('create-notif-btn').addEventListener('click', () => {
329
+ const name = document.getElementById('f-name').value.trim();
330
+ const heading = document.getElementById('f-heading').value.trim();
331
+ const body = document.getElementById('f-body').value.trim();
332
+ const hyperlink= document.getElementById('f-hyperlink').value.trim();
333
+ const soundId = document.getElementById('f-sound').value;
334
+
335
+ if (!name || !heading) {
336
+ toast('Name and Heading are required', 'error');
337
+ return;
338
+ }
339
+
340
+ // If there is a pending sound upload, upload it first, then create notif
341
+ if (pendingSoundFile) {
342
+ const { soundName, data } = pendingSoundFile;
343
+ if (!soundName) { toast('Please enter a name for the sound', 'error'); return; }
344
+ send({ type: 'create_sound', name: soundName, data });
345
+ // We'll pick up the soundId from the next sound_added event...
346
+ // To keep things simple, generate a local temp approach: just warn user to re-select
347
+ pendingSoundFile = null;
348
+ document.getElementById('sound-file-name').textContent = 'No file chosen';
349
+ document.getElementById('f-sound-name').value = '';
350
+ toast('Sound uploaded! Select it in the Sound dropdown, then create the notification.', 'success');
351
+ return;
352
+ }
353
+
354
+ send({
355
+ type: 'create_notification',
356
+ name, heading, body, hyperlink,
357
+ soundId: soundId || null,
358
+ });
359
+
360
+ // Clear form
361
+ document.getElementById('f-name').value = '';
362
+ document.getElementById('f-heading').value = '';
363
+ document.getElementById('f-body').value = '';
364
+ document.getElementById('f-hyperlink').value = '';
365
+ document.getElementById('f-sound').value = '';
366
+ });
367
+
368
+ // ─── Sound File Upload ────────────────────────────────────────────────────────
369
+ document.getElementById('sound-upload-btn').addEventListener('click', () => {
370
+ document.getElementById('f-sound-file').click();
371
+ });
372
+
373
+ document.getElementById('f-sound-file').addEventListener('change', (e) => {
374
+ const file = e.target.files[0];
375
+ if (!file) return;
376
+ document.getElementById('sound-file-name').textContent = file.name;
377
+
378
+ const reader = new FileReader();
379
+ reader.onload = (evt) => {
380
+ const base64 = evt.target.result.split(',')[1];
381
+ const soundName = document.getElementById('f-sound-name').value.trim() || file.name;
382
+ pendingSoundFile = { soundName, data: base64 };
383
+ };
384
+ reader.readAsDataURL(file);
385
+ });
386
+
387
+ document.getElementById('f-sound-name').addEventListener('input', () => {
388
+ if (pendingSoundFile) {
389
+ pendingSoundFile.soundName = document.getElementById('f-sound-name').value.trim();
390
+ }
391
+ });
392
+
393
+ // ─── Play Now Button ──────────────────────────────────────────────────────────
394
+ document.getElementById('play-now-btn').addEventListener('click', () => {
395
+ if (!selectedNotifId) return;
396
+ // Play on ALL devices β€” or if a device is selected in devices tab, just that one
397
+ // Here we play on all connected devices
398
+ for (const device of state.devices) {
399
+ send({ type: 'play_now', uuid: device.uuid, notificationId: selectedNotifId });
400
+ }
401
+ toast('β–Ά Sent to all devices', 'success');
402
+ });
403
+
404
+ // ─── Now Toggle ───────────────────────────────────────────────────────────────
405
+ document.getElementById('now-toggle').addEventListener('change', (e) => {
406
+ if (e.target.checked) {
407
+ setDateTimeToNow();
408
+ nowIntervalId = setInterval(setDateTimeToNow, 1000);
409
+ document.getElementById('datetime-pickers').style.opacity = '0.5';
410
+ document.getElementById('datetime-pickers').style.pointerEvents = 'none';
411
+ } else {
412
+ clearInterval(nowIntervalId);
413
+ document.getElementById('datetime-pickers').style.opacity = '';
414
+ document.getElementById('datetime-pickers').style.pointerEvents = '';
415
+ }
416
+ });
417
+
418
+ // ─── Schedule Button ──────────────────────────────────────────────────────────
419
+ document.getElementById('schedule-btn').addEventListener('click', () => {
420
+ if (!selectedNotifId) return;
421
+
422
+ const dateVal = document.getElementById('sched-date').value;
423
+ const timeVal = document.getElementById('sched-time').value;
424
+ if (!dateVal || !timeVal) { toast('Select a date and time', 'error'); return; }
425
+
426
+ const scheduledAt = new Date(`${dateVal}T${timeVal}`).getTime();
427
+ if (isNaN(scheduledAt)) { toast('Invalid date/time', 'error'); return; }
428
+
429
+ // Schedule on ALL devices, or just selected device
430
+ const targets = selectedDeviceUUID
431
+ ? state.devices.filter(d => d.uuid === selectedDeviceUUID)
432
+ : state.devices;
433
+
434
+ if (!targets.length) { toast('No devices to schedule on', 'error'); return; }
435
+
436
+ for (const device of targets) {
437
+ send({
438
+ type: 'schedule_notification',
439
+ uuid: device.uuid,
440
+ notificationId: selectedNotifId,
441
+ scheduledAt,
442
+ });
443
+ }
444
+ toast(`Scheduled on ${targets.length} device(s)`, 'success');
445
+ });
446
+
447
+ // ─── Tabs ─────────────────────────────────────────────────────────────────────
448
+ document.querySelectorAll('.tab-btn').forEach(btn => {
449
+ btn.addEventListener('click', () => {
450
+ const tab = btn.dataset.tab;
451
+ document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
452
+ document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
453
+ btn.classList.add('active');
454
+ document.getElementById(`tab-${tab}`).classList.add('active');
455
+ });
456
+ });
457
+
458
+ // ─── Device Name Save ─────────────────────────────────────────────────────────
459
+ document.getElementById('device-name-save').addEventListener('click', () => {
460
+ if (!selectedDeviceUUID) return;
461
+ const name = document.getElementById('device-name-input').value.trim();
462
+ if (!name) { toast('Enter a name', 'error'); return; }
463
+ send({ type: 'update_device_name', uuid: selectedDeviceUUID, name });
464
+ toast('Name updated', 'success');
465
+ });
466
+
467
+ // ─── Toast ────────────────────────────────────────────────────────────────────
468
+ let toastTimer;
469
+ function toast(msg, type = '') {
470
+ let el = document.getElementById('toast');
471
+ if (!el) {
472
+ el = document.createElement('div');
473
+ el.id = 'toast';
474
+ document.body.appendChild(el);
475
+ }
476
+ el.textContent = msg;
477
+ el.className = type ? `show ${type}` : 'show';
478
+ clearTimeout(toastTimer);
479
+ toastTimer = setTimeout(() => { el.className = el.className.replace('show', '').trim(); }, 3000);
480
+ }
481
+
482
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
483
+ function esc(str) {
484
+ return String(str || '')
485
+ .replace(/&/g,'&amp;')
486
+ .replace(/</g,'&lt;')
487
+ .replace(/>/g,'&gt;')
488
+ .replace(/"/g,'&quot;');
489
+ }
490
+
491
+ function timeSince(ts) {
492
+ const diff = Date.now() - ts;
493
+ if (diff < 60000) return `${Math.floor(diff/1000)}s ago`;
494
+ if (diff < 3600000) return `${Math.floor(diff/60000)}m ago`;
495
+ if (diff < 86400000) return `${Math.floor(diff/3600000)}h ago`;
496
+ return `${Math.floor(diff/86400000)}d ago`;
497
+ }
498
+
499
+ // ─── Boot ──────────────────────────────────────────────────────────��──────────
500
+ setDateTimeToNow();
501
+ connect();