From 74133ec589a6ca35c70689a57a1493aa3fd939f0 Mon Sep 17 00:00:00 2001 From: zwitschi Date: Wed, 5 Aug 2026 11:29:11 +0200 Subject: [PATCH] feat: add chat log admin management and refactor admin panel to lazy-load tab content --- backend/chat/src/main.go | 184 +++++++++++++ frontend/admin/analytics.html | 45 +++ frontend/admin/chat-log.html | 46 ++++ frontend/admin/dashboard.html | 21 ++ frontend/admin/index.html | 309 +-------------------- frontend/admin/moderation.html | 58 ++++ frontend/admin/playlist.html | 16 ++ frontend/admin/schedule.html | 26 ++ frontend/admin/system.html | 54 ++++ frontend/admin/upload.html | 41 +++ frontend/admin/users.html | 23 ++ frontend/src/admin.ts | 485 +++++++++++++++++++++++++++------ 12 files changed, 921 insertions(+), 387 deletions(-) create mode 100644 frontend/admin/analytics.html create mode 100644 frontend/admin/chat-log.html create mode 100644 frontend/admin/dashboard.html create mode 100644 frontend/admin/moderation.html create mode 100644 frontend/admin/playlist.html create mode 100644 frontend/admin/schedule.html create mode 100644 frontend/admin/system.html create mode 100644 frontend/admin/upload.html create mode 100644 frontend/admin/users.html diff --git a/backend/chat/src/main.go b/backend/chat/src/main.go index 8a46f19..866b65f 100644 --- a/backend/chat/src/main.go +++ b/backend/chat/src/main.go @@ -156,6 +156,24 @@ type AdminUsersResponse struct { GeneratedAt string `json:"generatedAt"` } +type ChatLogEntry struct { + ID uint `json:"id"` + Room string `json:"room"` + Username string `json:"username"` + Text string `json:"text"` + Timestamp time.Time `json:"timestamp"` + Type string `json:"type"` +} + +type ChatLogListResponse struct { + Entries []ChatLogEntry `json:"entries"` + Total int64 `json:"total"` +} + +type ChatLogBulkDeleteRequest struct { + IDs []uint `json:"ids"` +} + type ScheduleEntry struct { Index int `json:"index"` Path string `json:"path"` @@ -1934,6 +1952,162 @@ func handleModerationCommand(msg Message, conn *websocket.Conn) { }) } +// handleAdminChatLog lists chat log entries with optional filtering and pagination +func handleAdminChatLog(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + query := r.URL.Query() + room := strings.TrimSpace(query.Get("room")) + + limit := 50 + if rawLimit := strings.TrimSpace(query.Get("limit")); rawLimit != "" { + parsedLimit, err := strconv.Atoi(rawLimit) + if err != nil || parsedLimit <= 0 { + http.Error(w, "invalid limit", http.StatusBadRequest) + return + } + if parsedLimit > 200 { + parsedLimit = 200 + } + limit = parsedLimit + } + + dbQuery := db.Model(&Message{}) + + if room != "" { + dbQuery = dbQuery.Where("room = ?", room) + } + + if beforeRaw := strings.TrimSpace(query.Get("before")); beforeRaw != "" { + beforeTime, err := time.Parse(time.RFC3339, beforeRaw) + if err != nil { + http.Error(w, "invalid before timestamp, use RFC3339 format", http.StatusBadRequest) + return + } + dbQuery = dbQuery.Where("timestamp < ?", beforeTime) + } + + if username := strings.TrimSpace(query.Get("username")); username != "" { + dbQuery = dbQuery.Where("username LIKE ?", "%"+username+"%") + } + + if text := strings.TrimSpace(query.Get("text")); text != "" { + dbQuery = dbQuery.Where("text LIKE ?", "%"+text+"%") + } + + var total int64 + if err := dbQuery.Count(&total).Error; err != nil { + http.Error(w, fmt.Sprintf("failed to count chat log entries: %v", err), http.StatusInternalServerError) + return + } + + var messages []Message + if err := dbQuery.Order("timestamp desc").Limit(limit).Find(&messages).Error; err != nil { + http.Error(w, fmt.Sprintf("failed to load chat log entries: %v", err), http.StatusInternalServerError) + return + } + + entries := make([]ChatLogEntry, len(messages)) + for i, msg := range messages { + entries[i] = ChatLogEntry{ + ID: msg.ID, + Room: msg.Room, + Username: msg.Username, + Text: msg.Text, + Timestamp: msg.Timestamp, + Type: msg.Type, + } + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(ChatLogListResponse{Entries: entries, Total: total}) +} + +// handleAdminChatLogByID returns or deletes a single chat log entry +func handleAdminChatLogByID(w http.ResponseWriter, r *http.Request) { + idPart := strings.TrimPrefix(r.URL.Path, "/api/admin/chat-log/") + idPart = strings.TrimSpace(idPart) + if idPart == "" || strings.Contains(idPart, "/") { + http.Error(w, "invalid chat log id", http.StatusBadRequest) + return + } + + id, err := strconv.ParseUint(idPart, 10, 64) + if err != nil || id == 0 { + http.Error(w, "invalid chat log id", http.StatusBadRequest) + return + } + + switch r.Method { + case http.MethodGet: + var msg Message + if err := db.First(&msg, id).Error; err != nil { + http.Error(w, "chat log entry not found", http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(ChatLogEntry{ + ID: msg.ID, + Room: msg.Room, + Username: msg.Username, + Text: msg.Text, + Timestamp: msg.Timestamp, + Type: msg.Type, + }) + case http.MethodDelete: + result := db.Delete(&Message{}, id) + if result.Error != nil { + http.Error(w, fmt.Sprintf("failed to delete chat log entry: %v", result.Error), http.StatusInternalServerError) + return + } + if result.RowsAffected == 0 { + http.Error(w, "chat log entry not found", http.StatusNotFound) + return + } + + w.WriteHeader(http.StatusNoContent) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +// handleAdminChatLogBulk deletes multiple chat log entries by ID +func handleAdminChatLogBulk(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + var payload ChatLogBulkDeleteRequest + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + http.Error(w, fmt.Sprintf("invalid request body: %v", err), http.StatusBadRequest) + return + } + + if len(payload.IDs) == 0 { + http.Error(w, "ids array is required", http.StatusBadRequest) + return + } + + if len(payload.IDs) > 500 { + http.Error(w, "maximum 500 ids per bulk request", http.StatusBadRequest) + return + } + + result := db.Delete(&Message{}, payload.IDs) + if result.Error != nil { + http.Error(w, fmt.Sprintf("failed to bulk delete chat log entries: %v", result.Error), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{"deleted": result.RowsAffected}) +} + func main() { initRedis() initDB() @@ -1954,6 +2128,9 @@ func main() { http.HandleFunc("/api/admin/media/uploads", requireAdminAuth(handleAdminMediaUploads)) http.HandleFunc("/api/admin/media/uploads/", requireAdminAuth(handleAdminMediaUploads)) http.HandleFunc("/api/admin/media/", requireAdminAuth(handleAdminMediaByID)) + http.HandleFunc("/api/admin/chat-log/bulk", requireAdminAuth(handleAdminChatLogBulk)) + http.HandleFunc("/api/admin/chat-log/", requireAdminAuth(handleAdminChatLogByID)) + http.HandleFunc("/api/admin/chat-log", requireAdminAuth(handleAdminChatLog)) http.HandleFunc("/metrics", handleMetrics) http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") @@ -1961,6 +2138,13 @@ func main() { fmt.Fprint(w, `{"status":"healthy","service":"chat","timestamp":"`+time.Now().UTC().Format(time.RFC3339)+`"}`) }) + // Catch-all: return 404 JSON for all unhandled paths + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + fmt.Fprint(w, `{"error":"not found"}`) + }) + port := os.Getenv("CHAT_PORT") if port == "" { port = "3000" diff --git a/frontend/admin/analytics.html b/frontend/admin/analytics.html new file mode 100644 index 0000000..2e7804d --- /dev/null +++ b/frontend/admin/analytics.html @@ -0,0 +1,45 @@ +
+
+

