Skip to content

Commit 36e0669

Browse files
authored
Spicy new things again (#10)
* mega commit * mega commit v2 * Added smingo message * Add moving messages * Add user fetching * added mentions * Small design fixes * Fixed so they go brrrr at the top * Added avatar * Added avatar * Added ping tooltip * Added file upload * Added link previews * Refactor into multiple smaller files
1 parent 98ce763 commit 36e0669

File tree

14 files changed

+2760
-1346
lines changed

14 files changed

+2760
-1346
lines changed

components/AdminPage.tsx

Lines changed: 1 addition & 367 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)