Almaatla commited on
Commit
5f5e5d5
·
verified ·
1 Parent(s): 489b626

Update app/static/index.html

Browse files
Files changed (1) hide show
  1. app/static/index.html +277 -136
app/static/index.html CHANGED
@@ -2,53 +2,118 @@
2
  <html lang="en">
3
  <head>
4
  <meta charset="UTF-8" />
 
5
  <title>Real-Time Display Board</title>
6
  <style>
7
- body { font-family: sans-serif; margin: 0; padding: 0; background: #111; color: #eee; }
8
- header { padding: 10px 16px; background: #222; display: flex; align-items: center; justify-content: space-between; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  #status { font-size: 0.9em; }
10
- #status span { padding: 4px 8px; border-radius: 4px; }
11
- .status-connected { background: #154; color: #8f8; }
12
- .status-connecting { background: #444; color: #ccc; }
13
- .status-disconnected { background: #511; color: #f88; }
14
- #controls { padding: 8px 16px; background: #181818; display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
 
 
 
 
 
 
 
 
 
 
15
  label { font-size: 0.9em; }
16
- input, select { padding: 4px 6px; border-radius: 4px; border: 1px solid #444; background: #000; color: #eee; }
17
- #messages { padding: 8px 16px 16px; height: calc(100vh - 120px); overflow-y: auto; }
18
- .message { border-bottom: 1px solid #333; padding: 6px 0; }
19
- .meta { font-size: 0.8em; color: #aaa; margin-bottom: 2px; display: flex; align-items: center; gap: 8px; }
20
- .poster { font-weight: bold; color: #8cf; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
  .timestamp { color: #ccc; }
22
- .category { font-size: 0.75em; padding: 2px 6px; border-radius: 4px; background: #333; color: #eee; }
23
  .content { white-space: pre-wrap; }
24
- #apiKeyInput { width: 260px; }
25
  </style>
26
  </head>
27
  <body>
28
- <header>
29
- <div>Real-Time Display Board</div>
30
- <div id="status"><span class="status-disconnected">Disconnected</span></div>
31
- </header>
32
-
33
- <div id="controls">
34
- <label>Reader API Key:
35
- <input id="apiKeyInput" type="password" placeholder="Enter READER_KEY and press Connect" />
36
- </label>
37
- <button id="connectBtn">Connect</button>
38
-
39
- <label>Poster filter:
40
- <select id="posterFilter">
41
- <option value="">All posters</option>
42
- </select>
43
- </label>
44
-
45
- <label>Keyword filter:
46
- <input id="keywordFilter" type="text" placeholder="Search poster, content, metadata" />
47
- </label>
48
-
49
- <label>
50
- <input type="checkbox" id="autoScrollToggle" checked /> Auto-scroll
51
- </label>
 
 
 
52
  </div>
53
 
54
  <div id="messages"></div>
@@ -63,130 +128,185 @@
63
  const autoScrollToggle = document.getElementById("autoScrollToggle");
64
 
65
  let socket = null;
 
 
66
  let reconnectDelay = 1000;
67
  const maxReconnectDelay = 30000;
68
- let manualDisconnect = false;
69
  let allMessages = [];
70
  let autoScrollEnabled = true;
71
 
 
 
 
 
72
  function setStatus(state, text) {
73
  const span = document.createElement("span");
74
  span.textContent = text;
75
- span.className = "";
76
- if (state === "connected") span.classList.add("status-connected");
77
- if (state === "connecting") span.classList.add("status-connecting");
78
- if (state === "disconnected") span.classList.add("status-disconnected");
79
  statusEl.innerHTML = "";
80
  statusEl.appendChild(span);
81
  }
82
 
83
  function humanTime(ts) {
84
  const d = new Date(ts);
 
 
85
  return d.toTimeString().split(" ")[0];
86
  }
87
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
88
  function updatePosterFilterOptions() {
89
- const posters = new Set(allMessages.map(m => m.poster_id));
90
  const current = posterFilterEl.value;
91
- posterFilterEl.innerHTML = '<option value="">All posters</option>';
 
 
 
 
 
 
 
92
  posters.forEach(p => {
93
  const opt = document.createElement("option");
94
  opt.value = p;
95
  opt.textContent = p;
96
- posterFilterEl.appendChild(opt);
97
  });
 
 
 
 
98
  if ([...posterFilterEl.options].some(o => o.value === current)) {
99
  posterFilterEl.value = current;
100
  }
101
  }
102
 
103
- function renderMessages() {
104
  const posterFilter = posterFilterEl.value.trim().toLowerCase();
105
  const keyword = keywordFilterEl.value.trim().toLowerCase();
106
 
107
- const filtered = allMessages.filter(m => {
108
- if (posterFilter && m.poster_id.toLowerCase() !== posterFilter) return false;
109
-
110
- if (!keyword) return true;
111
-
112
- const tokens = [];
113
- tokens.push(m.poster_id || "");
114
- tokens.push(m.content || "");
115
- if (m.category) tokens.push(m.category);
116
- if (m.metadata) {
117
- Object.values(m.metadata).forEach(v => {
118
- try {
119
- tokens.push(String(v));
120
- } catch {}
121
- });
122
  }
123
- const haystack = tokens.join(" ").toLowerCase();
124
- return haystack.includes(keyword);
125
- });
126
 
127
- const atBottom = Math.abs(messagesEl.scrollHeight - messagesEl.scrollTop - messagesEl.clientHeight) < 5;
 
 
128
 
129
- messagesEl.innerHTML = "";
130
- filtered.forEach(msg => {
131
- const div = document.createElement("div");
132
- div.className = "message";
 
 
133
 
134
- const meta = document.createElement("div");
135
- meta.className = "meta";
 
136
 
137
- const poster = document.createElement("span");
138
- poster.className = "poster";
139
- poster.textContent = msg.poster_id;
140
 
141
- const ts = document.createElement("span");
142
- ts.className = "timestamp";
143
- ts.textContent = humanTime(msg.timestamp);
 
 
 
144
 
145
- meta.appendChild(poster);
146
- meta.appendChild(ts);
 
147
 
148
- if (msg.category) {
149
- const cat = document.createElement("span");
150
- cat.className = "category";
151
- cat.textContent = msg.category;
152
- meta.appendChild(cat);
153
- }
154
 
155
- const content = document.createElement("div");
156
- content.className = "content";
157
- content.textContent = msg.content;
158
 
159
- div.appendChild(meta);
160
- div.appendChild(content);
161
- messagesEl.appendChild(div);
162
- });
 
 
 
 
163
 
164
- if (autoScrollEnabled && atBottom) {
165
  messagesEl.scrollTop = messagesEl.scrollHeight;
 
166
  }
167
  }
168
 
169
- messagesEl.addEventListener("scroll", () => {
170
- const atBottom = Math.abs(messagesEl.scrollHeight - messagesEl.scrollTop - messagesEl.clientHeight) < 5;
171
- if (!atBottom) {
172
- autoScrollEnabled = false;
173
- autoScrollToggle.checked = false;
174
- } else {
175
- if (autoScrollToggle.checked) {
176
- autoScrollEnabled = true;
177
- }
178
  }
179
- });
180
 
181
- autoScrollToggle.addEventListener("change", () => {
182
- autoScrollEnabled = autoScrollToggle.checked;
183
- if (autoScrollEnabled) {
 
184
  messagesEl.scrollTop = messagesEl.scrollHeight;
 
185
  }
186
- });
187
-
188
- posterFilterEl.addEventListener("change", renderMessages);
189
- keywordFilterEl.addEventListener("input", renderMessages);
190
 
191
  function connectSocket() {
192
  const key = apiKeyInput.value.trim();
@@ -197,61 +317,82 @@
197
 
198
  const loc = window.location;
199
  const protocol = loc.protocol === "https:" ? "wss:" : "ws:";
200
- const wsUrl = `${protocol}//${loc.host}/ws?api_key=${encodeURIComponent(key)}`;
 
201
  setStatus("connecting", "Connecting...");
202
  socket = new WebSocket(wsUrl);
203
 
204
  socket.onopen = () => {
205
  setStatus("connected", "Connected");
206
  reconnectDelay = 1000;
 
207
  };
208
 
209
  socket.onmessage = (event) => {
210
- try {
211
- const data = JSON.parse(event.data);
212
- if (data.type === "history") {
213
- allMessages = data.messages || [];
214
- allMessages.sort((a,b) => new Date(a.timestamp) - new Date(b.timestamp));
215
- updatePosterFilterOptions();
216
- renderMessages();
217
- } else if (data.type === "new_post") {
218
- if (data.message) {
219
- allMessages.push(data.message);
220
- allMessages.sort((a,b) => new Date(a.timestamp) - new Date(b.timestamp));
221
- updatePosterFilterOptions();
222
- renderMessages();
223
- }
224
- } else if (data.type === "error") {
225
- alert(data.error || "Error from server");
226
  }
227
- } catch (e) {
228
- console.error("Error handling message", e);
229
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
230
  };
231
 
232
  socket.onclose = () => {
233
  socket = null;
 
234
  setStatus("disconnected", "Disconnected");
 
235
  if (!manualDisconnect) {
 
236
  setTimeout(() => connectSocket(), reconnectDelay);
237
  reconnectDelay = Math.min(reconnectDelay * 2, maxReconnectDelay);
238
- setStatus("connecting", "Reconnecting...");
239
  }
240
  };
241
 
242
- socket.onerror = (e) => {
243
- console.error("WebSocket error", e);
244
  };
245
  }
246
 
247
  connectBtn.addEventListener("click", () => {
248
- manualDisconnect = false;
249
  if (socket) {
 
250
  socket.close();
251
- } else {
252
- connectSocket();
 
 
253
  }
 
 
254
  });
 
 
 
255
  </script>
256
  </body>
257
  </html>
 
2
  <html lang="en">
3
  <head>
4
  <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width,initial-scale=1" />
6
  <title>Real-Time Display Board</title>
7
  <style>
8
+ :root {
9
+ --bg: #111;
10
+ --panel: #181818;
11
+ --header: #222;
12
+ --border: #333;
13
+ --muted: #aaa;
14
+ --text: #eee;
15
+ --ok-bg: #154;
16
+ --ok-fg: #8f8;
17
+ --warn-bg: #444;
18
+ --warn-fg: #ccc;
19
+ --bad-bg: #511;
20
+ --bad-fg: #f88;
21
+ --accent: #8cf;
22
+ }
23
+
24
+ * { box-sizing: border-box; }
25
+ body { margin: 0; font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; background: var(--bg); color: var(--text); }
26
+
27
+ /* Keep top visible */
28
+ .topbar {
29
+ position: sticky; /* stays visible while page scrolls */
30
+ top: 0;
31
+ z-index: 10;
32
+ background: var(--header);
33
+ border-bottom: 1px solid var(--border);
34
+ }
35
+
36
+ header {
37
+ padding: 10px 16px;
38
+ display: flex;
39
+ align-items: center;
40
+ justify-content: space-between;
41
+ gap: 12px;
42
+ }
43
+
44
  #status { font-size: 0.9em; }
45
+ #status span { padding: 4px 8px; border-radius: 4px; display: inline-block; }
46
+ .status-connected { background: var(--ok-bg); color: var(--ok-fg); }
47
+ .status-connecting { background: var(--warn-bg); color: var(--warn-fg); }
48
+ .status-disconnected { background: var(--bad-bg); color: var(--bad-fg); }
49
+
50
+ #controls {
51
+ padding: 8px 16px;
52
+ background: var(--panel);
53
+ display: flex;
54
+ gap: 10px;
55
+ align-items: center;
56
+ flex-wrap: wrap;
57
+ border-top: 1px solid var(--border);
58
+ }
59
+
60
  label { font-size: 0.9em; }
61
+ input, select, button {
62
+ padding: 6px 8px;
63
+ border-radius: 6px;
64
+ border: 1px solid #444;
65
+ background: #000;
66
+ color: var(--text);
67
+ }
68
+ button { cursor: pointer; background: #111; }
69
+ button:hover { background: #151515; }
70
+
71
+ #apiKeyInput { width: 280px; }
72
+
73
+ /* Messages area */
74
+ #messages {
75
+ padding: 8px 16px 16px;
76
+ height: calc(100vh - 114px); /* approximate height of sticky topbar */
77
+ overflow-y: auto;
78
+ overscroll-behavior: contain;
79
+ }
80
+
81
+ .message { border-bottom: 1px solid var(--border); padding: 8px 0; }
82
+ .meta { font-size: 0.82em; color: var(--muted); margin-bottom: 3px; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
83
+ .poster { font-weight: 700; color: var(--accent); }
84
  .timestamp { color: #ccc; }
85
+ .category { font-size: 0.78em; padding: 2px 7px; border-radius: 999px; background: #333; color: var(--text); }
86
  .content { white-space: pre-wrap; }
 
87
  </style>
88
  </head>
89
  <body>
90
+ <div class="topbar">
91
+ <header>
92
+ <div>Real-Time Display Board</div>
93
+ <div id="status"><span class="status-disconnected">Disconnected</span></div>
94
+ </header>
95
+
96
+ <div id="controls">
97
+ <label>Reader API Key:
98
+ <input id="apiKeyInput" type="password" placeholder="Enter READER_KEY" autocomplete="off" />
99
+ </label>
100
+ <button id="connectBtn">Connect</button>
101
+
102
+ <label>Poster filter:
103
+ <select id="posterFilter">
104
+ <option value="">All posters</option>
105
+ </select>
106
+ </label>
107
+
108
+ <label>Keyword filter:
109
+ <input id="keywordFilter" type="text" placeholder="Search poster, content, metadata" />
110
+ </label>
111
+
112
+ <label>
113
+ <input type="checkbox" id="autoScrollToggle" checked />
114
+ Auto-scroll
115
+ </label>
116
+ </div>
117
  </div>
118
 
119
  <div id="messages"></div>
 
128
  const autoScrollToggle = document.getElementById("autoScrollToggle");
129
 
130
  let socket = null;
131
+ let manualDisconnect = false;
132
+
133
  let reconnectDelay = 1000;
134
  const maxReconnectDelay = 30000;
135
+
136
  let allMessages = [];
137
  let autoScrollEnabled = true;
138
 
139
+ // Track whether user is at bottom without doing heavy work on every scroll tick
140
+ let userAtBottom = true;
141
+ let scrollTicking = false;
142
+
143
  function setStatus(state, text) {
144
  const span = document.createElement("span");
145
  span.textContent = text;
146
+ span.className =
147
+ state === "connected" ? "status-connected" :
148
+ state === "connecting" ? "status-connecting" :
149
+ "status-disconnected";
150
  statusEl.innerHTML = "";
151
  statusEl.appendChild(span);
152
  }
153
 
154
  function humanTime(ts) {
155
  const d = new Date(ts);
156
+ // If ts is missing/invalid, show placeholder rather than crashing
157
+ if (Number.isNaN(d.getTime())) return "--:--:--";
158
  return d.toTimeString().split(" ")[0];
159
  }
160
 
161
+ function computeAtBottom() {
162
+ // Only read layout values when scheduled (rAF)
163
+ return Math.abs(messagesEl.scrollHeight - messagesEl.scrollTop - messagesEl.clientHeight) < 5;
164
+ }
165
+
166
+ messagesEl.addEventListener("scroll", () => {
167
+ if (scrollTicking) return;
168
+ scrollTicking = true;
169
+
170
+ requestAnimationFrame(() => {
171
+ const atBottom = computeAtBottom();
172
+ userAtBottom = atBottom;
173
+
174
+ if (!atBottom) {
175
+ autoScrollEnabled = false;
176
+ autoScrollToggle.checked = false;
177
+ } else {
178
+ if (autoScrollToggle.checked) autoScrollEnabled = true;
179
+ }
180
+
181
+ scrollTicking = false;
182
+ });
183
+ }, { passive: true }); // helps scroll performance by letting browser scroll immediately [web:48]
184
+
185
+ autoScrollToggle.addEventListener("change", () => {
186
+ autoScrollEnabled = autoScrollToggle.checked;
187
+ if (autoScrollEnabled) {
188
+ messagesEl.scrollTop = messagesEl.scrollHeight;
189
+ userAtBottom = true;
190
+ }
191
+ });
192
+
193
+ posterFilterEl.addEventListener("change", () => renderMessages());
194
+ keywordFilterEl.addEventListener("input", () => renderMessages());
195
+
196
  function updatePosterFilterOptions() {
197
+ const posters = new Set(allMessages.map(m => m.poster_id).filter(Boolean));
198
  const current = posterFilterEl.value;
199
+
200
+ // Build options efficiently
201
+ const frag = document.createDocumentFragment();
202
+ const optAll = document.createElement("option");
203
+ optAll.value = "";
204
+ optAll.textContent = "All posters";
205
+ frag.appendChild(optAll);
206
+
207
  posters.forEach(p => {
208
  const opt = document.createElement("option");
209
  opt.value = p;
210
  opt.textContent = p;
211
+ frag.appendChild(opt);
212
  });
213
+
214
+ posterFilterEl.innerHTML = "";
215
+ posterFilterEl.appendChild(frag);
216
+
217
  if ([...posterFilterEl.options].some(o => o.value === current)) {
218
  posterFilterEl.value = current;
219
  }
220
  }
221
 
222
+ function messageMatchesFilters(m) {
223
  const posterFilter = posterFilterEl.value.trim().toLowerCase();
224
  const keyword = keywordFilterEl.value.trim().toLowerCase();
225
 
226
+ if (posterFilter && (m.poster_id || "").toLowerCase() !== posterFilter) return false;
227
+ if (!keyword) return true;
228
+
229
+ const tokens = [];
230
+ tokens.push(m.poster_id || "");
231
+ tokens.push(m.content || "");
232
+ if (m.category) tokens.push(m.category);
233
+ if (m.metadata && typeof m.metadata === "object") {
234
+ for (const v of Object.values(m.metadata)) {
235
+ try { tokens.push(String(v)); } catch {}
 
 
 
 
 
236
  }
237
+ }
238
+ return tokens.join(" ").toLowerCase().includes(keyword);
239
+ }
240
 
241
+ function buildMessageNode(msg) {
242
+ const div = document.createElement("div");
243
+ div.className = "message";
244
 
245
+ const meta = document.createElement("div");
246
+ meta.className = "meta";
247
+
248
+ const poster = document.createElement("span");
249
+ poster.className = "poster";
250
+ poster.textContent = msg.poster_id || "(unknown)";
251
 
252
+ const ts = document.createElement("span");
253
+ ts.className = "timestamp";
254
+ ts.textContent = humanTime(msg.timestamp);
255
 
256
+ meta.appendChild(poster);
257
+ meta.appendChild(ts);
 
258
 
259
+ if (msg.category) {
260
+ const cat = document.createElement("span");
261
+ cat.className = "category";
262
+ cat.textContent = msg.category;
263
+ meta.appendChild(cat);
264
+ }
265
 
266
+ const content = document.createElement("div");
267
+ content.className = "content";
268
+ content.textContent = msg.content || "";
269
 
270
+ div.appendChild(meta);
271
+ div.appendChild(content);
272
+ return div;
273
+ }
 
 
274
 
275
+ function renderMessages() {
276
+ // Keep the current scroll position; only autoscroll if user is already at bottom + enabled
277
+ const shouldStickToBottom = autoScrollEnabled && userAtBottom;
278
 
279
+ const frag = document.createDocumentFragment();
280
+ for (const m of allMessages) {
281
+ if (!messageMatchesFilters(m)) continue;
282
+ frag.appendChild(buildMessageNode(m));
283
+ }
284
+
285
+ messagesEl.innerHTML = "";
286
+ messagesEl.appendChild(frag);
287
 
288
+ if (shouldStickToBottom) {
289
  messagesEl.scrollTop = messagesEl.scrollHeight;
290
+ userAtBottom = true;
291
  }
292
  }
293
 
294
+ function appendMessageIfVisible(msg) {
295
+ // Avoid full rerender for every new message: append if it matches current filters
296
+ const shouldStickToBottom = autoScrollEnabled && userAtBottom;
297
+
298
+ if (messageMatchesFilters(msg)) {
299
+ messagesEl.appendChild(buildMessageNode(msg));
 
 
 
300
  }
 
301
 
302
+ // Keep dropdown up to date (cheap, small list)
303
+ updatePosterFilterOptions();
304
+
305
+ if (shouldStickToBottom) {
306
  messagesEl.scrollTop = messagesEl.scrollHeight;
307
+ userAtBottom = true;
308
  }
309
+ }
 
 
 
310
 
311
  function connectSocket() {
312
  const key = apiKeyInput.value.trim();
 
317
 
318
  const loc = window.location;
319
  const protocol = loc.protocol === "https:" ? "wss:" : "ws:";
320
+ const wsUrl = `${protocol}//${loc.host}/ws?api_key=${encodeURIComponent(key)}`;
321
+
322
  setStatus("connecting", "Connecting...");
323
  socket = new WebSocket(wsUrl);
324
 
325
  socket.onopen = () => {
326
  setStatus("connected", "Connected");
327
  reconnectDelay = 1000;
328
+ connectBtn.textContent = "Disconnect";
329
  };
330
 
331
  socket.onmessage = (event) => {
332
+ let data;
333
+ try { data = JSON.parse(event.data); } catch { return; }
334
+
335
+ if (data.type === "history") {
336
+ allMessages = Array.isArray(data.messages) ? data.messages : [];
337
+ allMessages.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
338
+ updatePosterFilterOptions();
339
+ // On fresh history load, always go to bottom if auto-scroll is enabled
340
+ if (autoScrollToggle.checked) {
341
+ autoScrollEnabled = true;
342
+ userAtBottom = true;
 
 
 
 
 
343
  }
344
+ renderMessages();
345
+ return;
346
  }
347
+
348
+ if (data.type === "new_post" && data.message) {
349
+ allMessages.push(data.message);
350
+ // Keep only last 50 on client too (server already does, but this keeps UI stable)
351
+ if (allMessages.length > 50) allMessages = allMessages.slice(allMessages.length - 50);
352
+ appendMessageIfVisible(data.message);
353
+ return;
354
+ }
355
+
356
+ if (data.type === "error") {
357
+ alert(data.error || "Error from server");
358
+ return;
359
+ }
360
+
361
+ // Ignore ping
362
  };
363
 
364
  socket.onclose = () => {
365
  socket = null;
366
+ connectBtn.textContent = "Connect";
367
  setStatus("disconnected", "Disconnected");
368
+
369
  if (!manualDisconnect) {
370
+ setStatus("connecting", "Reconnecting...");
371
  setTimeout(() => connectSocket(), reconnectDelay);
372
  reconnectDelay = Math.min(reconnectDelay * 2, maxReconnectDelay);
 
373
  }
374
  };
375
 
376
+ socket.onerror = () => {
377
+ // onclose will handle UI
378
  };
379
  }
380
 
381
  connectBtn.addEventListener("click", () => {
 
382
  if (socket) {
383
+ manualDisconnect = true;
384
  socket.close();
385
+ socket = null;
386
+ setStatus("disconnected", "Disconnected");
387
+ connectBtn.textContent = "Connect";
388
+ return;
389
  }
390
+ manualDisconnect = false;
391
+ connectSocket();
392
  });
393
+
394
+ // Initial UI state
395
+ setStatus("disconnected", "Disconnected");
396
  </script>
397
  </body>
398
  </html>