Analytics

+

Basic activity counters from chat history and moderation audit logs.

+
+ +
+ +
+
+

Total Messages

+

0

+
+
+

Last 24 Hours

+

0

+
+
+

Distinct Rooms

+

0

+
+
+

Moderation Actions

+

0

+
+
+ +
+
+

Top Rooms

+

loading...

+
+
+ + + + + + + + +
RoomMessages
+
+

Loading analytics...

+
diff --git a/frontend/admin/chat-log.html b/frontend/admin/chat-log.html new file mode 100644 index 0000000..3927291 --- /dev/null +++ b/frontend/admin/chat-log.html @@ -0,0 +1,46 @@ +
+
+

Chat Log

+

Browse and manage stored chat messages.

+
+
+ + +
+
+ +
+
+ + + + + +
+
+ +
+
+ + + + + + + + + + + + + + +
IDRoomUsernameMessageTypeTimestampActions
+
+
+ + Page 1 + +
+

Loading chat log...

+
diff --git a/frontend/admin/dashboard.html b/frontend/admin/dashboard.html new file mode 100644 index 0000000..b006f74 --- /dev/null +++ b/frontend/admin/dashboard.html @@ -0,0 +1,21 @@ +

Dashboard

+
+
+

Concurrent Viewers

+

0

+
+
+

