Sebebeb commited on
Commit
1a4520a
·
verified ·
1 Parent(s): 65be0cc

Delete public

Browse files
public/admin/index.html DELETED
@@ -1,190 +0,0 @@
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
- <!-- ── LOGIN SCREEN ── -->
13
- <div id="login-screen">
14
- <div id="login-box">
15
- <div id="login-logo">DISPATCH</div>
16
- <div id="login-subtitle">ADMIN ACCESS</div>
17
- <div id="login-form">
18
- <div class="form-group">
19
- <label for="login-password">ACCESS KEY</label>
20
- <input id="login-password" type="password" placeholder="Enter key..." autocomplete="current-password" />
21
- </div>
22
- <div id="login-error"></div>
23
- <button id="login-btn" class="btn-create">AUTHENTICATE</button>
24
- </div>
25
- <div id="login-connecting" style="display:none">
26
- <div class="spinner"></div>
27
- <span>Connecting...</span>
28
- </div>
29
- </div>
30
- </div>
31
-
32
- <!-- ── MAIN APP (hidden until authenticated) ── -->
33
- <div id="app" style="display:none">
34
-
35
- <!-- Top Bar -->
36
- <header id="topbar">
37
- <div id="topbar-logo">DISPATCH</div>
38
- <div id="topbar-status">
39
- <span id="ws-indicator" class="indicator offline"></span>
40
- <span id="ws-label">OFFLINE</span>
41
- <button id="logout-btn" class="btn-logout">LOGOUT</button>
42
- </div>
43
- </header>
44
-
45
- <!-- Tabs -->
46
- <nav id="tabs">
47
- <button class="tab-btn active" data-tab="notifications">NOTIFICATIONS</button>
48
- <button class="tab-btn" data-tab="devices">DEVICES</button>
49
- </nav>
50
-
51
- <!-- ── NOTIFICATIONS TAB ── -->
52
- <div id="tab-notifications" class="tab-panel active">
53
- <div id="notif-layout">
54
-
55
- <!-- LEFT: Create Notification Form -->
56
- <aside id="create-panel">
57
- <h2 class="panel-title">NEW NOTIFICATION</h2>
58
- <div class="form-group">
59
- <label for="f-name">Internal Name</label>
60
- <input id="f-name" type="text" placeholder="e.g. promo-sept-24" />
61
- </div>
62
- <div class="form-group">
63
- <label for="f-heading">Heading</label>
64
- <input id="f-heading" type="text" placeholder="Notification heading" />
65
- </div>
66
- <div class="form-group">
67
- <label for="f-body">Body</label>
68
- <textarea id="f-body" rows="4" placeholder="Notification body text..."></textarea>
69
- </div>
70
- <div class="form-group">
71
- <label for="f-hyperlink">Hyperlink</label>
72
- <input id="f-hyperlink" type="url" placeholder="https://..." />
73
- </div>
74
- <div class="form-group">
75
- <label for="f-sound">Sound</label>
76
- <select id="f-sound">
77
- <option value="">— No Sound —</option>
78
- </select>
79
- </div>
80
- <div class="form-group">
81
- <label>Upload Sound File</label>
82
- <div id="sound-upload-area">
83
- <input id="f-sound-file" type="file" accept="audio/*" hidden />
84
- <button id="sound-upload-btn" class="btn-outline" type="button">▲ UPLOAD AUDIO</button>
85
- <span id="sound-file-name">No file chosen</span>
86
- </div>
87
- <input id="f-sound-name" type="text" placeholder="Sound name (required for upload)" style="margin-top:8px" />
88
- </div>
89
- <button id="create-notif-btn" class="btn-create">CREATE NOTIFICATION</button>
90
- </aside>
91
-
92
- <!-- MIDDLE: Notification List -->
93
- <main id="notif-list-panel">
94
- <h2 class="panel-title">ALL NOTIFICATIONS <span id="notif-count" class="badge">0</span></h2>
95
- <div id="notif-list">
96
- <div class="empty-state">No notifications yet.</div>
97
- </div>
98
- </main>
99
-
100
- <!-- RIGHT: Schedule Panel -->
101
- <section id="schedule-panel">
102
- <div id="schedule-empty-state">
103
- <div class="select-hint">
104
- <div class="select-hint-icon">◎</div>
105
- <p>Select a notification<br>to schedule or play it</p>
106
- </div>
107
- </div>
108
- <div id="schedule-active" style="display:none">
109
- <div id="selected-notif-info">
110
- <div id="selected-notif-name"></div>
111
- </div>
112
- <button id="play-now-btn" title="Play Now"><span>▶ PLAY NOW</span></button>
113
- <div id="schedule-controls">
114
- <div class="toggle-row">
115
- <label class="toggle-label" for="now-toggle">NOW</label>
116
- <label class="toggle-switch">
117
- <input type="checkbox" id="now-toggle" />
118
- <span class="toggle-track"><span class="toggle-thumb"></span></span>
119
- </label>
120
- </div>
121
- <div id="datetime-pickers">
122
- <div class="picker-group">
123
- <label>DATE</label>
124
- <input type="date" id="sched-date" />
125
- </div>
126
- <div class="picker-group">
127
- <label>TIME</label>
128
- <input type="time" id="sched-time" />
129
- </div>
130
- </div>
131
- <button id="schedule-btn" class="btn-schedule">SCHEDULE</button>
132
- </div>
133
- </div>
134
- </section>
135
-
136
- </div>
137
- </div>
138
-
139
- <!-- ── DEVICES TAB ── -->
140
- <div id="tab-devices" class="tab-panel">
141
- <div id="devices-layout">
142
- <aside id="device-list-panel">
143
- <h2 class="panel-title">DEVICES <span id="device-count" class="badge">0</span></h2>
144
- <div id="device-list">
145
- <div class="empty-state">No devices connected yet.</div>
146
- </div>
147
- </aside>
148
- <main id="device-detail-panel">
149
- <div id="device-empty-state" class="select-hint">
150
- <div class="select-hint-icon">◉</div>
151
- <p>Select a device<br>to manage it</p>
152
- </div>
153
- <div id="device-detail" style="display:none">
154
- <div id="device-header">
155
- <div id="device-status-indicator" class="indicator"></div>
156
- <div>
157
- <div id="detail-device-name-display" class="device-detail-name"></div>
158
- <div id="detail-device-uuid" class="device-detail-uuid"></div>
159
- </div>
160
- <div id="device-name-edit-area">
161
- <input id="device-name-input" type="text" placeholder="Device name..." />
162
- <button id="device-name-save" class="btn-sm">SAVE NAME</button>
163
- </div>
164
- </div>
165
- <div id="device-sections">
166
- <div class="device-section">
167
- <h3>CACHED SOUNDS</h3>
168
- <div id="detail-sounds" class="tag-list"></div>
169
- </div>
170
- <div class="device-section">
171
- <h3>NOTIFICATIONS</h3>
172
- <div id="detail-notifications" class="notif-table-wrap"></div>
173
- </div>
174
- <div class="device-section">
175
- <h3>SCHEDULE</h3>
176
- <div id="detail-schedule" class="schedule-table-wrap"></div>
177
- </div>
178
- </div>
179
- </div>
180
- </main>
181
- </div>
182
- </div>
183
-
184
- </div><!-- /#app -->
185
-
186
- </div><!-- /#root -->
187
-
188
- <script src="/admin/script.js"></script>
189
- </body>
190
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
public/admin/script.js DELETED
@@ -1,522 +0,0 @@
1
- // ─── DISPATCH Admin Script ────────────────────────────────────────────────────
2
-
3
- const SESSION_KEY = 'dispatch_session_token';
4
-
5
- // ─── State ────────────────────────────────────────────────────────────────────
6
- let state = {
7
- sounds: [],
8
- notifications: [],
9
- devices: [],
10
- };
11
-
12
- let selectedNotifId = null;
13
- let selectedDeviceUUID = null;
14
- let nowIntervalId = null;
15
- let pendingSoundFile = null;
16
-
17
- let ws = null;
18
- let reconnectTimer = null;
19
- let authenticated = false;
20
-
21
- // ─── UI Refs ──────────────────────────────────────────────────────────────────
22
- const loginScreen = document.getElementById('login-screen');
23
- const appEl = document.getElementById('app');
24
- const loginBtn = document.getElementById('login-btn');
25
- const loginPwInput = document.getElementById('login-password');
26
- const loginError = document.getElementById('login-error');
27
- const loginForm = document.getElementById('login-form');
28
- const loginConnecting = document.getElementById('login-connecting');
29
-
30
- // ─── Auth Helpers ─────────────────────────────────────────────────────────────
31
- function getToken() { return sessionStorage.getItem(SESSION_KEY); }
32
- function setToken(t) { sessionStorage.setItem(SESSION_KEY, t); }
33
- function clearToken() { sessionStorage.removeItem(SESSION_KEY); }
34
-
35
- function showApp() {
36
- loginScreen.style.display = 'none';
37
- appEl.style.display = 'block';
38
- authenticated = true;
39
- }
40
-
41
- function showLogin(errorMsg) {
42
- appEl.style.display = 'none';
43
- loginScreen.style.display = 'flex';
44
- authenticated = false;
45
- loginForm.style.display = 'block';
46
- loginConnecting.style.display = 'none';
47
- loginPwInput.value = '';
48
- loginError.textContent = errorMsg || '';
49
- }
50
-
51
- function setLoginLoading(loading) {
52
- loginForm.style.display = loading ? 'none' : 'block';
53
- loginConnecting.style.display = loading ? 'flex' : 'none';
54
- }
55
-
56
- // ─── WebSocket ────────────────────────────────────────────────────────────────
57
- function connect() {
58
- clearTimeout(reconnectTimer);
59
- const proto = location.protocol === 'https:' ? 'wss' : 'ws';
60
- ws = new WebSocket(`${proto}://${location.host}/admin-ws`);
61
-
62
- ws.addEventListener('open', () => {
63
- // Server will immediately send auth_required; we respond appropriately
64
- });
65
-
66
- ws.addEventListener('message', (e) => {
67
- let msg;
68
- try { msg = JSON.parse(e.data); } catch { return; }
69
- handleMessage(msg);
70
- });
71
-
72
- ws.addEventListener('close', () => {
73
- if (authenticated) {
74
- setIndicator(false);
75
- // Try to reconnect silently — session token is still in sessionStorage
76
- reconnectTimer = setTimeout(connect, 3000);
77
- }
78
- });
79
-
80
- ws.addEventListener('error', () => ws.close());
81
- }
82
-
83
- function send(msg) {
84
- if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg));
85
- }
86
-
87
- // ─── Message Handling ─────────────────────────────────────────────────────────
88
- function handleMessage(msg) {
89
- switch (msg.type) {
90
-
91
- case 'auth_required': {
92
- // Server is asking us to authenticate
93
- const token = getToken();
94
- if (token) {
95
- // Try to resume existing session
96
- setLoginLoading(true);
97
- send({ type: 'auth_resume', token });
98
- } else {
99
- // Show login form
100
- showLogin();
101
- }
102
- break;
103
- }
104
-
105
- case 'auth_ok': {
106
- setToken(msg.token);
107
- showApp();
108
- setIndicator(true);
109
- break;
110
- }
111
-
112
- case 'auth_error': {
113
- clearToken();
114
- showLogin(msg.reason || 'Authentication failed.');
115
- break;
116
- }
117
-
118
- case 'auth_timeout': {
119
- clearToken();
120
- showLogin('Connection timed out. Please try again.');
121
- break;
122
- }
123
-
124
- case 'full_state': {
125
- state.sounds = msg.sounds || [];
126
- state.notifications = msg.notifications || [];
127
- state.devices = msg.devices || [];
128
- renderAll();
129
- break;
130
- }
131
-
132
- case 'sound_added': {
133
- if (!state.sounds.find(s => s.id === msg.sound.id)) state.sounds.push(msg.sound);
134
- updateSoundSelect();
135
- break;
136
- }
137
-
138
- case 'notification_added': {
139
- if (!state.notifications.find(n => n.id === msg.notification.id))
140
- state.notifications.push(msg.notification);
141
- renderNotifList();
142
- updateNotifCount();
143
- toast('Notification created', 'success');
144
- break;
145
- }
146
-
147
- case 'devices_updated': {
148
- state.devices = msg.devices || [];
149
- renderDeviceList();
150
- updateDeviceCount();
151
- if (selectedDeviceUUID) {
152
- const d = state.devices.find(d => d.uuid === selectedDeviceUUID);
153
- if (d) renderDeviceDetail(d);
154
- }
155
- break;
156
- }
157
-
158
- case 'sound_data': {
159
- const existing = state.sounds.find(s => s.id === msg.sound.id);
160
- if (existing) { existing.data = msg.sound.data; }
161
- else { state.sounds.push(msg.sound); }
162
- break;
163
- }
164
-
165
- default: break;
166
- }
167
- }
168
-
169
- // ─── Login Form ───────────────────────────────────────────────────────────────
170
- loginBtn.addEventListener('click', doLogin);
171
- loginPwInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') doLogin(); });
172
-
173
- function doLogin() {
174
- const password = loginPwInput.value;
175
- if (!password) { loginError.textContent = 'Enter a password.'; return; }
176
- loginError.textContent = '';
177
- setLoginLoading(true);
178
- // If WS is not open yet, connect first then send on open
179
- if (!ws || ws.readyState !== WebSocket.OPEN) {
180
- connect();
181
- // wait for auth_required, then it will show login — but we want to auto-submit
182
- // Store the password to auto-submit after connection
183
- ws.addEventListener('open', () => {
184
- // auth_required will trigger from server, which will show login again
185
- // Instead, wait for auth_required then send login directly
186
- }, { once: true });
187
- // Override: on auth_required from a fresh connect after user clicked login,
188
- // send the login immediately
189
- const originalHandler = ws.onmessage;
190
- ws.addEventListener('message', function onFirstMsg(e) {
191
- let msg;
192
- try { msg = JSON.parse(e.data); } catch { return; }
193
- if (msg.type === 'auth_required') {
194
- ws.removeEventListener('message', onFirstMsg);
195
- send({ type: 'auth_login', password });
196
- }
197
- });
198
- } else {
199
- send({ type: 'auth_login', password });
200
- }
201
- }
202
-
203
- // ─── Logout ───────────────────────────────────────────────────────────────────
204
- document.getElementById('logout-btn').addEventListener('click', () => {
205
- clearToken();
206
- authenticated = false;
207
- clearTimeout(reconnectTimer);
208
- if (ws) ws.close();
209
- showLogin();
210
- });
211
-
212
- // ─── Render All ───────────────────────────────────────────────────────────────
213
- function renderAll() {
214
- updateSoundSelect();
215
- renderNotifList();
216
- updateNotifCount();
217
- renderDeviceList();
218
- updateDeviceCount();
219
- if (selectedNotifId) {
220
- const n = state.notifications.find(n => n.id === selectedNotifId);
221
- if (!n) selectedNotifId = null;
222
- else showSchedulePanel(n);
223
- }
224
- }
225
-
226
- // ─── Notifications ────────────────────────────────────────────────────────────
227
- function renderNotifList() {
228
- const list = document.getElementById('notif-list');
229
- if (!state.notifications.length) {
230
- list.innerHTML = '<div class="empty-state">No notifications yet.</div>';
231
- return;
232
- }
233
- list.innerHTML = state.notifications.map(n => {
234
- const sound = n.soundId ? state.sounds.find(s => s.id === n.soundId) : null;
235
- const sel = n.id === selectedNotifId ? 'selected' : '';
236
- return `
237
- <div class="notif-item ${sel}" data-id="${n.id}">
238
- <div class="notif-item-name">${esc(n.name)}</div>
239
- <div class="notif-item-heading">${esc(n.heading)}</div>
240
- <div class="notif-item-body">${esc(n.body)}</div>
241
- ${sound ? `<span class="notif-item-sound">♪ ${esc(sound.name)}</span>` : ''}
242
- </div>`;
243
- }).join('');
244
-
245
- list.querySelectorAll('.notif-item').forEach(el => {
246
- el.addEventListener('click', () => {
247
- selectedNotifId = el.dataset.id;
248
- list.querySelectorAll('.notif-item').forEach(e => e.classList.remove('selected'));
249
- el.classList.add('selected');
250
- const notif = state.notifications.find(n => n.id === selectedNotifId);
251
- if (notif) showSchedulePanel(notif);
252
- });
253
- });
254
- }
255
-
256
- function updateNotifCount() {
257
- document.getElementById('notif-count').textContent = state.notifications.length;
258
- }
259
-
260
- function updateSoundSelect() {
261
- const sel = document.getElementById('f-sound');
262
- const current = sel.value;
263
- sel.innerHTML = '<option value="">— No Sound —</option>' +
264
- state.sounds.map(s => `<option value="${s.id}">${esc(s.name)}</option>`).join('');
265
- if (current) sel.value = current;
266
- }
267
-
268
- // ─── Schedule Panel ───────────────────────────────────────────────────────────
269
- function showSchedulePanel(notif) {
270
- document.getElementById('schedule-empty-state').style.display = 'none';
271
- const active = document.getElementById('schedule-active');
272
- active.style.display = 'flex';
273
- active.style.flexDirection = 'column';
274
- document.getElementById('selected-notif-name').textContent = notif.heading || notif.name;
275
- setDateTimeToNow();
276
- }
277
-
278
- function setDateTimeToNow() {
279
- const now = new Date();
280
- document.getElementById('sched-date').value = now.toISOString().slice(0, 10);
281
- document.getElementById('sched-time').value = now.toTimeString().slice(0, 5);
282
- }
283
-
284
- // ─── Devices ──────────────────────────────────────────────────────────────────
285
- function renderDeviceList() {
286
- const list = document.getElementById('device-list');
287
- if (!state.devices.length) {
288
- list.innerHTML = '<div class="empty-state">No devices yet.</div>';
289
- return;
290
- }
291
- list.innerHTML = state.devices.map(d => {
292
- const sel = d.uuid === selectedDeviceUUID ? 'selected' : '';
293
- const lastConn = d.lastConnection ? `Last seen ${timeSince(d.lastConnection)}` : 'Active now';
294
- return `
295
- <div class="device-item ${sel}" data-uuid="${d.uuid}">
296
- <span class="indicator ${d.online ? 'online' : 'offline'}"></span>
297
- <div class="device-item-info">
298
- <div class="device-item-name">${esc(d.name)}</div>
299
- <div class="device-item-uuid">${d.uuid.slice(0,18)}…</div>
300
- <div class="device-item-last">${lastConn}</div>
301
- </div>
302
- </div>`;
303
- }).join('');
304
-
305
- list.querySelectorAll('.device-item').forEach(el => {
306
- el.addEventListener('click', () => {
307
- selectedDeviceUUID = el.dataset.uuid;
308
- list.querySelectorAll('.device-item').forEach(e => e.classList.remove('selected'));
309
- el.classList.add('selected');
310
- const d = state.devices.find(d => d.uuid === selectedDeviceUUID);
311
- if (d) renderDeviceDetail(d);
312
- });
313
- });
314
- }
315
-
316
- function updateDeviceCount() {
317
- document.getElementById('device-count').textContent = state.devices.length;
318
- }
319
-
320
- function renderDeviceDetail(device) {
321
- document.getElementById('device-empty-state').style.display = 'none';
322
- const detail = document.getElementById('device-detail');
323
- detail.style.display = 'block';
324
-
325
- document.getElementById('detail-device-name-display').textContent = device.name;
326
- document.getElementById('detail-device-uuid').textContent = device.uuid;
327
- document.getElementById('device-name-input').value = device.name;
328
- document.getElementById('device-status-indicator').className = `indicator ${device.online ? 'online' : 'offline'}`;
329
-
330
- // Cached sounds
331
- const soundsEl = document.getElementById('detail-sounds');
332
- const cached = device.cachedSounds || [];
333
- soundsEl.innerHTML = !cached.length
334
- ? '<span style="color:var(--text-dimmer);font-size:12px">None cached</span>'
335
- : cached.map(sid => {
336
- const sound = state.sounds.find(s => s.id === sid);
337
- return `<span class="tag"><span class="tag-dot"></span>${esc(sound ? sound.name : sid.slice(0,8))}</span>`;
338
- }).join('');
339
-
340
- // Notifications table
341
- const notifsEl = document.getElementById('detail-notifications');
342
- const deviceNotifs = device.notifications || [];
343
- notifsEl.innerHTML = !deviceNotifs.length
344
- ? '<div class="empty-state" style="padding:20px 0">No notifications</div>'
345
- : `<table class="data-table"><thead><tr><th>NAME</th><th>HEADING</th><th>DISPLAYED</th></tr></thead><tbody>
346
- ${deviceNotifs.map(n => `<tr>
347
- <td>${esc(n.name)}</td><td>${esc(n.heading)}</td>
348
- <td style="color:${n.displayed ? 'var(--green)' : 'var(--text-dimmer)'}">${n.displayed ? '✓ YES' : '— NO'}</td>
349
- </tr>`).join('')}
350
- </tbody></table>`;
351
-
352
- // Schedule table
353
- const schedEl = document.getElementById('detail-schedule');
354
- const schedule = device.schedule || [];
355
- if (!schedule.length) {
356
- schedEl.innerHTML = '<div class="empty-state" style="padding:20px 0">No scheduled notifications</div>';
357
- } else {
358
- schedEl.innerHTML = `<table class="data-table"><thead><tr><th>NOTIFICATION</th><th>SCHEDULED FOR</th><th>ACTION</th></tr></thead><tbody>
359
- ${schedule.map(entry => {
360
- const notif = state.notifications.find(n => n.id === entry.notificationId);
361
- const name = notif ? (notif.heading || notif.name) : entry.notificationId.slice(0,8);
362
- return `<tr>
363
- <td>${esc(name)}</td>
364
- <td>${new Date(entry.scheduledAt).toLocaleString()}</td>
365
- <td><button class="action-btn" data-uuid="${device.uuid}" data-nid="${entry.notificationId}">REMOVE</button></td>
366
- </tr>`;
367
- }).join('')}
368
- </tbody></table>`;
369
- schedEl.querySelectorAll('.action-btn').forEach(btn => {
370
- btn.addEventListener('click', () => {
371
- send({ type: 'remove_schedule', uuid: btn.dataset.uuid, notificationId: btn.dataset.nid });
372
- toast('Schedule entry removed');
373
- });
374
- });
375
- }
376
- }
377
-
378
- // ─── Create Notification ──────────────────────────────────────────────────────
379
- document.getElementById('create-notif-btn').addEventListener('click', () => {
380
- const name = document.getElementById('f-name').value.trim();
381
- const heading = document.getElementById('f-heading').value.trim();
382
- const body = document.getElementById('f-body').value.trim();
383
- const hyperlink = document.getElementById('f-hyperlink').value.trim();
384
- const soundId = document.getElementById('f-sound').value;
385
-
386
- if (!name || !heading) { toast('Name and Heading are required', 'error'); return; }
387
-
388
- if (pendingSoundFile) {
389
- const { soundName, data } = pendingSoundFile;
390
- if (!soundName) { toast('Enter a name for the sound', 'error'); return; }
391
- send({ type: 'create_sound', name: soundName, data });
392
- pendingSoundFile = null;
393
- document.getElementById('sound-file-name').textContent = 'No file chosen';
394
- document.getElementById('f-sound-name').value = '';
395
- toast('Sound uploaded — select it in the dropdown, then create the notification.', 'success');
396
- return;
397
- }
398
-
399
- send({ type: 'create_notification', name, heading, body, hyperlink, soundId: soundId || null });
400
- document.getElementById('f-name').value = '';
401
- document.getElementById('f-heading').value = '';
402
- document.getElementById('f-body').value = '';
403
- document.getElementById('f-hyperlink').value = '';
404
- document.getElementById('f-sound').value = '';
405
- });
406
-
407
- document.getElementById('sound-upload-btn').addEventListener('click', () => {
408
- document.getElementById('f-sound-file').click();
409
- });
410
-
411
- document.getElementById('f-sound-file').addEventListener('change', (e) => {
412
- const file = e.target.files[0];
413
- if (!file) return;
414
- document.getElementById('sound-file-name').textContent = file.name;
415
- const reader = new FileReader();
416
- reader.onload = (evt) => {
417
- const base64 = evt.target.result.split(',')[1];
418
- const soundName = document.getElementById('f-sound-name').value.trim() || file.name;
419
- pendingSoundFile = { soundName, data: base64 };
420
- };
421
- reader.readAsDataURL(file);
422
- });
423
-
424
- document.getElementById('f-sound-name').addEventListener('input', () => {
425
- if (pendingSoundFile) pendingSoundFile.soundName = document.getElementById('f-sound-name').value.trim();
426
- });
427
-
428
- // ─── Play Now ─────────────────────────────────────────────────────────────────
429
- document.getElementById('play-now-btn').addEventListener('click', () => {
430
- if (!selectedNotifId) return;
431
- for (const device of state.devices) {
432
- send({ type: 'play_now', uuid: device.uuid, notificationId: selectedNotifId });
433
- }
434
- toast('▶ Sent to all devices', 'success');
435
- });
436
-
437
- // ─── Now Toggle ───────────────────────────────────────────────────────────────
438
- document.getElementById('now-toggle').addEventListener('change', (e) => {
439
- if (e.target.checked) {
440
- setDateTimeToNow();
441
- nowIntervalId = setInterval(setDateTimeToNow, 1000);
442
- document.getElementById('datetime-pickers').style.opacity = '0.5';
443
- document.getElementById('datetime-pickers').style.pointerEvents = 'none';
444
- } else {
445
- clearInterval(nowIntervalId);
446
- document.getElementById('datetime-pickers').style.opacity = '';
447
- document.getElementById('datetime-pickers').style.pointerEvents = '';
448
- }
449
- });
450
-
451
- // ─── Schedule ─────────────────────────────────────────────────────────────────
452
- document.getElementById('schedule-btn').addEventListener('click', () => {
453
- if (!selectedNotifId) return;
454
- const dateVal = document.getElementById('sched-date').value;
455
- const timeVal = document.getElementById('sched-time').value;
456
- if (!dateVal || !timeVal) { toast('Select a date and time', 'error'); return; }
457
- const scheduledAt = new Date(`${dateVal}T${timeVal}`).getTime();
458
- if (isNaN(scheduledAt)) { toast('Invalid date/time', 'error'); return; }
459
- const targets = selectedDeviceUUID
460
- ? state.devices.filter(d => d.uuid === selectedDeviceUUID)
461
- : state.devices;
462
- if (!targets.length) { toast('No devices to schedule on', 'error'); return; }
463
- for (const device of targets) {
464
- send({ type: 'schedule_notification', uuid: device.uuid, notificationId: selectedNotifId, scheduledAt });
465
- }
466
- toast(`Scheduled on ${targets.length} device(s)`, 'success');
467
- });
468
-
469
- // ─── Tabs ─────────────────────────────────────────────────────────────────────
470
- document.querySelectorAll('.tab-btn').forEach(btn => {
471
- btn.addEventListener('click', () => {
472
- const tab = btn.dataset.tab;
473
- document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
474
- document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
475
- btn.classList.add('active');
476
- document.getElementById(`tab-${tab}`).classList.add('active');
477
- });
478
- });
479
-
480
- // ─── Device Name ─────────────────────────────────────────────────────────────
481
- document.getElementById('device-name-save').addEventListener('click', () => {
482
- if (!selectedDeviceUUID) return;
483
- const name = document.getElementById('device-name-input').value.trim();
484
- if (!name) { toast('Enter a name', 'error'); return; }
485
- send({ type: 'update_device_name', uuid: selectedDeviceUUID, name });
486
- toast('Name updated', 'success');
487
- });
488
-
489
- // ─── WS Indicator ─────────────────────────────────────────────────────────────
490
- function setIndicator(online) {
491
- const dot = document.getElementById('ws-indicator');
492
- const label = document.getElementById('ws-label');
493
- dot.className = `indicator ${online ? 'online' : 'offline'}`;
494
- label.textContent = online ? 'LIVE' : 'OFFLINE';
495
- }
496
-
497
- // ─── Toast ────────────────────────────────────────────────────────────────────
498
- let toastTimer;
499
- function toast(msg, type = '') {
500
- let el = document.getElementById('toast');
501
- if (!el) { el = document.createElement('div'); el.id = 'toast'; document.body.appendChild(el); }
502
- el.textContent = msg;
503
- el.className = type ? `show ${type}` : 'show';
504
- clearTimeout(toastTimer);
505
- toastTimer = setTimeout(() => { el.className = el.className.replace('show', '').trim(); }, 3000);
506
- }
507
-
508
- // ─── Helpers ──────────────────────────────────────────────────────────────────
509
- function esc(str) {
510
- return String(str || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
511
- }
512
- function timeSince(ts) {
513
- const d = Date.now() - ts;
514
- if (d < 60000) return `${Math.floor(d/1000)}s ago`;
515
- if (d < 3600000) return `${Math.floor(d/60000)}m ago`;
516
- if (d < 86400000) return `${Math.floor(d/3600000)}h ago`;
517
- return `${Math.floor(d/86400000)}d ago`;
518
- }
519
-
520
- // ─── Boot ─────────────────────────────────────────────────────────────────────
521
- setDateTimeToNow();
522
- connect();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
public/admin/style.css DELETED
@@ -1,789 +0,0 @@
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); }
694
-
695
- /* ─── Login Screen ───────────────────────────────────────────────────────────── */
696
- #login-screen {
697
- position: fixed;
698
- inset: 0;
699
- background: var(--bg);
700
- display: flex;
701
- align-items: center;
702
- justify-content: center;
703
- z-index: 1000;
704
- }
705
-
706
- #login-box {
707
- width: 360px;
708
- background: var(--surface);
709
- border: 1px solid var(--border2);
710
- border-radius: var(--radius-lg);
711
- padding: 40px 36px 32px;
712
- box-shadow: 0 8px 48px rgba(0,0,0,0.6);
713
- }
714
-
715
- #login-logo {
716
- font-family: var(--sans);
717
- font-weight: 800;
718
- font-size: 28px;
719
- letter-spacing: 0.2em;
720
- color: var(--accent);
721
- text-align: center;
722
- margin-bottom: 4px;
723
- }
724
-
725
- #login-subtitle {
726
- text-align: center;
727
- font-size: 10px;
728
- letter-spacing: 0.22em;
729
- color: var(--text-dimmer);
730
- margin-bottom: 32px;
731
- }
732
-
733
- #login-box .form-group {
734
- padding: 0;
735
- border-bottom: none;
736
- margin-bottom: 16px;
737
- }
738
-
739
- #login-box .btn-create {
740
- margin: 8px 0 0;
741
- width: 100%;
742
- }
743
-
744
- #login-error {
745
- font-size: 11px;
746
- color: var(--red);
747
- letter-spacing: 0.08em;
748
- min-height: 18px;
749
- margin-bottom: 4px;
750
- text-align: center;
751
- }
752
-
753
- #login-connecting {
754
- display: flex;
755
- align-items: center;
756
- justify-content: center;
757
- gap: 10px;
758
- color: var(--text-dim);
759
- font-size: 12px;
760
- letter-spacing: 0.1em;
761
- padding-top: 8px;
762
- }
763
-
764
- .spinner {
765
- width: 16px;
766
- height: 16px;
767
- border: 2px solid var(--border2);
768
- border-top-color: var(--accent);
769
- border-radius: 50%;
770
- animation: spin 0.7s linear infinite;
771
- }
772
-
773
- @keyframes spin { to { transform: rotate(360deg); } }
774
-
775
- /* ─── Logout Button ──────────────────────────────────────────────��───────────── */
776
- .btn-logout {
777
- background: none;
778
- border: 1px solid var(--border2);
779
- color: var(--text-dimmer);
780
- font-family: var(--mono);
781
- font-size: 10px;
782
- letter-spacing: 0.1em;
783
- padding: 4px 10px;
784
- border-radius: var(--radius);
785
- cursor: pointer;
786
- margin-left: 14px;
787
- transition: border-color 0.2s, color 0.2s;
788
- }
789
- .btn-logout:hover { border-color: var(--red); color: var(--red); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
public/index.html DELETED
@@ -1,14 +0,0 @@
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 DELETED
@@ -1,307 +0,0 @@
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();