Sebebeb commited on
Commit
0325642
Β·
verified Β·
1 Parent(s): c8000c1

Upload 8 files

Browse files
Dockerfile ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM node:20-slim
2
+
3
+ WORKDIR /app
4
+
5
+ COPY package.json ./
6
+ RUN npm install
7
+
8
+ COPY . .
9
+
10
+ # Generate self-signed cert for HTTPS
11
+ RUN apt-get update && apt-get install -y openssl && \
12
+ openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem -days 365 -nodes \
13
+ -subj "/C=US/ST=State/L=City/O=Org/CN=localhost"
14
+
15
+ EXPOSE 7860
16
+
17
+ CMD ["node", "server.js"]
package.json ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "notification-server",
3
+ "version": "1.0.0",
4
+ "description": "Notification push server with WebSocket and admin panel",
5
+ "main": "server.js",
6
+ "scripts": {
7
+ "start": "node server.js"
8
+ },
9
+ "dependencies": {
10
+ "ws": "^8.16.0",
11
+ "uuid": "^9.0.0"
12
+ }
13
+ }
public/admin/index.html ADDED
@@ -0,0 +1,174 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>DISPATCH // Admin</title>
7
+ <link rel="stylesheet" href="/admin/style.css" />
8
+ </head>
9
+ <body>
10
+ <div id="root">
11
+
12
+ <!-- Top Bar -->
13
+ <header id="topbar">
14
+ <div id="topbar-logo">DISPATCH</div>
15
+ <div id="topbar-status">
16
+ <span id="ws-indicator" class="indicator offline"></span>
17
+ <span id="ws-label">OFFLINE</span>
18
+ </div>
19
+ </header>
20
+
21
+ <!-- Tabs -->
22
+ <nav id="tabs">
23
+ <button class="tab-btn active" data-tab="notifications">NOTIFICATIONS</button>
24
+ <button class="tab-btn" data-tab="devices">DEVICES</button>
25
+ </nav>
26
+
27
+ <!-- ── NOTIFICATIONS TAB ── -->
28
+ <div id="tab-notifications" class="tab-panel active">
29
+ <div id="notif-layout">
30
+
31
+ <!-- LEFT: Create Notification Form -->
32
+ <aside id="create-panel">
33
+ <h2 class="panel-title">NEW NOTIFICATION</h2>
34
+ <div class="form-group">
35
+ <label for="f-name">Internal Name</label>
36
+ <input id="f-name" type="text" placeholder="e.g. promo-sept-24" />
37
+ </div>
38
+ <div class="form-group">
39
+ <label for="f-heading">Heading</label>
40
+ <input id="f-heading" type="text" placeholder="Notification heading" />
41
+ </div>
42
+ <div class="form-group">
43
+ <label for="f-body">Body</label>
44
+ <textarea id="f-body" rows="4" placeholder="Notification body text..."></textarea>
45
+ </div>
46
+ <div class="form-group">
47
+ <label for="f-hyperlink">Hyperlink</label>
48
+ <input id="f-hyperlink" type="url" placeholder="https://..." />
49
+ </div>
50
+ <div class="form-group">
51
+ <label for="f-sound">Sound</label>
52
+ <select id="f-sound">
53
+ <option value="">β€” No Sound β€”</option>
54
+ </select>
55
+ </div>
56
+ <div class="form-group">
57
+ <label>Upload Sound File</label>
58
+ <div id="sound-upload-area">
59
+ <input id="f-sound-file" type="file" accept="audio/*" hidden />
60
+ <button id="sound-upload-btn" class="btn-outline" type="button">
61
+ β–² UPLOAD AUDIO
62
+ </button>
63
+ <span id="sound-file-name">No file chosen</span>
64
+ </div>
65
+ <input id="f-sound-name" type="text" placeholder="Sound name (required for upload)" style="margin-top:8px" />
66
+ </div>
67
+ <button id="create-notif-btn" class="btn-create">CREATE NOTIFICATION</button>
68
+ </aside>
69
+
70
+ <!-- MIDDLE: Notification List -->
71
+ <main id="notif-list-panel">
72
+ <h2 class="panel-title">ALL NOTIFICATIONS <span id="notif-count" class="badge">0</span></h2>
73
+ <div id="notif-list">
74
+ <div class="empty-state">No notifications yet.</div>
75
+ </div>
76
+ </main>
77
+
78
+ <!-- RIGHT: Schedule Panel -->
79
+ <section id="schedule-panel">
80
+ <div id="schedule-empty-state">
81
+ <div class="select-hint">
82
+ <div class="select-hint-icon">β—Ž</div>
83
+ <p>Select a notification<br>to schedule or play it</p>
84
+ </div>
85
+ </div>
86
+ <div id="schedule-active" style="display:none">
87
+ <div id="selected-notif-info">
88
+ <div id="selected-notif-name"></div>
89
+ </div>
90
+ <button id="play-now-btn" title="Play Now">
91
+ <span>β–Ά PLAY NOW</span>
92
+ </button>
93
+ <div id="schedule-controls">
94
+ <div class="toggle-row">
95
+ <label class="toggle-label" for="now-toggle">NOW</label>
96
+ <label class="toggle-switch">
97
+ <input type="checkbox" id="now-toggle" />
98
+ <span class="toggle-track"><span class="toggle-thumb"></span></span>
99
+ </label>
100
+ </div>
101
+ <div id="datetime-pickers">
102
+ <div class="picker-group">
103
+ <label>DATE</label>
104
+ <input type="date" id="sched-date" />
105
+ </div>
106
+ <div class="picker-group">
107
+ <label>TIME</label>
108
+ <input type="time" id="sched-time" />
109
+ </div>
110
+ </div>
111
+ <button id="schedule-btn" class="btn-schedule">SCHEDULE</button>
112
+ </div>
113
+ </div>
114
+ </section>
115
+
116
+ </div>
117
+ </div>
118
+
119
+ <!-- ── DEVICES TAB ── -->
120
+ <div id="tab-devices" class="tab-panel">
121
+ <div id="devices-layout">
122
+
123
+ <!-- Device List -->
124
+ <aside id="device-list-panel">
125
+ <h2 class="panel-title">DEVICES <span id="device-count" class="badge">0</span></h2>
126
+ <div id="device-list">
127
+ <div class="empty-state">No devices connected yet.</div>
128
+ </div>
129
+ </aside>
130
+
131
+ <!-- Device Detail -->
132
+ <main id="device-detail-panel">
133
+ <div id="device-empty-state" class="select-hint">
134
+ <div class="select-hint-icon">β—‰</div>
135
+ <p>Select a device<br>to manage it</p>
136
+ </div>
137
+ <div id="device-detail" style="display:none">
138
+ <div id="device-header">
139
+ <div id="device-status-indicator" class="indicator"></div>
140
+ <div>
141
+ <div id="detail-device-name-display" class="device-detail-name"></div>
142
+ <div id="detail-device-uuid" class="device-detail-uuid"></div>
143
+ </div>
144
+ <div id="device-name-edit-area">
145
+ <input id="device-name-input" type="text" placeholder="Device name..." />
146
+ <button id="device-name-save" class="btn-sm">SAVE NAME</button>
147
+ </div>
148
+ </div>
149
+
150
+ <div id="device-sections">
151
+ <div class="device-section">
152
+ <h3>CACHED SOUNDS</h3>
153
+ <div id="detail-sounds" class="tag-list"></div>
154
+ </div>
155
+ <div class="device-section">
156
+ <h3>NOTIFICATIONS</h3>
157
+ <div id="detail-notifications" class="notif-table-wrap"></div>
158
+ </div>
159
+ <div class="device-section">
160
+ <h3>SCHEDULE</h3>
161
+ <div id="detail-schedule" class="schedule-table-wrap"></div>
162
+ </div>
163
+ </div>
164
+ </div>
165
+ </main>
166
+
167
+ </div>
168
+ </div>
169
+
170
+ </div><!-- /#root -->
171
+
172
+ <script src="/admin/script.js"></script>
173
+ </body>
174
+ </html>
public/admin/server.js ADDED
@@ -0,0 +1,378 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const https = require('https');
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const { WebSocketServer, WebSocket } = require('ws');
5
+ const { v4: uuidv4 } = require('uuid');
6
+
7
+ // ─── TLS ──────────────────────────────────────────────────────────────────────
8
+ const serverOptions = {
9
+ cert: fs.readFileSync('cert.pem'),
10
+ key: fs.readFileSync('key.pem'),
11
+ };
12
+
13
+ // ─── In-Memory State ──────────────────────────────────────────────────────────
14
+ // sounds[id] = { id, name, data } (data = base64 string)
15
+ // notifications[id] = { id, name, heading, body, hyperlink, displayed, soundId }
16
+ // devices[uuid] = { uuid, name, notifications: [...], lastConnection, pendingChanges: [...], schedule: [...] }
17
+ const sounds = {};
18
+ const notifications = {};
19
+ const devices = {};
20
+
21
+ // ─── Live Connection Maps ─────────────────────────────────────────────────────
22
+ // deviceClients: uuid -> WebSocket (Frontend 1 connections)
23
+ // adminClients: Set<WebSocket> (Frontend 2 connections)
24
+ const deviceClients = {};
25
+ const adminClients = new Set();
26
+
27
+ // ─── MIME Types ───────────────────────────────────────────────────────────────
28
+ const MIME = {
29
+ '.html': 'text/html',
30
+ '.js': 'application/javascript',
31
+ '.css': 'text/css',
32
+ '.json': 'application/json',
33
+ '.png': 'image/png',
34
+ '.ico': 'image/x-icon',
35
+ };
36
+
37
+ // ─── HTTP File Server ─────────────────────────────────────────────────────────
38
+ const httpServer = https.createServer(serverOptions, (req, res) => {
39
+ let urlPath = req.url.split('?')[0];
40
+ if (urlPath === '/' || urlPath === '') urlPath = '/index.html';
41
+ if (urlPath === '/admin' || urlPath === '/admin/') urlPath = '/admin/index.html';
42
+
43
+ const filePath = path.join(__dirname, 'public', urlPath);
44
+ const ext = path.extname(filePath);
45
+ const mime = MIME[ext] || 'application/octet-stream';
46
+
47
+ fs.readFile(filePath, (err, data) => {
48
+ if (err) {
49
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
50
+ res.end('Not found');
51
+ return;
52
+ }
53
+ res.writeHead(200, { 'Content-Type': mime });
54
+ res.end(data);
55
+ });
56
+ });
57
+
58
+ // ─── WebSocket Server ─────────────────────────────────────────────────────────
59
+ const wss = new WebSocketServer({ server: httpServer });
60
+
61
+ wss.on('connection', (ws, req) => {
62
+ const urlPath = req.url || '/';
63
+ const isAdmin = urlPath.startsWith('/admin-ws');
64
+
65
+ if (isAdmin) {
66
+ handleAdminConnection(ws);
67
+ } else {
68
+ handleDeviceConnection(ws);
69
+ }
70
+ });
71
+
72
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
73
+ function send(ws, msg) {
74
+ if (ws.readyState === WebSocket.OPEN) {
75
+ ws.send(JSON.stringify(msg));
76
+ }
77
+ }
78
+
79
+ function broadcastAdmin(msg) {
80
+ for (const ws of adminClients) {
81
+ send(ws, msg);
82
+ }
83
+ }
84
+
85
+ function broadcastDevice(uuid, msg) {
86
+ const ws = deviceClients[uuid];
87
+ if (ws) send(ws, msg);
88
+ }
89
+
90
+ function broadcastAllDevices(msg) {
91
+ for (const uuid of Object.keys(deviceClients)) {
92
+ broadcastDevice(uuid, msg);
93
+ }
94
+ }
95
+
96
+ function getFullState() {
97
+ return {
98
+ sounds: Object.values(sounds),
99
+ notifications: Object.values(notifications),
100
+ devices: Object.values(devices).map(devicePublic),
101
+ };
102
+ }
103
+
104
+ function devicePublic(d) {
105
+ return {
106
+ uuid: d.uuid,
107
+ name: d.name,
108
+ notifications: d.notifications,
109
+ lastConnection: d.lastConnection,
110
+ schedule: d.schedule || [],
111
+ pendingChanges: d.pendingChanges || [],
112
+ online: !!deviceClients[d.uuid],
113
+ };
114
+ }
115
+
116
+ // ─── Admin Connection Handler ─────────────────────────────────────────────────
117
+ function handleAdminConnection(ws) {
118
+ adminClients.add(ws);
119
+ console.log(`[Admin] connected, total=${adminClients.size}`);
120
+
121
+ // Send full state immediately
122
+ send(ws, { type: 'full_state', ...getFullState() });
123
+
124
+ ws.on('message', (raw) => {
125
+ let msg;
126
+ try { msg = JSON.parse(raw); } catch { return; }
127
+ handleAdminMessage(ws, msg);
128
+ });
129
+
130
+ ws.on('close', () => {
131
+ adminClients.delete(ws);
132
+ console.log(`[Admin] disconnected, total=${adminClients.size}`);
133
+ });
134
+
135
+ ws.on('error', (e) => console.error('[Admin] WS error', e.message));
136
+ }
137
+
138
+ function handleAdminMessage(ws, msg) {
139
+ switch (msg.type) {
140
+
141
+ case 'create_sound': {
142
+ // msg: { name, data }
143
+ const id = uuidv4();
144
+ const sound = { id, name: msg.name, data: msg.data };
145
+ sounds[id] = sound;
146
+ broadcastAdmin({ type: 'sound_added', sound });
147
+ // Don't push sound data to all devices here β€” devices pull sounds on demand
148
+ break;
149
+ }
150
+
151
+ case 'create_notification': {
152
+ // msg: { name, heading, body, hyperlink, soundId }
153
+ const id = uuidv4();
154
+ const notif = {
155
+ id,
156
+ name: msg.name,
157
+ heading: msg.heading,
158
+ body: msg.body,
159
+ hyperlink: msg.hyperlink || '',
160
+ displayed: false,
161
+ soundId: msg.soundId || null,
162
+ };
163
+ notifications[id] = notif;
164
+ // Add to all devices
165
+ for (const uuid of Object.keys(devices)) {
166
+ if (!devices[uuid].notifications.find(n => n.id === id)) {
167
+ devices[uuid].notifications.push({ ...notif });
168
+ }
169
+ }
170
+ broadcastAdmin({ type: 'notification_added', notification: notif });
171
+ broadcastAdmin({ type: 'devices_updated', devices: Object.values(devices).map(devicePublic) });
172
+ // Push to all connected devices
173
+ broadcastAllDevices({ type: 'notification_added', notification: notif });
174
+ break;
175
+ }
176
+
177
+ case 'schedule_notification': {
178
+ // msg: { uuid, notificationId, scheduledAt }
179
+ const device = devices[msg.uuid];
180
+ if (!device) return;
181
+ device.schedule = device.schedule || [];
182
+ const existing = device.schedule.find(s => s.notificationId === msg.notificationId);
183
+ if (existing) {
184
+ existing.scheduledAt = msg.scheduledAt;
185
+ } else {
186
+ device.schedule.push({ notificationId: msg.notificationId, scheduledAt: msg.scheduledAt });
187
+ }
188
+ const scheduleMsg = { type: 'schedule_update', schedule: device.schedule };
189
+ if (deviceClients[msg.uuid]) {
190
+ broadcastDevice(msg.uuid, scheduleMsg);
191
+ } else {
192
+ device.pendingChanges = device.pendingChanges || [];
193
+ device.pendingChanges.push(scheduleMsg);
194
+ }
195
+ broadcastAdmin({ type: 'devices_updated', devices: Object.values(devices).map(devicePublic) });
196
+ break;
197
+ }
198
+
199
+ case 'play_now': {
200
+ // msg: { uuid, notificationId }
201
+ const playMsg = { type: 'play_now', notificationId: msg.notificationId };
202
+ if (deviceClients[msg.uuid]) {
203
+ broadcastDevice(msg.uuid, playMsg);
204
+ } else {
205
+ const device = devices[msg.uuid];
206
+ if (device) {
207
+ device.pendingChanges = device.pendingChanges || [];
208
+ device.pendingChanges.push(playMsg);
209
+ }
210
+ }
211
+ break;
212
+ }
213
+
214
+ case 'update_device_name': {
215
+ // msg: { uuid, name }
216
+ const device = devices[msg.uuid];
217
+ if (!device) return;
218
+ device.name = msg.name;
219
+ const nameMsg = { type: 'name_update', name: msg.name };
220
+ if (deviceClients[msg.uuid]) {
221
+ broadcastDevice(msg.uuid, nameMsg);
222
+ } else {
223
+ device.pendingChanges = device.pendingChanges || [];
224
+ device.pendingChanges.push(nameMsg);
225
+ }
226
+ broadcastAdmin({ type: 'devices_updated', devices: Object.values(devices).map(devicePublic) });
227
+ break;
228
+ }
229
+
230
+ case 'remove_schedule': {
231
+ // msg: { uuid, notificationId }
232
+ const device = devices[msg.uuid];
233
+ if (!device) return;
234
+ device.schedule = (device.schedule || []).filter(s => s.notificationId !== msg.notificationId);
235
+ const rmMsg = { type: 'schedule_update', schedule: device.schedule };
236
+ if (deviceClients[msg.uuid]) {
237
+ broadcastDevice(msg.uuid, rmMsg);
238
+ } else {
239
+ device.pendingChanges = device.pendingChanges || [];
240
+ device.pendingChanges.push(rmMsg);
241
+ }
242
+ broadcastAdmin({ type: 'devices_updated', devices: Object.values(devices).map(devicePublic) });
243
+ break;
244
+ }
245
+
246
+ case 'request_sound': {
247
+ // admin wants a specific sound by id
248
+ const sound = sounds[msg.id];
249
+ if (sound) send(ws, { type: 'sound_data', sound });
250
+ break;
251
+ }
252
+
253
+ default:
254
+ console.warn('[Admin] unknown message type:', msg.type);
255
+ }
256
+ }
257
+
258
+ // ─── Device Connection Handler ────────────────────────────────────────────────
259
+ function handleDeviceConnection(ws) {
260
+ let deviceUUID = null;
261
+ console.log('[Device] new connection (uuid pending)');
262
+
263
+ ws.on('message', (raw) => {
264
+ let msg;
265
+ try { msg = JSON.parse(raw); } catch { return; }
266
+
267
+ if (msg.type === 'hello') {
268
+ // Device introduces itself with optional uuid
269
+ if (msg.uuid && devices[msg.uuid]) {
270
+ deviceUUID = msg.uuid;
271
+ } else {
272
+ // New device
273
+ deviceUUID = msg.uuid || uuidv4();
274
+ devices[deviceUUID] = {
275
+ uuid: deviceUUID,
276
+ name: `Device ${Object.keys(devices).length + 1}`,
277
+ notifications: Object.values(notifications).map(n => ({ ...n })),
278
+ lastConnection: null,
279
+ schedule: [],
280
+ pendingChanges: [],
281
+ };
282
+ }
283
+
284
+ deviceClients[deviceUUID] = ws;
285
+ const device = devices[deviceUUID];
286
+ device.lastConnection = null; // online
287
+
288
+ // Deliver any pending changes
289
+ if (device.pendingChanges && device.pendingChanges.length > 0) {
290
+ for (const change of device.pendingChanges) {
291
+ send(ws, change);
292
+ }
293
+ device.pendingChanges = [];
294
+ }
295
+
296
+ // Send device its full state
297
+ send(ws, {
298
+ type: 'device_init',
299
+ uuid: deviceUUID,
300
+ notifications: device.notifications,
301
+ schedule: device.schedule,
302
+ name: device.name,
303
+ });
304
+
305
+ // Tell admin about new/reconnected device
306
+ broadcastAdmin({ type: 'devices_updated', devices: Object.values(devices).map(devicePublic) });
307
+ console.log(`[Device] ${deviceUUID} connected (${device.name})`);
308
+ return;
309
+ }
310
+
311
+ if (!deviceUUID) return; // not initialized
312
+
313
+ handleDeviceMessage(ws, deviceUUID, msg);
314
+ });
315
+
316
+ ws.on('close', () => {
317
+ if (deviceUUID && devices[deviceUUID]) {
318
+ devices[deviceUUID].lastConnection = Date.now();
319
+ delete deviceClients[deviceUUID];
320
+ broadcastAdmin({ type: 'devices_updated', devices: Object.values(devices).map(devicePublic) });
321
+ console.log(`[Device] ${deviceUUID} disconnected`);
322
+ }
323
+ });
324
+
325
+ ws.on('error', (e) => console.error('[Device] WS error', e.message));
326
+ }
327
+
328
+ function handleDeviceMessage(ws, uuid, msg) {
329
+ const device = devices[uuid];
330
+ if (!device) return;
331
+
332
+ switch (msg.type) {
333
+
334
+ case 'mark_displayed': {
335
+ // msg: { notificationId }
336
+ const notif = device.notifications.find(n => n.id === msg.notificationId);
337
+ if (notif) notif.displayed = true;
338
+ // Also mark in master list
339
+ if (notifications[msg.notificationId]) {
340
+ notifications[msg.notificationId].displayed = true;
341
+ }
342
+ broadcastAdmin({ type: 'devices_updated', devices: Object.values(devices).map(devicePublic) });
343
+ break;
344
+ }
345
+
346
+ case 'request_sound': {
347
+ // Device needs sound data for a notification
348
+ const sound = sounds[msg.soundId];
349
+ if (sound) {
350
+ send(ws, { type: 'sound_data', sound });
351
+ }
352
+ break;
353
+ }
354
+
355
+ case 'sync_schedule': {
356
+ // Device reports its current schedule (for reconciliation)
357
+ device.schedule = msg.schedule || [];
358
+ broadcastAdmin({ type: 'devices_updated', devices: Object.values(devices).map(devicePublic) });
359
+ break;
360
+ }
361
+
362
+ case 'cached_sounds': {
363
+ // Device reports which sound IDs it has cached
364
+ device.cachedSounds = msg.soundIds || [];
365
+ broadcastAdmin({ type: 'devices_updated', devices: Object.values(devices).map(devicePublic) });
366
+ break;
367
+ }
368
+
369
+ default:
370
+ console.warn('[Device] unknown message type:', msg.type);
371
+ }
372
+ }
373
+
374
+ // ─── Start ────────────────────────────────────────────────────────────────────
375
+ const PORT = process.env.PORT || 7860;
376
+ httpServer.listen(PORT, () => {
377
+ console.log(`Server running on https://0.0.0.0:${PORT}`);
378
+ });
public/admin/style.css ADDED
@@ -0,0 +1,693 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ─── DISPATCH Admin UI ─────────────────────────────────────────────────────── */
2
+ @import url('https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&family=Syne:wght@400;600;800&display=swap');
3
+
4
+ :root {
5
+ --bg: #0d0d0f;
6
+ --surface: #141416;
7
+ --surface2: #1c1c20;
8
+ --surface3: #242428;
9
+ --border: #2a2a30;
10
+ --border2: #3a3a42;
11
+ --text: #e8e8ec;
12
+ --text-dim: #8888a0;
13
+ --text-dimmer: #55556a;
14
+ --accent: #f5a623;
15
+ --accent-dim: #a87010;
16
+ --red: #e03030;
17
+ --red-dark: #8a1010;
18
+ --green: #22c55e;
19
+ --mono: 'Space Mono', monospace;
20
+ --sans: 'Syne', sans-serif;
21
+ --radius: 4px;
22
+ --radius-lg: 8px;
23
+ }
24
+
25
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
26
+
27
+ html, body, #root {
28
+ height: 100%;
29
+ background: var(--bg);
30
+ color: var(--text);
31
+ font-family: var(--mono);
32
+ font-size: 13px;
33
+ line-height: 1.5;
34
+ }
35
+
36
+ /* ─── Top Bar ───────────────────────────────────────────────────────────────── */
37
+ #topbar {
38
+ display: flex;
39
+ align-items: center;
40
+ justify-content: space-between;
41
+ padding: 0 24px;
42
+ height: 52px;
43
+ background: var(--surface);
44
+ border-bottom: 1px solid var(--border);
45
+ position: sticky;
46
+ top: 0;
47
+ z-index: 100;
48
+ }
49
+
50
+ #topbar-logo {
51
+ font-family: var(--sans);
52
+ font-weight: 800;
53
+ font-size: 20px;
54
+ letter-spacing: 0.15em;
55
+ color: var(--accent);
56
+ }
57
+
58
+ #topbar-status {
59
+ display: flex;
60
+ align-items: center;
61
+ gap: 8px;
62
+ font-size: 11px;
63
+ letter-spacing: 0.12em;
64
+ color: var(--text-dim);
65
+ }
66
+
67
+ /* ─── Status Indicator ──────────────────────────────────────────────────────── */
68
+ .indicator {
69
+ width: 8px;
70
+ height: 8px;
71
+ border-radius: 50%;
72
+ background: #555;
73
+ display: inline-block;
74
+ flex-shrink: 0;
75
+ transition: background 0.3s;
76
+ }
77
+ .indicator.online { background: var(--green); box-shadow: 0 0 6px var(--green); }
78
+ .indicator.offline { background: var(--red); box-shadow: 0 0 6px var(--red); }
79
+
80
+ /* ─── Tabs ──────────────────────────────────────────────────────────────────── */
81
+ #tabs {
82
+ display: flex;
83
+ border-bottom: 1px solid var(--border);
84
+ background: var(--surface);
85
+ }
86
+
87
+ .tab-btn {
88
+ padding: 12px 28px;
89
+ background: none;
90
+ border: none;
91
+ border-bottom: 2px solid transparent;
92
+ color: var(--text-dim);
93
+ font-family: var(--mono);
94
+ font-size: 11px;
95
+ font-weight: 700;
96
+ letter-spacing: 0.14em;
97
+ cursor: pointer;
98
+ transition: color 0.2s, border-color 0.2s;
99
+ }
100
+ .tab-btn:hover { color: var(--text); }
101
+ .tab-btn.active {
102
+ color: var(--accent);
103
+ border-bottom-color: var(--accent);
104
+ }
105
+
106
+ /* ─── Tab Panels ────────────────────────────────────────────────────────────── */
107
+ .tab-panel { display: none; height: calc(100vh - 52px - 43px); overflow: hidden; }
108
+ .tab-panel.active { display: flex; flex-direction: column; }
109
+
110
+ /* ─── Notifications Tab Layout ──────────────────────────────────────────────── */
111
+ #notif-layout {
112
+ display: grid;
113
+ grid-template-columns: 300px 1fr 340px;
114
+ height: 100%;
115
+ overflow: hidden;
116
+ }
117
+
118
+ /* ─── Panel Titles ──────────────────────────────────────────────────────────── */
119
+ .panel-title {
120
+ font-family: var(--sans);
121
+ font-weight: 800;
122
+ font-size: 12px;
123
+ letter-spacing: 0.18em;
124
+ color: var(--text-dim);
125
+ padding: 16px 20px 12px;
126
+ border-bottom: 1px solid var(--border);
127
+ display: flex;
128
+ align-items: center;
129
+ gap: 8px;
130
+ }
131
+
132
+ .badge {
133
+ background: var(--surface3);
134
+ color: var(--accent);
135
+ padding: 1px 7px;
136
+ border-radius: 99px;
137
+ font-size: 10px;
138
+ font-weight: 700;
139
+ }
140
+
141
+ /* ─── Left Panel: Create Form ───────────────────────────────────────────────── */
142
+ #create-panel {
143
+ border-right: 1px solid var(--border);
144
+ display: flex;
145
+ flex-direction: column;
146
+ overflow-y: auto;
147
+ background: var(--surface);
148
+ }
149
+
150
+ .form-group {
151
+ padding: 10px 20px;
152
+ border-bottom: 1px solid var(--border);
153
+ }
154
+
155
+ .form-group label {
156
+ display: block;
157
+ font-size: 10px;
158
+ letter-spacing: 0.14em;
159
+ color: var(--text-dim);
160
+ margin-bottom: 6px;
161
+ font-weight: 700;
162
+ }
163
+
164
+ .form-group input,
165
+ .form-group textarea,
166
+ .form-group select {
167
+ width: 100%;
168
+ background: var(--surface2);
169
+ border: 1px solid var(--border);
170
+ border-radius: var(--radius);
171
+ color: var(--text);
172
+ font-family: var(--mono);
173
+ font-size: 12px;
174
+ padding: 8px 10px;
175
+ outline: none;
176
+ transition: border-color 0.2s;
177
+ resize: none;
178
+ }
179
+
180
+ .form-group input:focus,
181
+ .form-group textarea:focus,
182
+ .form-group select:focus {
183
+ border-color: var(--accent);
184
+ }
185
+
186
+ .form-group select option { background: var(--surface2); }
187
+
188
+ #sound-upload-area {
189
+ display: flex;
190
+ align-items: center;
191
+ gap: 10px;
192
+ }
193
+
194
+ .btn-outline {
195
+ background: none;
196
+ border: 1px solid var(--border2);
197
+ color: var(--text-dim);
198
+ font-family: var(--mono);
199
+ font-size: 10px;
200
+ letter-spacing: 0.1em;
201
+ padding: 7px 12px;
202
+ border-radius: var(--radius);
203
+ cursor: pointer;
204
+ transition: border-color 0.2s, color 0.2s;
205
+ white-space: nowrap;
206
+ }
207
+ .btn-outline:hover { border-color: var(--accent); color: var(--accent); }
208
+
209
+ #sound-file-name {
210
+ font-size: 11px;
211
+ color: var(--text-dimmer);
212
+ overflow: hidden;
213
+ text-overflow: ellipsis;
214
+ white-space: nowrap;
215
+ }
216
+
217
+ .btn-create {
218
+ margin: 16px 20px;
219
+ padding: 14px;
220
+ background: #1a4a1a;
221
+ border: 1px solid #2d7a2d;
222
+ border-radius: var(--radius-lg);
223
+ color: var(--green);
224
+ font-family: var(--mono);
225
+ font-size: 13px;
226
+ font-weight: 700;
227
+ letter-spacing: 0.12em;
228
+ cursor: pointer;
229
+ transition: background 0.2s, box-shadow 0.2s;
230
+ }
231
+ .btn-create:hover {
232
+ background: #1f6a1f;
233
+ box-shadow: 0 0 20px rgba(34, 197, 94, 0.2);
234
+ }
235
+ .btn-create:active { transform: scale(0.98); }
236
+
237
+ /* ─── Middle Panel: Notification List ──────────────────────────────────────── */
238
+ #notif-list-panel {
239
+ border-right: 1px solid var(--border);
240
+ display: flex;
241
+ flex-direction: column;
242
+ overflow: hidden;
243
+ }
244
+
245
+ #notif-list {
246
+ flex: 1;
247
+ overflow-y: auto;
248
+ }
249
+
250
+ .notif-item {
251
+ padding: 14px 20px;
252
+ border-bottom: 1px solid var(--border);
253
+ cursor: pointer;
254
+ transition: background 0.15s;
255
+ position: relative;
256
+ }
257
+ .notif-item:hover { background: var(--surface2); }
258
+ .notif-item.selected { background: var(--surface2); border-left: 3px solid var(--accent); }
259
+
260
+ .notif-item-name {
261
+ font-size: 11px;
262
+ color: var(--text-dimmer);
263
+ letter-spacing: 0.1em;
264
+ margin-bottom: 3px;
265
+ }
266
+ .notif-item-heading {
267
+ font-family: var(--sans);
268
+ font-weight: 600;
269
+ font-size: 14px;
270
+ color: var(--text);
271
+ margin-bottom: 3px;
272
+ }
273
+ .notif-item-body {
274
+ font-size: 11px;
275
+ color: var(--text-dim);
276
+ white-space: nowrap;
277
+ overflow: hidden;
278
+ text-overflow: ellipsis;
279
+ }
280
+ .notif-item-sound {
281
+ display: inline-flex;
282
+ align-items: center;
283
+ gap: 4px;
284
+ margin-top: 6px;
285
+ font-size: 10px;
286
+ color: var(--accent-dim);
287
+ background: rgba(245,166,35,0.08);
288
+ padding: 2px 7px;
289
+ border-radius: 3px;
290
+ }
291
+
292
+ .empty-state {
293
+ padding: 40px 20px;
294
+ text-align: center;
295
+ color: var(--text-dimmer);
296
+ font-size: 12px;
297
+ letter-spacing: 0.08em;
298
+ }
299
+
300
+ /* ─── Right Panel: Schedule ─────────────────────────────────────────────────── */
301
+ #schedule-panel {
302
+ display: flex;
303
+ flex-direction: column;
304
+ background: var(--surface);
305
+ overflow-y: auto;
306
+ }
307
+
308
+ .select-hint {
309
+ display: flex;
310
+ flex-direction: column;
311
+ align-items: center;
312
+ justify-content: center;
313
+ height: 100%;
314
+ gap: 12px;
315
+ color: var(--text-dimmer);
316
+ text-align: center;
317
+ line-height: 1.7;
318
+ font-size: 12px;
319
+ letter-spacing: 0.06em;
320
+ }
321
+
322
+ .select-hint-icon {
323
+ font-size: 36px;
324
+ color: var(--border2);
325
+ }
326
+
327
+ #schedule-active { padding: 0; display: flex; flex-direction: column; }
328
+
329
+ #selected-notif-info {
330
+ padding: 16px 20px 12px;
331
+ border-bottom: 1px solid var(--border);
332
+ }
333
+
334
+ #selected-notif-name {
335
+ font-family: var(--sans);
336
+ font-size: 16px;
337
+ font-weight: 600;
338
+ color: var(--text);
339
+ }
340
+
341
+ /* ── Big Red Button ── */
342
+ #play-now-btn {
343
+ margin: 28px 24px 20px;
344
+ width: calc(100% - 48px);
345
+ padding: 26px;
346
+ background: radial-gradient(circle at 40% 35%, #e84040, #8a0000);
347
+ border: 2px solid #c02020;
348
+ border-radius: 12px;
349
+ color: #fff;
350
+ font-family: var(--sans);
351
+ font-size: 22px;
352
+ font-weight: 800;
353
+ letter-spacing: 0.12em;
354
+ cursor: pointer;
355
+ box-shadow:
356
+ 0 0 0 4px rgba(200,0,0,0.15),
357
+ 0 8px 32px rgba(200,0,0,0.35),
358
+ inset 0 2px 0 rgba(255,255,255,0.12),
359
+ inset 0 -3px 0 rgba(0,0,0,0.3);
360
+ position: relative;
361
+ transition: box-shadow 0.1s, transform 0.1s;
362
+ text-shadow: 0 1px 3px rgba(0,0,0,0.5);
363
+ }
364
+ #play-now-btn:hover {
365
+ box-shadow:
366
+ 0 0 0 6px rgba(200,0,0,0.2),
367
+ 0 10px 40px rgba(200,0,0,0.5),
368
+ inset 0 2px 0 rgba(255,255,255,0.15),
369
+ inset 0 -3px 0 rgba(0,0,0,0.35);
370
+ transform: translateY(-1px);
371
+ }
372
+ #play-now-btn:active {
373
+ transform: translateY(2px);
374
+ box-shadow:
375
+ 0 0 0 4px rgba(200,0,0,0.12),
376
+ 0 4px 16px rgba(200,0,0,0.3),
377
+ inset 0 3px 6px rgba(0,0,0,0.4);
378
+ }
379
+
380
+ /* ── Schedule Controls ── */
381
+ #schedule-controls {
382
+ padding: 0 20px 20px;
383
+ }
384
+
385
+ .toggle-row {
386
+ display: flex;
387
+ align-items: center;
388
+ gap: 12px;
389
+ padding: 14px 0 12px;
390
+ border-top: 1px solid var(--border);
391
+ }
392
+
393
+ .toggle-label {
394
+ font-size: 11px;
395
+ letter-spacing: 0.14em;
396
+ color: var(--text-dim);
397
+ font-weight: 700;
398
+ }
399
+
400
+ /* Toggle Switch */
401
+ .toggle-switch { position: relative; display: inline-block; cursor: pointer; }
402
+ .toggle-switch input { display: none; }
403
+ .toggle-track {
404
+ display: block;
405
+ width: 42px;
406
+ height: 22px;
407
+ background: var(--surface3);
408
+ border: 1px solid var(--border2);
409
+ border-radius: 11px;
410
+ position: relative;
411
+ transition: background 0.2s, border-color 0.2s;
412
+ }
413
+ .toggle-thumb {
414
+ position: absolute;
415
+ top: 3px;
416
+ left: 3px;
417
+ width: 14px;
418
+ height: 14px;
419
+ background: var(--text-dimmer);
420
+ border-radius: 50%;
421
+ transition: transform 0.25s, background 0.2s;
422
+ }
423
+ .toggle-switch input:checked ~ .toggle-track { background: rgba(245,166,35,0.25); border-color: var(--accent); }
424
+ .toggle-switch input:checked ~ .toggle-track .toggle-thumb { transform: translateX(20px); background: var(--accent); }
425
+
426
+ #datetime-pickers {
427
+ display: grid;
428
+ grid-template-columns: 1fr 1fr;
429
+ gap: 10px;
430
+ margin: 6px 0 14px;
431
+ }
432
+ .picker-group label {
433
+ display: block;
434
+ font-size: 10px;
435
+ letter-spacing: 0.14em;
436
+ color: var(--text-dim);
437
+ margin-bottom: 5px;
438
+ font-weight: 700;
439
+ }
440
+ .picker-group input[type="date"],
441
+ .picker-group input[type="time"] {
442
+ width: 100%;
443
+ background: var(--surface2);
444
+ border: 1px solid var(--border);
445
+ border-radius: var(--radius);
446
+ color: var(--text);
447
+ font-family: var(--mono);
448
+ font-size: 12px;
449
+ padding: 8px 8px;
450
+ outline: none;
451
+ transition: border-color 0.2s;
452
+ color-scheme: dark;
453
+ }
454
+ .picker-group input:focus { border-color: var(--accent); }
455
+
456
+ .btn-schedule {
457
+ width: 100%;
458
+ padding: 12px;
459
+ background: rgba(245,166,35,0.12);
460
+ border: 1px solid var(--accent-dim);
461
+ border-radius: var(--radius-lg);
462
+ color: var(--accent);
463
+ font-family: var(--mono);
464
+ font-size: 12px;
465
+ font-weight: 700;
466
+ letter-spacing: 0.12em;
467
+ cursor: pointer;
468
+ transition: background 0.2s, box-shadow 0.2s;
469
+ }
470
+ .btn-schedule:hover {
471
+ background: rgba(245,166,35,0.2);
472
+ box-shadow: 0 0 16px rgba(245,166,35,0.15);
473
+ }
474
+
475
+ /* ─── Devices Tab Layout ─────────────────────────────────────────────────────── */
476
+ #devices-layout {
477
+ display: grid;
478
+ grid-template-columns: 280px 1fr;
479
+ height: 100%;
480
+ overflow: hidden;
481
+ }
482
+
483
+ #device-list-panel {
484
+ border-right: 1px solid var(--border);
485
+ display: flex;
486
+ flex-direction: column;
487
+ overflow: hidden;
488
+ background: var(--surface);
489
+ }
490
+
491
+ #device-list {
492
+ flex: 1;
493
+ overflow-y: auto;
494
+ }
495
+
496
+ .device-item {
497
+ display: flex;
498
+ align-items: center;
499
+ gap: 10px;
500
+ padding: 14px 20px;
501
+ border-bottom: 1px solid var(--border);
502
+ cursor: pointer;
503
+ transition: background 0.15s;
504
+ }
505
+ .device-item:hover { background: var(--surface2); }
506
+ .device-item.selected { background: var(--surface2); border-left: 3px solid var(--accent); }
507
+
508
+ .device-item-info { flex: 1; min-width: 0; }
509
+ .device-item-name {
510
+ font-family: var(--sans);
511
+ font-size: 14px;
512
+ font-weight: 600;
513
+ white-space: nowrap;
514
+ overflow: hidden;
515
+ text-overflow: ellipsis;
516
+ }
517
+ .device-item-uuid {
518
+ font-size: 10px;
519
+ color: var(--text-dimmer);
520
+ letter-spacing: 0.06em;
521
+ white-space: nowrap;
522
+ overflow: hidden;
523
+ text-overflow: ellipsis;
524
+ }
525
+ .device-item-last {
526
+ font-size: 10px;
527
+ color: var(--text-dimmer);
528
+ margin-top: 2px;
529
+ }
530
+
531
+ /* ─── Device Detail ──────────────────────────────────────────────────────────── */
532
+ #device-detail-panel {
533
+ overflow-y: auto;
534
+ display: flex;
535
+ flex-direction: column;
536
+ }
537
+
538
+ #device-empty-state { flex: 1; }
539
+
540
+ #device-detail { padding: 0; }
541
+
542
+ #device-header {
543
+ display: flex;
544
+ align-items: center;
545
+ gap: 14px;
546
+ padding: 20px 24px;
547
+ border-bottom: 1px solid var(--border);
548
+ background: var(--surface);
549
+ position: sticky;
550
+ top: 0;
551
+ z-index: 10;
552
+ flex-wrap: wrap;
553
+ }
554
+
555
+ .device-detail-name {
556
+ font-family: var(--sans);
557
+ font-weight: 800;
558
+ font-size: 20px;
559
+ }
560
+
561
+ .device-detail-uuid {
562
+ font-size: 10px;
563
+ color: var(--text-dimmer);
564
+ letter-spacing: 0.08em;
565
+ margin-top: 2px;
566
+ }
567
+
568
+ #device-name-edit-area {
569
+ display: flex;
570
+ align-items: center;
571
+ gap: 8px;
572
+ margin-left: auto;
573
+ }
574
+ #device-name-input {
575
+ background: var(--surface2);
576
+ border: 1px solid var(--border);
577
+ border-radius: var(--radius);
578
+ color: var(--text);
579
+ font-family: var(--mono);
580
+ font-size: 12px;
581
+ padding: 6px 10px;
582
+ outline: none;
583
+ width: 180px;
584
+ transition: border-color 0.2s;
585
+ }
586
+ #device-name-input:focus { border-color: var(--accent); }
587
+
588
+ .btn-sm {
589
+ background: none;
590
+ border: 1px solid var(--border2);
591
+ border-radius: var(--radius);
592
+ color: var(--text-dim);
593
+ font-family: var(--mono);
594
+ font-size: 10px;
595
+ letter-spacing: 0.1em;
596
+ padding: 6px 12px;
597
+ cursor: pointer;
598
+ transition: border-color 0.2s, color 0.2s;
599
+ }
600
+ .btn-sm:hover { border-color: var(--accent); color: var(--accent); }
601
+
602
+ #device-sections { padding: 16px 24px; display: flex; flex-direction: column; gap: 24px; }
603
+
604
+ .device-section h3 {
605
+ font-size: 10px;
606
+ letter-spacing: 0.18em;
607
+ color: var(--text-dimmer);
608
+ font-weight: 700;
609
+ margin-bottom: 10px;
610
+ padding-bottom: 8px;
611
+ border-bottom: 1px solid var(--border);
612
+ }
613
+
614
+ /* Tag List (cached sounds) */
615
+ .tag-list { display: flex; flex-wrap: wrap; gap: 6px; }
616
+ .tag {
617
+ background: var(--surface3);
618
+ border: 1px solid var(--border2);
619
+ color: var(--text-dim);
620
+ font-size: 11px;
621
+ padding: 3px 10px;
622
+ border-radius: 99px;
623
+ display: flex;
624
+ align-items: center;
625
+ gap: 5px;
626
+ }
627
+ .tag-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--accent); }
628
+
629
+ /* Tables */
630
+ .data-table {
631
+ width: 100%;
632
+ border-collapse: collapse;
633
+ font-size: 12px;
634
+ }
635
+ .data-table th {
636
+ text-align: left;
637
+ font-size: 10px;
638
+ letter-spacing: 0.12em;
639
+ color: var(--text-dimmer);
640
+ font-weight: 700;
641
+ padding: 6px 10px;
642
+ border-bottom: 1px solid var(--border);
643
+ }
644
+ .data-table td {
645
+ padding: 9px 10px;
646
+ border-bottom: 1px solid var(--border);
647
+ color: var(--text-dim);
648
+ vertical-align: top;
649
+ }
650
+ .data-table tr:last-child td { border-bottom: none; }
651
+ .data-table td:first-child { color: var(--text); }
652
+
653
+ .action-btn {
654
+ background: none;
655
+ border: 1px solid var(--border2);
656
+ color: var(--text-dimmer);
657
+ font-family: var(--mono);
658
+ font-size: 10px;
659
+ padding: 3px 8px;
660
+ border-radius: var(--radius);
661
+ cursor: pointer;
662
+ transition: border-color 0.2s, color 0.2s;
663
+ }
664
+ .action-btn:hover { border-color: var(--red); color: var(--red); }
665
+ .action-btn.edit:hover { border-color: var(--accent); color: var(--accent); }
666
+
667
+ /* ─── Scrollbar ──────────────────────────────────────────────────────────────── */
668
+ ::-webkit-scrollbar { width: 6px; height: 6px; }
669
+ ::-webkit-scrollbar-track { background: transparent; }
670
+ ::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 3px; }
671
+ ::-webkit-scrollbar-thumb:hover { background: var(--text-dimmer); }
672
+
673
+ /* ─── Toast ──────────────────────────────────────────────────────────────────── */
674
+ #toast {
675
+ position: fixed;
676
+ bottom: 24px;
677
+ left: 50%;
678
+ transform: translateX(-50%) translateY(80px);
679
+ background: var(--surface3);
680
+ border: 1px solid var(--border2);
681
+ color: var(--text);
682
+ font-family: var(--mono);
683
+ font-size: 12px;
684
+ padding: 10px 20px;
685
+ border-radius: var(--radius-lg);
686
+ box-shadow: 0 4px 20px rgba(0,0,0,0.5);
687
+ transition: transform 0.3s;
688
+ z-index: 9999;
689
+ letter-spacing: 0.06em;
690
+ }
691
+ #toast.show { transform: translateX(-50%) translateY(0); }
692
+ #toast.success { border-color: var(--green); color: var(--green); }
693
+ #toast.error { border-color: var(--red); color: var(--red); }
public/index.html ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <style>
7
+ * { margin: 0; padding: 0; box-sizing: border-box; }
8
+ html, body { background: #fff; width: 100%; height: 100%; }
9
+ </style>
10
+ </head>
11
+ <body>
12
+ <script src="/script.js"></script>
13
+ </body>
14
+ </html>
public/script.js ADDED
@@ -0,0 +1,307 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // ─── Frontend 1: Device Client ────────────────────────────────────────────────
2
+ // Responsibilities:
3
+ // - Negotiate UUID with server
4
+ // - Keep notifications + schedule in localStorage
5
+ // - Cache sounds in localStorage
6
+ // - Request notification permission and show notifications
7
+ // - Schedule notifications to fire at the right time
8
+
9
+ const LS = {
10
+ get: (k) => { try { return JSON.parse(localStorage.getItem(k)); } catch { return null; } },
11
+ set: (k, v) => { try { localStorage.setItem(k, JSON.stringify(v)); } catch {} },
12
+ };
13
+
14
+ const STORAGE_UUID = 'device_uuid';
15
+ const STORAGE_NOTIFICATIONS = 'device_notifications';
16
+ const STORAGE_SCHEDULE = 'device_schedule';
17
+ const STORAGE_SOUNDS = 'device_sounds'; // { [soundId]: base64 }
18
+ const STORAGE_NAME = 'device_name';
19
+
20
+ let ws = null;
21
+ let reconnectTimer = null;
22
+ let scheduledTimers = {}; // notifId -> timeoutId
23
+ let cachedSounds = LS.get(STORAGE_SOUNDS) || {};
24
+
25
+ // ─── Notification Permission ──────────────────────────────────────────────────
26
+ if ('Notification' in window && Notification.permission === 'default') {
27
+ Notification.requestPermission();
28
+ }
29
+
30
+ // ─── WebSocket Connection ─────────────────────────────────────────────────────
31
+ function connect() {
32
+ const proto = location.protocol === 'https:' ? 'wss' : 'wss';
33
+ ws = new WebSocket(`${proto}://${location.host}/device-ws`);
34
+
35
+ ws.addEventListener('open', () => {
36
+ console.log('[WS] connected');
37
+ clearTimeout(reconnectTimer);
38
+ const uuid = LS.get(STORAGE_UUID);
39
+ // Introduce ourselves
40
+ send({ type: 'hello', uuid });
41
+ // Report cached sounds
42
+ send({ type: 'cached_sounds', soundIds: Object.keys(cachedSounds) });
43
+ });
44
+
45
+ ws.addEventListener('message', (e) => {
46
+ let msg;
47
+ try { msg = JSON.parse(e.data); } catch { return; }
48
+ handleMessage(msg);
49
+ });
50
+
51
+ ws.addEventListener('close', () => {
52
+ console.log('[WS] closed, reconnecting in 3s...');
53
+ reconnectTimer = setTimeout(connect, 3000);
54
+ });
55
+
56
+ ws.addEventListener('error', () => {
57
+ ws.close();
58
+ });
59
+ }
60
+
61
+ function send(msg) {
62
+ if (ws && ws.readyState === WebSocket.OPEN) {
63
+ ws.send(JSON.stringify(msg));
64
+ }
65
+ }
66
+
67
+ // ─── Message Handler ──────────────────────────────────────────────────────────
68
+ function handleMessage(msg) {
69
+ switch (msg.type) {
70
+
71
+ case 'device_init': {
72
+ // Server assigns/confirms UUID and sends all notifications + schedule
73
+ LS.set(STORAGE_UUID, msg.uuid);
74
+ LS.set(STORAGE_NAME, msg.name);
75
+ mergeNotifications(msg.notifications || []);
76
+ LS.set(STORAGE_SCHEDULE, msg.schedule || []);
77
+ ensureSounds();
78
+ rescheduleAll();
79
+ break;
80
+ }
81
+
82
+ case 'notification_added': {
83
+ const notifs = LS.get(STORAGE_NOTIFICATIONS) || [];
84
+ if (!notifs.find(n => n.id === msg.notification.id)) {
85
+ notifs.push(msg.notification);
86
+ LS.set(STORAGE_NOTIFICATIONS, notifs);
87
+ ensureSoundForNotif(msg.notification);
88
+ }
89
+ break;
90
+ }
91
+
92
+ case 'schedule_update': {
93
+ LS.set(STORAGE_SCHEDULE, msg.schedule || []);
94
+ rescheduleAll();
95
+ break;
96
+ }
97
+
98
+ case 'play_now': {
99
+ const notifs = LS.get(STORAGE_NOTIFICATIONS) || [];
100
+ const notif = notifs.find(n => n.id === msg.notificationId);
101
+ if (notif) fireNotification(notif);
102
+ break;
103
+ }
104
+
105
+ case 'name_update': {
106
+ LS.set(STORAGE_NAME, msg.name);
107
+ break;
108
+ }
109
+
110
+ case 'sound_data': {
111
+ // Server sent us a sound we requested
112
+ const sound = msg.sound;
113
+ cachedSounds[sound.id] = { data: sound.data, name: sound.name };
114
+ LS.set(STORAGE_SOUNDS, cachedSounds);
115
+ send({ type: 'cached_sounds', soundIds: Object.keys(cachedSounds) });
116
+ break;
117
+ }
118
+
119
+ default:
120
+ break;
121
+ }
122
+ }
123
+
124
+ // ─── Merge Notifications ──────────────────────────────────────────────────────
125
+ function mergeNotifications(incoming) {
126
+ const local = LS.get(STORAGE_NOTIFICATIONS) || [];
127
+ const localMap = {};
128
+ for (const n of local) localMap[n.id] = n;
129
+
130
+ for (const n of incoming) {
131
+ if (!localMap[n.id]) {
132
+ localMap[n.id] = n;
133
+ } else {
134
+ // preserve local displayed status if already true
135
+ if (localMap[n.id].displayed) n.displayed = true;
136
+ localMap[n.id] = { ...localMap[n.id], ...n };
137
+ }
138
+ }
139
+ LS.set(STORAGE_NOTIFICATIONS, Object.values(localMap));
140
+ }
141
+
142
+ // ─── Sound Fetching ────────────────────────────���──────────────────────────────
143
+ function ensureSounds() {
144
+ const notifs = LS.get(STORAGE_NOTIFICATIONS) || [];
145
+ for (const n of notifs) ensureSoundForNotif(n);
146
+ }
147
+
148
+ function ensureSoundForNotif(notif) {
149
+ if (notif.soundId && !cachedSounds[notif.soundId]) {
150
+ send({ type: 'request_sound', soundId: notif.soundId });
151
+ }
152
+ }
153
+
154
+ // ─── Scheduling ───────────────────────────────────────────────────────────────
155
+ function rescheduleAll() {
156
+ // Clear all existing timers
157
+ for (const id of Object.keys(scheduledTimers)) {
158
+ clearTimeout(scheduledTimers[id]);
159
+ }
160
+ scheduledTimers = {};
161
+
162
+ const schedule = LS.get(STORAGE_SCHEDULE) || [];
163
+ const notifs = LS.get(STORAGE_NOTIFICATIONS) || [];
164
+ const now = Date.now();
165
+
166
+ for (const entry of schedule) {
167
+ const notif = notifs.find(n => n.id === entry.notificationId);
168
+ if (!notif) continue;
169
+
170
+ const delay = entry.scheduledAt - now;
171
+
172
+ if (delay <= 0) {
173
+ // Missed β€” fire immediately if not displayed
174
+ if (!notif.displayed) {
175
+ fireNotification(notif);
176
+ }
177
+ } else {
178
+ scheduledTimers[notif.id] = setTimeout(() => {
179
+ const freshNotifs = LS.get(STORAGE_NOTIFICATIONS) || [];
180
+ const fresh = freshNotifs.find(n => n.id === notif.id);
181
+ if (fresh && !fresh.displayed) fireNotification(fresh);
182
+ }, delay);
183
+ }
184
+ }
185
+ }
186
+
187
+ // ─── Fire a Notification ──────────────────────────────────────────────────────
188
+ function fireNotification(notif) {
189
+ // Play sound
190
+ if (notif.soundId && cachedSounds[notif.soundId]) {
191
+ playBase64Audio(cachedSounds[notif.soundId].data);
192
+ }
193
+
194
+ // Show browser notification
195
+ if ('Notification' in window && Notification.permission === 'granted') {
196
+ const n = new Notification(notif.heading || notif.name, {
197
+ body: notif.body,
198
+ requireInteraction: true,
199
+ });
200
+ if (notif.hyperlink) {
201
+ n.addEventListener('click', () => {
202
+ window.open(notif.hyperlink, '_blank');
203
+ n.close();
204
+ });
205
+ }
206
+ } else {
207
+ // Fallback: in-page notification banner
208
+ showInPageBanner(notif);
209
+ }
210
+
211
+ // Mark displayed
212
+ markDisplayed(notif.id);
213
+ }
214
+
215
+ function showInPageBanner(notif) {
216
+ const banner = document.createElement('div');
217
+ Object.assign(banner.style, {
218
+ position: 'fixed',
219
+ bottom: '20px',
220
+ right: '20px',
221
+ background: '#222',
222
+ color: '#fff',
223
+ padding: '16px 24px',
224
+ borderRadius: '8px',
225
+ boxShadow: '0 4px 20px rgba(0,0,0,0.4)',
226
+ zIndex: '99999',
227
+ cursor: notif.hyperlink ? 'pointer' : 'default',
228
+ maxWidth: '320px',
229
+ fontFamily: 'sans-serif',
230
+ });
231
+ banner.innerHTML = `<strong>${escapeHtml(notif.heading)}</strong><br>${escapeHtml(notif.body)}`;
232
+ if (notif.hyperlink) {
233
+ banner.addEventListener('click', () => window.open(notif.hyperlink, '_blank'));
234
+ }
235
+ document.body.appendChild(banner);
236
+ setTimeout(() => banner.remove(), 10000);
237
+ }
238
+
239
+ function escapeHtml(str) {
240
+ return String(str || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
241
+ }
242
+
243
+ function markDisplayed(notifId) {
244
+ const notifs = LS.get(STORAGE_NOTIFICATIONS) || [];
245
+ const notif = notifs.find(n => n.id === notifId);
246
+ if (notif) {
247
+ notif.displayed = true;
248
+ LS.set(STORAGE_NOTIFICATIONS, notifs);
249
+ }
250
+ send({ type: 'mark_displayed', notificationId: notifId });
251
+ }
252
+
253
+ // ─── Audio Playback ───────────────────────────────────────────────────────────
254
+ function playBase64Audio(base64Data) {
255
+ try {
256
+ // Try direct audio element
257
+ const audio = new Audio(`data:audio/mpeg;base64,${base64Data}`);
258
+ audio.play().catch(() => {
259
+ // Fallback: decode with AudioContext
260
+ playWithAudioContext(base64Data);
261
+ });
262
+ } catch {
263
+ playWithAudioContext(base64Data);
264
+ }
265
+ }
266
+
267
+ function playWithAudioContext(base64Data) {
268
+ try {
269
+ const ctx = new (window.AudioContext || window.webkitAudioContext)();
270
+ const binary = atob(base64Data);
271
+ const bytes = new Uint8Array(binary.length);
272
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
273
+ ctx.decodeAudioData(bytes.buffer, (buf) => {
274
+ const src = ctx.createBufferSource();
275
+ src.buffer = buf;
276
+ src.connect(ctx.destination);
277
+ src.start(0);
278
+ });
279
+ } catch (e) {
280
+ console.warn('[Audio] playback failed:', e);
281
+ }
282
+ }
283
+
284
+ // ─── localStorage change listener ─────────────────────────────────────────────
285
+ window.addEventListener('storage', (e) => {
286
+ if (e.key === STORAGE_SCHEDULE || e.key === STORAGE_NOTIFICATIONS) {
287
+ rescheduleAll();
288
+ }
289
+ });
290
+
291
+ // ─── Check missed notifications on load ──────────────────────────────────────
292
+ function checkMissedOnLoad() {
293
+ const schedule = LS.get(STORAGE_SCHEDULE) || [];
294
+ const notifs = LS.get(STORAGE_NOTIFICATIONS) || [];
295
+ const now = Date.now();
296
+ for (const entry of schedule) {
297
+ if (entry.scheduledAt <= now) {
298
+ const notif = notifs.find(n => n.id === entry.notificationId);
299
+ if (notif && !notif.displayed) fireNotification(notif);
300
+ }
301
+ }
302
+ }
303
+
304
+ // ─── Boot ─────────────────────────────────────────────────────────────────────
305
+ checkMissedOnLoad();
306
+ rescheduleAll();
307
+ connect();
server.js ADDED
@@ -0,0 +1,378 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const https = require('https');
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const { WebSocketServer, WebSocket } = require('ws');
5
+ const { v4: uuidv4 } = require('uuid');
6
+
7
+ // ─── TLS ──────────────────────────────────────────────────────────────────────
8
+ const serverOptions = {
9
+ cert: fs.readFileSync('cert.pem'),
10
+ key: fs.readFileSync('key.pem'),
11
+ };
12
+
13
+ // ─── In-Memory State ──────────────────────────────────────────────────────────
14
+ // sounds[id] = { id, name, data } (data = base64 string)
15
+ // notifications[id] = { id, name, heading, body, hyperlink, displayed, soundId }
16
+ // devices[uuid] = { uuid, name, notifications: [...], lastConnection, pendingChanges: [...], schedule: [...] }
17
+ const sounds = {};
18
+ const notifications = {};
19
+ const devices = {};
20
+
21
+ // ─── Live Connection Maps ─────────────────────────────────────────────────────
22
+ // deviceClients: uuid -> WebSocket (Frontend 1 connections)
23
+ // adminClients: Set<WebSocket> (Frontend 2 connections)
24
+ const deviceClients = {};
25
+ const adminClients = new Set();
26
+
27
+ // ─── MIME Types ───────────────────────────────────────────────────────────────
28
+ const MIME = {
29
+ '.html': 'text/html',
30
+ '.js': 'application/javascript',
31
+ '.css': 'text/css',
32
+ '.json': 'application/json',
33
+ '.png': 'image/png',
34
+ '.ico': 'image/x-icon',
35
+ };
36
+
37
+ // ─── HTTP File Server ─────────────────────────────────────────────────────────
38
+ const httpServer = https.createServer(serverOptions, (req, res) => {
39
+ let urlPath = req.url.split('?')[0];
40
+ if (urlPath === '/' || urlPath === '') urlPath = '/index.html';
41
+ if (urlPath === '/admin' || urlPath === '/admin/') urlPath = '/admin/index.html';
42
+
43
+ const filePath = path.join(__dirname, 'public', urlPath);
44
+ const ext = path.extname(filePath);
45
+ const mime = MIME[ext] || 'application/octet-stream';
46
+
47
+ fs.readFile(filePath, (err, data) => {
48
+ if (err) {
49
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
50
+ res.end('Not found');
51
+ return;
52
+ }
53
+ res.writeHead(200, { 'Content-Type': mime });
54
+ res.end(data);
55
+ });
56
+ });
57
+
58
+ // ─── WebSocket Server ─────────────────────────────────────────────────────────
59
+ const wss = new WebSocketServer({ server: httpServer });
60
+
61
+ wss.on('connection', (ws, req) => {
62
+ const urlPath = req.url || '/';
63
+ const isAdmin = urlPath.startsWith('/admin-ws');
64
+
65
+ if (isAdmin) {
66
+ handleAdminConnection(ws);
67
+ } else {
68
+ handleDeviceConnection(ws);
69
+ }
70
+ });
71
+
72
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
73
+ function send(ws, msg) {
74
+ if (ws.readyState === WebSocket.OPEN) {
75
+ ws.send(JSON.stringify(msg));
76
+ }
77
+ }
78
+
79
+ function broadcastAdmin(msg) {
80
+ for (const ws of adminClients) {
81
+ send(ws, msg);
82
+ }
83
+ }
84
+
85
+ function broadcastDevice(uuid, msg) {
86
+ const ws = deviceClients[uuid];
87
+ if (ws) send(ws, msg);
88
+ }
89
+
90
+ function broadcastAllDevices(msg) {
91
+ for (const uuid of Object.keys(deviceClients)) {
92
+ broadcastDevice(uuid, msg);
93
+ }
94
+ }
95
+
96
+ function getFullState() {
97
+ return {
98
+ sounds: Object.values(sounds),
99
+ notifications: Object.values(notifications),
100
+ devices: Object.values(devices).map(devicePublic),
101
+ };
102
+ }
103
+
104
+ function devicePublic(d) {
105
+ return {
106
+ uuid: d.uuid,
107
+ name: d.name,
108
+ notifications: d.notifications,
109
+ lastConnection: d.lastConnection,
110
+ schedule: d.schedule || [],
111
+ pendingChanges: d.pendingChanges || [],
112
+ online: !!deviceClients[d.uuid],
113
+ };
114
+ }
115
+
116
+ // ─── Admin Connection Handler ─────────────────────────────────────────────────
117
+ function handleAdminConnection(ws) {
118
+ adminClients.add(ws);
119
+ console.log(`[Admin] connected, total=${adminClients.size}`);
120
+
121
+ // Send full state immediately
122
+ send(ws, { type: 'full_state', ...getFullState() });
123
+
124
+ ws.on('message', (raw) => {
125
+ let msg;
126
+ try { msg = JSON.parse(raw); } catch { return; }
127
+ handleAdminMessage(ws, msg);
128
+ });
129
+
130
+ ws.on('close', () => {
131
+ adminClients.delete(ws);
132
+ console.log(`[Admin] disconnected, total=${adminClients.size}`);
133
+ });
134
+
135
+ ws.on('error', (e) => console.error('[Admin] WS error', e.message));
136
+ }
137
+
138
+ function handleAdminMessage(ws, msg) {
139
+ switch (msg.type) {
140
+
141
+ case 'create_sound': {
142
+ // msg: { name, data }
143
+ const id = uuidv4();
144
+ const sound = { id, name: msg.name, data: msg.data };
145
+ sounds[id] = sound;
146
+ broadcastAdmin({ type: 'sound_added', sound });
147
+ // Don't push sound data to all devices here β€” devices pull sounds on demand
148
+ break;
149
+ }
150
+
151
+ case 'create_notification': {
152
+ // msg: { name, heading, body, hyperlink, soundId }
153
+ const id = uuidv4();
154
+ const notif = {
155
+ id,
156
+ name: msg.name,
157
+ heading: msg.heading,
158
+ body: msg.body,
159
+ hyperlink: msg.hyperlink || '',
160
+ displayed: false,
161
+ soundId: msg.soundId || null,
162
+ };
163
+ notifications[id] = notif;
164
+ // Add to all devices
165
+ for (const uuid of Object.keys(devices)) {
166
+ if (!devices[uuid].notifications.find(n => n.id === id)) {
167
+ devices[uuid].notifications.push({ ...notif });
168
+ }
169
+ }
170
+ broadcastAdmin({ type: 'notification_added', notification: notif });
171
+ broadcastAdmin({ type: 'devices_updated', devices: Object.values(devices).map(devicePublic) });
172
+ // Push to all connected devices
173
+ broadcastAllDevices({ type: 'notification_added', notification: notif });
174
+ break;
175
+ }
176
+
177
+ case 'schedule_notification': {
178
+ // msg: { uuid, notificationId, scheduledAt }
179
+ const device = devices[msg.uuid];
180
+ if (!device) return;
181
+ device.schedule = device.schedule || [];
182
+ const existing = device.schedule.find(s => s.notificationId === msg.notificationId);
183
+ if (existing) {
184
+ existing.scheduledAt = msg.scheduledAt;
185
+ } else {
186
+ device.schedule.push({ notificationId: msg.notificationId, scheduledAt: msg.scheduledAt });
187
+ }
188
+ const scheduleMsg = { type: 'schedule_update', schedule: device.schedule };
189
+ if (deviceClients[msg.uuid]) {
190
+ broadcastDevice(msg.uuid, scheduleMsg);
191
+ } else {
192
+ device.pendingChanges = device.pendingChanges || [];
193
+ device.pendingChanges.push(scheduleMsg);
194
+ }
195
+ broadcastAdmin({ type: 'devices_updated', devices: Object.values(devices).map(devicePublic) });
196
+ break;
197
+ }
198
+
199
+ case 'play_now': {
200
+ // msg: { uuid, notificationId }
201
+ const playMsg = { type: 'play_now', notificationId: msg.notificationId };
202
+ if (deviceClients[msg.uuid]) {
203
+ broadcastDevice(msg.uuid, playMsg);
204
+ } else {
205
+ const device = devices[msg.uuid];
206
+ if (device) {
207
+ device.pendingChanges = device.pendingChanges || [];
208
+ device.pendingChanges.push(playMsg);
209
+ }
210
+ }
211
+ break;
212
+ }
213
+
214
+ case 'update_device_name': {
215
+ // msg: { uuid, name }
216
+ const device = devices[msg.uuid];
217
+ if (!device) return;
218
+ device.name = msg.name;
219
+ const nameMsg = { type: 'name_update', name: msg.name };
220
+ if (deviceClients[msg.uuid]) {
221
+ broadcastDevice(msg.uuid, nameMsg);
222
+ } else {
223
+ device.pendingChanges = device.pendingChanges || [];
224
+ device.pendingChanges.push(nameMsg);
225
+ }
226
+ broadcastAdmin({ type: 'devices_updated', devices: Object.values(devices).map(devicePublic) });
227
+ break;
228
+ }
229
+
230
+ case 'remove_schedule': {
231
+ // msg: { uuid, notificationId }
232
+ const device = devices[msg.uuid];
233
+ if (!device) return;
234
+ device.schedule = (device.schedule || []).filter(s => s.notificationId !== msg.notificationId);
235
+ const rmMsg = { type: 'schedule_update', schedule: device.schedule };
236
+ if (deviceClients[msg.uuid]) {
237
+ broadcastDevice(msg.uuid, rmMsg);
238
+ } else {
239
+ device.pendingChanges = device.pendingChanges || [];
240
+ device.pendingChanges.push(rmMsg);
241
+ }
242
+ broadcastAdmin({ type: 'devices_updated', devices: Object.values(devices).map(devicePublic) });
243
+ break;
244
+ }
245
+
246
+ case 'request_sound': {
247
+ // admin wants a specific sound by id
248
+ const sound = sounds[msg.id];
249
+ if (sound) send(ws, { type: 'sound_data', sound });
250
+ break;
251
+ }
252
+
253
+ default:
254
+ console.warn('[Admin] unknown message type:', msg.type);
255
+ }
256
+ }
257
+
258
+ // ─── Device Connection Handler ────────────────────────────────────────────────
259
+ function handleDeviceConnection(ws) {
260
+ let deviceUUID = null;
261
+ console.log('[Device] new connection (uuid pending)');
262
+
263
+ ws.on('message', (raw) => {
264
+ let msg;
265
+ try { msg = JSON.parse(raw); } catch { return; }
266
+
267
+ if (msg.type === 'hello') {
268
+ // Device introduces itself with optional uuid
269
+ if (msg.uuid && devices[msg.uuid]) {
270
+ deviceUUID = msg.uuid;
271
+ } else {
272
+ // New device
273
+ deviceUUID = msg.uuid || uuidv4();
274
+ devices[deviceUUID] = {
275
+ uuid: deviceUUID,
276
+ name: `Device ${Object.keys(devices).length + 1}`,
277
+ notifications: Object.values(notifications).map(n => ({ ...n })),
278
+ lastConnection: null,
279
+ schedule: [],
280
+ pendingChanges: [],
281
+ };
282
+ }
283
+
284
+ deviceClients[deviceUUID] = ws;
285
+ const device = devices[deviceUUID];
286
+ device.lastConnection = null; // online
287
+
288
+ // Deliver any pending changes
289
+ if (device.pendingChanges && device.pendingChanges.length > 0) {
290
+ for (const change of device.pendingChanges) {
291
+ send(ws, change);
292
+ }
293
+ device.pendingChanges = [];
294
+ }
295
+
296
+ // Send device its full state
297
+ send(ws, {
298
+ type: 'device_init',
299
+ uuid: deviceUUID,
300
+ notifications: device.notifications,
301
+ schedule: device.schedule,
302
+ name: device.name,
303
+ });
304
+
305
+ // Tell admin about new/reconnected device
306
+ broadcastAdmin({ type: 'devices_updated', devices: Object.values(devices).map(devicePublic) });
307
+ console.log(`[Device] ${deviceUUID} connected (${device.name})`);
308
+ return;
309
+ }
310
+
311
+ if (!deviceUUID) return; // not initialized
312
+
313
+ handleDeviceMessage(ws, deviceUUID, msg);
314
+ });
315
+
316
+ ws.on('close', () => {
317
+ if (deviceUUID && devices[deviceUUID]) {
318
+ devices[deviceUUID].lastConnection = Date.now();
319
+ delete deviceClients[deviceUUID];
320
+ broadcastAdmin({ type: 'devices_updated', devices: Object.values(devices).map(devicePublic) });
321
+ console.log(`[Device] ${deviceUUID} disconnected`);
322
+ }
323
+ });
324
+
325
+ ws.on('error', (e) => console.error('[Device] WS error', e.message));
326
+ }
327
+
328
+ function handleDeviceMessage(ws, uuid, msg) {
329
+ const device = devices[uuid];
330
+ if (!device) return;
331
+
332
+ switch (msg.type) {
333
+
334
+ case 'mark_displayed': {
335
+ // msg: { notificationId }
336
+ const notif = device.notifications.find(n => n.id === msg.notificationId);
337
+ if (notif) notif.displayed = true;
338
+ // Also mark in master list
339
+ if (notifications[msg.notificationId]) {
340
+ notifications[msg.notificationId].displayed = true;
341
+ }
342
+ broadcastAdmin({ type: 'devices_updated', devices: Object.values(devices).map(devicePublic) });
343
+ break;
344
+ }
345
+
346
+ case 'request_sound': {
347
+ // Device needs sound data for a notification
348
+ const sound = sounds[msg.soundId];
349
+ if (sound) {
350
+ send(ws, { type: 'sound_data', sound });
351
+ }
352
+ break;
353
+ }
354
+
355
+ case 'sync_schedule': {
356
+ // Device reports its current schedule (for reconciliation)
357
+ device.schedule = msg.schedule || [];
358
+ broadcastAdmin({ type: 'devices_updated', devices: Object.values(devices).map(devicePublic) });
359
+ break;
360
+ }
361
+
362
+ case 'cached_sounds': {
363
+ // Device reports which sound IDs it has cached
364
+ device.cachedSounds = msg.soundIds || [];
365
+ broadcastAdmin({ type: 'devices_updated', devices: Object.values(devices).map(devicePublic) });
366
+ break;
367
+ }
368
+
369
+ default:
370
+ console.warn('[Device] unknown message type:', msg.type);
371
+ }
372
+ }
373
+
374
+ // ─── Start ────────────────────────────────────────────────────────────────────
375
+ const PORT = process.env.PORT || 7860;
376
+ httpServer.listen(PORT, () => {
377
+ console.log(`Server running on https://0.0.0.0:${PORT}`);
378
+ });