Stored Messages

+

0

+
+
+

Moderation Actions

+

0

+
+
+

Media Runtime

+

00:00:00

+

0 files

+
+
+

Loading live admin overview...

\ No newline at end of file diff --git a/frontend/admin/index.html b/frontend/admin/index.html index b73f506..df41d9f 100644 --- a/frontend/admin/index.html +++ b/frontend/admin/index.html @@ -24,6 +24,7 @@ + @@ -31,313 +32,7 @@
-
-

Dashboard

-
-
-

Concurrent Viewers

-

0

-
-
-

Stored Messages

-

0

-
-
-

Moderation Actions

-

0

-
-
-

Media Runtime

-

00:00:00

-

0 files

-
-
-

Loading live admin overview...

-
- - - - - - - - - - - - - - +
Loading...
diff --git a/frontend/admin/moderation.html b/frontend/admin/moderation.html new file mode 100644 index 0000000..0f26bdf --- /dev/null +++ b/frontend/admin/moderation.html @@ -0,0 +1,58 @@ +
+
+

Moderation

+

Create moderation actions and review the recent immutable audit log.

+
+ +
+ +
+
+

New Action

+
+ + + + + + +

Loading moderation logs...

+
+
+ +
+
+

Recent Audit Log

+

Latest entries

+
+
+ + + + + + + + + + + +
WhenActionTargetModeratorReason
+
+
+
\ No newline at end of file diff --git a/frontend/admin/playlist.html b/frontend/admin/playlist.html new file mode 100644 index 0000000..ed84697 --- /dev/null +++ b/frontend/admin/playlist.html @@ -0,0 +1,16 @@ +
+
+

Playlist

+

Edit the active concat playlist used by the playout service.

+
+
+ + +
+
+ +
+ + +

Loading playlist...

+
\ No newline at end of file diff --git a/frontend/admin/schedule.html b/frontend/admin/schedule.html new file mode 100644 index 0000000..065669e --- /dev/null +++ b/frontend/admin/schedule.html @@ -0,0 +1,26 @@ +
+
+

Schedule

+

Read-only view of the currently configured playout order.

