@@ -44,373 +44,7 @@ const AdminPage: FC = () => (
4444 </ section >
4545 </ div >
4646 </ main >
47- < script
48- type = "module"
49- dangerouslySetInnerHTML = { {
50- __html : `(() => {
51- const statusEl = document.getElementById("adminStatus");
52- const listEl = document.getElementById("adminUsers");
53- const detailEl = document.getElementById("adminDetail");
54- const searchInput = document.getElementById("adminSearch");
55- const sortSelect = document.getElementById("adminSort");
56- const socketUrl = (window.location.protocol === "https:" ? "wss://" : "ws://") + window.location.host + "/ws?role=admin";
57- let socket = null;
58- let reconnectTimer = null;
59-
60- const state = {
61- players: [],
62- filtered: [],
63- selectedId: null,
64- searchTerm: "",
65- sortMode: sortSelect ? sortSelect.value : "name",
66- };
67-
68- let connectionState = "Connecting…";
69-
70- function setConnectionState(next) {
71- connectionState = next;
72- updateStatus();
73- }
74-
75- function setBusy(isBusy) {
76- if (!detailEl) return;
77- detailEl.setAttribute("aria-busy", isBusy ? "true" : "false");
78- }
79-
80- function updateStatus() {
81- if (!statusEl) return;
82- if (!state.players.length) {
83- statusEl.textContent = connectionState;
84- return;
85- }
86- const visible = state.filtered.length;
87- const total = state.players.length;
88- const label = visible === 1 ? "player" : "players";
89- let sortLabel = "";
90- switch (state.sortMode) {
91- case "bingo":
92- sortLabel = "sorted by most bingos";
93- break;
94- case "updated":
95- sortLabel = "sorted by last update";
96- break;
97- default:
98- sortLabel = "sorted alphabetically";
99- }
100- const filterText = visible === total ? \`\${visible} \${label}\` : \`\${visible} of \${total} \${label}\`;
101- statusEl.textContent = \`\${connectionState} • \${filterText} • \${sortLabel}\`;
102- }
103-
104- function formatTime(value) {
105- if (typeof value !== "number" || !Number.isFinite(value)) return "n/a";
106- return new Date(value).toLocaleTimeString();
107- }
108-
109- function formatRelative(value) {
110- if (typeof value !== "number" || !Number.isFinite(value)) return "";
111- const delta = Date.now() - value;
112- if (delta < 0) return "just now";
113- const seconds = Math.floor(delta / 1000);
114- if (seconds < 5) return "just now";
115- if (seconds < 60) return \`\${seconds}s ago\`;
116- const minutes = Math.floor(seconds / 60);
117- if (minutes < 60) return \`\${minutes}m ago\`;
118- const hours = Math.floor(minutes / 60);
119- if (hours < 24) return \`\${hours}h ago\`;
120- const days = Math.floor(hours / 24);
121- return \`\${days}d ago\`;
122- }
123-
124- function normalizePlayers(list) {
125- if (!Array.isArray(list)) return [];
126- return list
127- .map((player) => {
128- if (!player || typeof player !== "object") return null;
129- const board = Array.isArray(player.board)
130- ? player.board.filter((item) => typeof item === "string")
131- : [];
132- const clicked = Array.isArray(player.clicked)
133- ? player.clicked
134- .map((n) => (typeof n === "number" ? n : Number(n)))
135- .filter((n) => Number.isInteger(n) && n >= 0 && n < board.length)
136- : [];
137- const id = typeof player.id === "string" ? player.id : String(player.id ?? "");
138- return {
139- id,
140- userId: typeof player.userId === "string" && player.userId ? player.userId : "Unknown user",
141- board,
142- clicked,
143- connectedAt: typeof player.connectedAt === "number" ? player.connectedAt : 0,
144- lastUpdate: typeof player.lastUpdate === "number" ? player.lastUpdate : 0,
145- bingoCount: typeof player.bingoCount === "number" ? player.bingoCount : 0,
146- };
147- })
148- .filter(Boolean);
149- }
150-
151- function applyFilters() {
152- const term = state.searchTerm.trim().toLowerCase();
153- let filtered = state.players.slice();
154- if (term) {
155- filtered = filtered.filter((player) => player.userId.toLowerCase().includes(term));
156- }
157-
158- switch (state.sortMode) {
159- case "bingo":
160- filtered.sort((a, b) => {
161- if (b.bingoCount !== a.bingoCount) return b.bingoCount - a.bingoCount;
162- if (b.lastUpdate !== a.lastUpdate) return b.lastUpdate - a.lastUpdate;
163- return a.userId.localeCompare(b.userId);
164- });
165- break;
166- case "updated":
167- filtered.sort((a, b) => {
168- if (b.lastUpdate !== a.lastUpdate) return b.lastUpdate - a.lastUpdate;
169- return a.userId.localeCompare(b.userId);
170- });
171- break;
172- default:
173- filtered.sort((a, b) => {
174- const byName = a.userId.localeCompare(b.userId);
175- if (byName !== 0) return byName;
176- return a.id.localeCompare(b.id);
177- });
178- break;
179- }
180-
181- state.filtered = filtered;
182- if (!state.selectedId || !filtered.some((player) => player.id === state.selectedId)) {
183- state.selectedId = filtered.length ? filtered[0].id : null;
184- }
185-
186- renderList();
187- renderDetail();
188- updateStatus();
189- }
190-
191- function renderList() {
192- if (!listEl) return;
193- listEl.innerHTML = "";
194-
195- if (!state.filtered.length) {
196- listEl.removeAttribute("aria-activedescendant");
197- const empty = document.createElement("p");
198- empty.className = "admin-empty";
199- empty.textContent = state.players.length === 0
200- ? "No active players."
201- : "No players match your search.";
202- listEl.appendChild(empty);
203- return;
204- }
205-
206- const fragment = document.createDocumentFragment();
207- for (const player of state.filtered) {
208- const btn = document.createElement("button");
209- btn.type = "button";
210- btn.dataset.id = player.id;
211- btn.id = player.id;
212- btn.className = "admin-user" + (player.id === state.selectedId ? " selected" : "");
213- btn.setAttribute("role", "option");
214-
215- const name = document.createElement("span");
216- name.className = "admin-user__name";
217- name.textContent = player.userId;
218-
219- const bingo = document.createElement("span");
220- bingo.className = "admin-user__bingo";
221- bingo.textContent = \`\${player.bingoCount} bingo\${player.bingoCount === 1 ? "" : "s"}\`;
222-
223- const updated = document.createElement("span");
224- updated.className = "admin-user__meta";
225- updated.textContent = player.lastUpdate
226- ? \`Updated \${formatRelative(player.lastUpdate)}\`
227- : "No updates yet";
228-
229- btn.append(name, bingo, updated);
230- fragment.appendChild(btn);
231- }
232-
233- listEl.appendChild(fragment);
234- if (state.selectedId) {
235- listEl.setAttribute("aria-activedescendant", state.selectedId);
236- } else {
237- listEl.removeAttribute("aria-activedescendant");
238- }
239- }
240-
241- function renderDetail() {
242- if (!detailEl) return;
243- detailEl.innerHTML = "";
244-
245- if (!state.selectedId) {
246- const placeholder = document.createElement("p");
247- placeholder.className = "admin-placeholder";
248- placeholder.textContent = state.players.length
249- ? "Select a player to view their board."
250- : "Waiting for active players…";
251- detailEl.appendChild(placeholder);
252- return;
253- }
254-
255- const player = state.players.find((item) => item.id === state.selectedId);
256- if (!player) {
257- const placeholder = document.createElement("p");
258- placeholder.className = "admin-placeholder";
259- placeholder.textContent = "Player not found.";
260- detailEl.appendChild(placeholder);
261- return;
262- }
263-
264- const card = document.createElement("article");
265- card.className = "admin-card";
266-
267- const header = document.createElement("header");
268- header.className = "admin-card__header";
269-
270- const titleRow = document.createElement("div");
271- titleRow.className = "admin-card__title";
272-
273- const title = document.createElement("h2");
274- title.textContent = player.userId;
275-
276- const badge = document.createElement("span");
277- badge.className = "admin-bingo";
278- badge.textContent = \`\${player.bingoCount} bingo\${player.bingoCount === 1 ? "" : "s"}\`;
279-
280- titleRow.append(title, badge);
281-
282- const meta = document.createElement("p");
283- meta.className = "admin-card__meta";
284- meta.textContent = \`Connected \${formatTime(player.connectedAt)} • Updated \${formatTime(player.lastUpdate)}\`;
285-
286- const metaSecondary = document.createElement("p");
287- metaSecondary.className = "admin-card__meta--secondary";
288- metaSecondary.textContent = player.lastUpdate ? \`(\${formatRelative(player.lastUpdate)})\` : "";
289-
290- header.append(titleRow, meta);
291- if (player.lastUpdate) {
292- header.appendChild(metaSecondary);
293- }
294- card.appendChild(header);
295-
296- if (!player.board.length) {
297- const empty = document.createElement("p");
298- empty.className = "admin-placeholder";
299- empty.textContent = "No board data available.";
300- card.appendChild(empty);
301- } else {
302- const grid = document.createElement("div");
303- grid.className = "admin-grid";
304- const clickedSet = new Set(player.clicked);
305- player.board.forEach((cellText, idx) => {
306- const cell = document.createElement("div");
307- cell.className = "admin-cell" + (clickedSet.has(idx) ? " checked" : "");
308- cell.textContent = String(cellText);
309- grid.appendChild(cell);
310- });
311- card.appendChild(grid);
312- }
313-
314- detailEl.appendChild(card);
315- }
316-
317- function handleMessage(event) {
318- try {
319- const data = JSON.parse(event.data);
320- if (data && data.type === "active") {
321- state.players = normalizePlayers(data.players);
322- applyFilters();
323- setBusy(false);
324- }
325- } catch (_err) {
326- // ignore malformed payloads
327- }
328- }
329-
330- function scheduleReconnect() {
331- if (reconnectTimer !== null) return;
332- setConnectionState("Disconnected • Reconnecting soon…");
333- if (listEl) {
334- listEl.innerHTML = "";
335- listEl.removeAttribute("aria-activedescendant");
336- }
337- state.players = [];
338- state.filtered = [];
339- state.selectedId = null;
340- if (detailEl) {
341- detailEl.innerHTML = "";
342- const placeholder = document.createElement("p");
343- placeholder.className = "admin-placeholder";
344- placeholder.textContent = "Reconnecting…";
345- detailEl.appendChild(placeholder);
346- }
347- setBusy(true);
348- reconnectTimer = window.setTimeout(() => {
349- reconnectTimer = null;
350- connect();
351- }, 2000);
352- }
353-
354- function connect() {
355- setBusy(true);
356- setConnectionState("Connecting…");
357- socket = new WebSocket(socketUrl);
358- socket.addEventListener("open", () => {
359- setBusy(true);
360- setConnectionState("Connected");
361- updateStatus();
362- });
363- socket.addEventListener("message", handleMessage);
364- socket.addEventListener("close", () => {
365- socket = null;
366- scheduleReconnect();
367- });
368- socket.addEventListener("error", () => {
369- if (socket) {
370- try {
371- socket.close();
372- } catch (_) {
373- // ignore
374- }
375- }
376- });
377- }
378-
379- if (listEl) {
380- listEl.addEventListener("click", (event) => {
381- const target = event.target instanceof Element ? event.target.closest("button.admin-user") : null;
382- if (!target || !target.dataset.id) return;
383- if (state.selectedId === target.dataset.id) return;
384- state.selectedId = target.dataset.id;
385- renderList();
386- renderDetail();
387- });
388- }
389-
390- if (searchInput) {
391- searchInput.addEventListener("input", () => {
392- state.searchTerm = searchInput.value ?? "";
393- applyFilters();
394- });
395- }
396-
397- if (sortSelect) {
398- sortSelect.addEventListener("change", () => {
399- state.sortMode = sortSelect.value;
400- applyFilters();
401- });
402- }
403-
404- connect();
405-
406- window.addEventListener("beforeunload", () => {
407- if (socket && socket.readyState === WebSocket.OPEN) {
408- socket.close();
409- }
410- });
411- })();` ,
412- } }
413- />
47+ < script type = "module" src = "/assets/admin-app.js" > </ script >
41448 </ >
41549) ;
41650
0 commit comments