+
+ +
+ +
+

0/0 matched • total runtime 00:00:00

+
+ + + + + + + + + + + +
#LabelRuntimeStatusPath
+
+

Loading schedule...

+
\ No newline at end of file diff --git a/frontend/admin/system.html b/frontend/admin/system.html new file mode 100644 index 0000000..5f5a624 --- /dev/null +++ b/frontend/admin/system.html @@ -0,0 +1,54 @@ +

System Configuration

+
+
+

Redis Stats

+
+
+
Memory Used
+
loading...
+
+
+
Connected Clients
+
0
+
+
+
Redis Enabled
+
no
+
+
+
+
+

Storage

+
+
+
Database Driver
+
loading...
+
+
+
Stored Messages
+
0
+
+
+
Last Refresh
+
loading...
+
+
+
Playlist Path
+
loading...
+
+
+
Playlist Present
+
no
+
+
+
+
+ +
+
+

Service Health

+ +
+
+

Loading system summary...

+
diff --git a/frontend/admin/upload.html b/frontend/admin/upload.html new file mode 100644 index 0000000..356c606 --- /dev/null +++ b/frontend/admin/upload.html @@ -0,0 +1,41 @@ +
+
+

Upload Video

+

Upload media files into the local media library mounted for playout.

+
+ +
+ +
+ + +

Resumable chunk upload enabled • up to 2GB per file.

+ +

Loading media library...

+
+ +
+
+

Media Library

+

loading...

+
+
+ + + + + + + + + + + + + +
FileTitleRuntimeStatusSizeModifiedActions
+
+
\ No newline at end of file diff --git a/frontend/admin/users.html b/frontend/admin/users.html new file mode 100644 index 0000000..dd9896d --- /dev/null +++ b/frontend/admin/users.html @@ -0,0 +1,23 @@ +
+
+

Users

+

Current room presence derived from Redis presence keys.

+
+ +
+ +
+
+ + + + + + + + + +
UsernameRoomOnline
+
+

Loading active users...

+
\ No newline at end of file diff --git a/frontend/src/admin.ts b/frontend/src/admin.ts index ca33e20..a9a4a95 100644 --- a/frontend/src/admin.ts +++ b/frontend/src/admin.ts @@ -47,7 +47,9 @@ const uploadProgressWrap = () => document.getElementById("media-upload-progress-wrap") as HTMLElement | null; const uploadProgressBar = () => - document.getElementById("media-upload-progress") as HTMLProgressElement | null; + document.getElementById( + "media-upload-progress", + ) as HTMLProgressElement | null; const uploadProgressText = () => document.getElementById("media-upload-progress-text") as HTMLElement | null; @@ -135,9 +137,21 @@ const fmtSize = (value?: number) => { return `${size.toFixed(size >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`; }; -function switchTab(tabName: string) { +// Page content cache: maps tab name to loaded HTML string +const pageCache: Record = {}; + +async function loadPageContent(tabName: string): Promise { + if (pageCache[tabName]) return pageCache[tabName]; + + const response = await fetch(`/admin/${tabName}.html`); + if (!response.ok) throw new Error(`Failed to load page: ${tabName}`); + const html = await response.text(); + pageCache[tabName] = html; + return html; +} + +async function switchTab(tabName: string) { const triggers = document.querySelectorAll(".tab-trigger"); - const panels = document.querySelectorAll(".tab-panel"); triggers.forEach((trigger) => { const active = trigger.dataset.tab === tabName; @@ -145,16 +159,27 @@ function switchTab(tabName: string) { trigger.setAttribute("aria-selected", String(active)); }); - panels.forEach((panel) => { - const active = panel.id === `tab-${tabName}`; - panel.hidden = !active; - panel.classList.toggle("is-active", active); - }); + const container = document.getElementById("tab-content-container"); + if (!container) return; + try { + const html = await loadPageContent(tabName); + container.innerHTML = html; + } catch (error) { + console.error("Failed to load tab content", error); + container.innerHTML = `

Failed to load ${tabName} page.

`; + return; + } + + // Re-bind events for the newly loaded page content + bindPageEvents(tabName); + + if (tabName === "dashboard") refreshOverview(); if (tabName === "upload") refreshMediaLibrary(); if (tabName === "playlist") refreshPlaylist(); if (tabName === "schedule") refreshSchedule(); if (tabName === "moderation") refreshModerationLogs(); + if (tabName === "chat-log") refreshChatLog(); if (tabName === "users") refreshUsers(); if (tabName === "analytics") refreshAnalytics(); if (tabName === "system") refreshSystemSummary(); @@ -446,11 +471,18 @@ async function uploadVideo(file: File) { const alreadyUploaded = Number(initPayload.uploadedChunks || 0); const effectiveTotalChunks = Number(initPayload.totalChunks || totalChunks); - const resumeFrom = Math.min(Math.max(alreadyUploaded, 0), effectiveTotalChunks); + const resumeFrom = Math.min( + Math.max(alreadyUploaded, 0), + effectiveTotalChunks, + ); setUploadProgress(resumeFrom, effectiveTotalChunks, "Uploading chunks"); - for (let chunkIndex = resumeFrom; chunkIndex < effectiveTotalChunks; chunkIndex += 1) { + for ( + let chunkIndex = resumeFrom; + chunkIndex < effectiveTotalChunks; + chunkIndex += 1 + ) { const start = chunkIndex * UPLOAD_CHUNK_BYTES; const end = Math.min(file.size, start + UPLOAD_CHUNK_BYTES); const chunk = file.slice(start, end); @@ -468,7 +500,11 @@ async function uploadVideo(file: File) { throw new Error(await readErrorMessage(chunkResponse)); } - setUploadProgress(chunkIndex + 1, effectiveTotalChunks, "Uploading chunks"); + setUploadProgress( + chunkIndex + 1, + effectiveTotalChunks, + "Uploading chunks", + ); setStatus( "media-status", `Uploading ${file.name} (${chunkIndex + 1}/${effectiveTotalChunks} chunks)...`, @@ -497,7 +533,8 @@ async function uploadVideo(file: File) { await refreshSchedule(); } catch (error) { console.error("Failed to upload media", error); - const details = error instanceof Error ? error.message : "Unknown upload error."; + const details = + error instanceof Error ? error.message : "Unknown upload error."; setStatus( "media-status", `Upload interrupted. Re-select the same file to resume. (${details})`, @@ -637,6 +674,245 @@ async function submitModerationAction(event: Event) { } } +// --- Chat Log --- + +type ChatLogEntry = { + id: number; + room: string; + username: string; + text: string; + timestamp: string; + type: string; +}; + +const chatLogState = { + entries: [] as ChatLogEntry[], + total: 0, + limit: 50, + before: "", + nextBefore: "", + selected: new Set(), + room: "", + username: "", + text: "", +}; + +function getChatLogBefore(): string { + if (chatLogState.entries.length === 0) return ""; + const oldest = chatLogState.entries[chatLogState.entries.length - 1]; + return oldest.timestamp; +} + +async function refreshChatLog() { + setStatus("chat-log-status", "Loading chat log..."); + try { + const params = new URLSearchParams(); + params.set("limit", String(chatLogState.limit)); + if (chatLogState.before) params.set("before", chatLogState.before); + if (chatLogState.room) params.set("room", chatLogState.room); + if (chatLogState.username) params.set("username", chatLogState.username); + if (chatLogState.text) params.set("text", chatLogState.text); + + const response = await fetch(`/api/admin/chat-log?${params}`); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + + const payload = await response.json(); + chatLogState.entries = payload.entries || []; + chatLogState.total = payload.total || 0; + chatLogState.selected.clear(); + + renderChatLogTable(); + updateChatLogPagination(); + updateBulkDeleteButton(); + setStatus( + "chat-log-status", + `Loaded ${chatLogState.entries.length} entries.`, + ); + } catch (error) { + console.error("Failed to load chat log", error); + setStatus("chat-log-status", "Failed to load chat log.", true); + } +} + +function renderChatLogTable() { + const tbody = document.getElementById("chat-log-table-body"); + if (!tbody) return; + tbody.innerHTML = ""; + + if (chatLogState.entries.length === 0) { + tbody.innerHTML = `No chat log entries found.`; + return; + } + + chatLogState.entries.forEach((entry) => { + const tr = document.createElement("tr"); + const checked = chatLogState.selected.has(entry.id) ? "checked" : ""; + tr.innerHTML = ` + + ${entry.id} + ${entry.room} + ${entry.username} + ${entry.text} + ${entry.type} + ${entry.timestamp} + + + + `; + tr.querySelector('[data-action="delete-chat"]')?.addEventListener( + "click", + () => deleteChatEntry(entry.id), + ); + tr.querySelector(".chat-log-checkbox")?.addEventListener("change", (e) => { + const cb = e.target as HTMLInputElement; + if (cb.checked) { + chatLogState.selected.add(entry.id); + } else { + chatLogState.selected.delete(entry.id); + } + updateBulkDeleteButton(); + updateSelectAllCheckbox(); + }); + tbody.appendChild(tr); + }); + + updateSelectAllCheckbox(); +} + +function updateSelectAllCheckbox() { + const selectAll = document.getElementById( + "chat-log-select-all", + ) as HTMLInputElement | null; + if (!selectAll) return; + const allSelected = + chatLogState.entries.length > 0 && + chatLogState.entries.every((e) => chatLogState.selected.has(e.id)); + selectAll.checked = allSelected; +} + +function updateBulkDeleteButton() { + const btn = document.getElementById( + "bulk-delete-chat-log-btn", + ) as HTMLButtonElement | null; + if (!btn) return; + btn.disabled = chatLogState.selected.size === 0; +} + +function updateChatLogPagination() { + const prevBtn = document.getElementById( + "chat-log-prev-btn", + ) as HTMLButtonElement | null; + const nextBtn = document.getElementById( + "chat-log-next-btn", + ) as HTMLButtonElement | null; + const info = document.getElementById("chat-log-page-info"); + + if (prevBtn) prevBtn.disabled = !chatLogState.before; + if (nextBtn) + nextBtn.disabled = chatLogState.entries.length < chatLogState.limit; + if (info) + info.textContent = `Showing ${chatLogState.entries.length} of ${chatLogState.total}`; +} + +function chatLogNextPage() { + if (chatLogState.entries.length === 0) return; + chatLogState.before = getChatLogBefore(); + refreshChatLog(); +} + +function chatLogPrevPage() { + chatLogState.before = ""; + refreshChatLog(); +} + +function chatLogApplyFilter() { + chatLogState.room = + (document.getElementById("chat-log-filter-room") as HTMLInputElement) + ?.value || ""; + chatLogState.username = + (document.getElementById("chat-log-filter-username") as HTMLInputElement) + ?.value || ""; + chatLogState.text = + (document.getElementById("chat-log-filter-text") as HTMLInputElement) + ?.value || ""; + chatLogState.before = ""; + refreshChatLog(); +} + +function chatLogClearFilter() { + const roomEl = document.getElementById( + "chat-log-filter-room", + ) as HTMLInputElement | null; + const userEl = document.getElementById( + "chat-log-filter-username", + ) as HTMLInputElement | null; + const textEl = document.getElementById( + "chat-log-filter-text", + ) as HTMLInputElement | null; + if (roomEl) roomEl.value = ""; + if (userEl) userEl.value = ""; + if (textEl) textEl.value = ""; + chatLogState.room = ""; + chatLogState.username = ""; + chatLogState.text = ""; + chatLogState.before = ""; + refreshChatLog(); +} + +async function deleteChatEntry(id: number) { + if (!window.confirm(`Delete chat message #${id}? This cannot be undone.`)) + return; + + setStatus("chat-log-status", `Deleting message #${id}...`); + try { + const response = await fetch(`/api/admin/chat-log/${id}`, { + method: "DELETE", + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + setStatus("chat-log-status", `Message #${id} deleted.`); + await refreshChatLog(); + await refreshOverview(); + } catch (error) { + console.error("Failed to delete chat entry", error); + setStatus("chat-log-status", "Failed to delete message.", true); + } +} + +async function bulkDeleteChatLog() { + if (chatLogState.selected.size === 0) return; + + if ( + !window.confirm( + `Delete ${chatLogState.selected.size} selected messages? This cannot be undone.`, + ) + ) + return; + + setStatus( + "chat-log-status", + `Deleting ${chatLogState.selected.size} messages...`, + ); + try { + const ids = Array.from(chatLogState.selected); + const response = await fetch("/api/admin/chat-log/bulk", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ids }), + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const payload = await response.json(); + setStatus( + "chat-log-status", + `${payload.deleted || ids.length} messages deleted.`, + ); + await refreshChatLog(); + await refreshOverview(); + } catch (error) { + console.error("Failed to bulk delete chat entries", error); + setStatus("chat-log-status", "Failed to delete messages.", true); + } +} + async function refreshUsers() { setStatus("users-status", "Loading active users..."); try { @@ -688,84 +964,133 @@ function bindEvents() { window.location.href = "/admin/login/"; } }); +} - document - .getElementById("reload-media-btn") - ?.addEventListener("click", refreshMediaLibrary); - document - .getElementById("reload-schedule-btn") - ?.addEventListener("click", refreshSchedule); - document - .getElementById("reload-playlist-btn") - ?.addEventListener("click", refreshPlaylist); - document - .getElementById("save-playlist-btn") - ?.addEventListener("click", savePlaylist); - document - .getElementById("reload-moderation-btn") - ?.addEventListener("click", refreshModerationLogs); - document - .getElementById("reload-analytics-btn") - ?.addEventListener("click", refreshAnalytics); - document - .getElementById("reload-users-btn") - ?.addEventListener("click", refreshUsers); - document - .getElementById("reload-system-btn") - ?.addEventListener("click", refreshSystemSummary); +// bindPageEvents wires up page-specific DOM listeners after HTML is loaded +function bindPageEvents(tabName: string) { + switch (tabName) { + case "upload": + document + .getElementById("reload-media-btn") + ?.addEventListener("click", refreshMediaLibrary); - const uploadInput = document.getElementById( - "video-upload", - ) as HTMLInputElement | null; - const uploadDropzone = document.getElementById("upload-dropzone"); - document - .getElementById("upload-trigger-btn") - ?.addEventListener("click", () => uploadInput?.click()); + const uploadInput = document.getElementById( + "video-upload", + ) as HTMLInputElement | null; + const uploadDropzone = document.getElementById("upload-dropzone"); + document + .getElementById("upload-trigger-btn") + ?.addEventListener("click", () => uploadInput?.click()); - uploadDropzone?.addEventListener("dragover", (event) => { - event.preventDefault(); - uploadDropzone.classList.add("is-dragover"); - }); - uploadDropzone?.addEventListener("dragleave", () => { - uploadDropzone.classList.remove("is-dragover"); - }); - uploadDropzone?.addEventListener("drop", async (event) => { - event.preventDefault(); - uploadDropzone.classList.remove("is-dragover"); - const droppedFiles = event.dataTransfer?.files; - if (!droppedFiles || droppedFiles.length === 0) return; - await uploadVideo(droppedFiles[0]); - }); + uploadDropzone?.addEventListener("dragover", (event) => { + event.preventDefault(); + uploadDropzone.classList.add("is-dragover"); + }); + uploadDropzone?.addEventListener("dragleave", () => { + uploadDropzone.classList.remove("is-dragover"); + }); + uploadDropzone?.addEventListener("drop", async (event) => { + event.preventDefault(); + uploadDropzone.classList.remove("is-dragover"); + const droppedFiles = event.dataTransfer?.files; + if (!droppedFiles || droppedFiles.length === 0) return; + await uploadVideo(droppedFiles[0]); + }); + uploadInput?.addEventListener("change", async (event) => { + const target = event.target as HTMLInputElement; + if (!target.files || target.files.length === 0) return; + await uploadVideo(target.files[0]); + target.value = ""; + }); + break; - uploadInput?.addEventListener("change", async (event) => { - const target = event.target as HTMLInputElement; - if (!target.files || target.files.length === 0) return; - await uploadVideo(target.files[0]); - target.value = ""; - }); + case "playlist": + document + .getElementById("reload-playlist-btn") + ?.addEventListener("click", refreshPlaylist); + document + .getElementById("save-playlist-btn") + ?.addEventListener("click", savePlaylist); + break; - document - .getElementById("moderation-form") - ?.addEventListener("submit", submitModerationAction); + case "schedule": + document + .getElementById("reload-schedule-btn") + ?.addEventListener("click", refreshSchedule); + break; - (document.getElementById("mod-moderator") as HTMLInputElement).value = - adminConfig.defaultModerator; - (document.getElementById("mod-room") as HTMLInputElement).value = - adminConfig.defaultRoom; + case "moderation": + document + .getElementById("reload-moderation-btn") + ?.addEventListener("click", refreshModerationLogs); + document + .getElementById("moderation-form") + ?.addEventListener("submit", submitModerationAction); + (document.getElementById("mod-moderator") as HTMLInputElement).value = + adminConfig.defaultModerator; + (document.getElementById("mod-room") as HTMLInputElement).value = + adminConfig.defaultRoom; + break; + + case "chat-log": + document + .getElementById("reload-chat-log-btn") + ?.addEventListener("click", refreshChatLog); + document + .getElementById("bulk-delete-chat-log-btn") + ?.addEventListener("click", bulkDeleteChatLog); + document + .getElementById("chat-log-filter-btn") + ?.addEventListener("click", chatLogApplyFilter); + document + .getElementById("chat-log-clear-filter-btn") + ?.addEventListener("click", chatLogClearFilter); + document + .getElementById("chat-log-prev-btn") + ?.addEventListener("click", chatLogPrevPage); + document + .getElementById("chat-log-next-btn") + ?.addEventListener("click", chatLogNextPage); + document + .getElementById("chat-log-select-all") + ?.addEventListener("change", (e) => { + const checked = (e.target as HTMLInputElement).checked; + chatLogState.entries.forEach((entry) => { + if (checked) { + chatLogState.selected.add(entry.id); + } else { + chatLogState.selected.delete(entry.id); + } + }); + updateBulkDeleteButton(); + renderChatLogTable(); + }); + break; + + case "analytics": + document + .getElementById("reload-analytics-btn") + ?.addEventListener("click", refreshAnalytics); + break; + + case "users": + document + .getElementById("reload-users-btn") + ?.addEventListener("click", refreshUsers); + break; + + case "system": + document + .getElementById("reload-system-btn") + ?.addEventListener("click", refreshSystemSummary); + break; + } } async function init() { bindEvents(); - await Promise.all([ - refreshOverview(), - refreshAnalytics(), - refreshMediaLibrary(), - refreshPlaylist(), - refreshSchedule(), - refreshModerationLogs(), - refreshSystemSummary(), - refreshUsers(), - ]); + // Load dashboard as the initial tab + await switchTab("dashboard"); } document.addEventListener("DOMContentLoaded", () => {