import "./style.css"; type ServiceHealth = { name: string; status: string; detail: string; checked: string; }; type MediaEntry = { id?: number; name: string; title?: string; status?: string; size?: number; runtime?: string; modifiedAt?: string; tags?: string; }; const bodyDataset = document.body.dataset; const moderationLogLimit = Number.parseInt( bodyDataset.adminModerationLogLimit || "", 10, ); const adminConfig = { defaultModerator: bodyDataset.adminDefaultModerator || "admin", defaultRoom: bodyDataset.adminDefaultRoom || "main", moderationLogLimit: Number.isFinite(moderationLogLimit) && moderationLogLimit > 0 ? moderationLogLimit : 50, }; const MAX_UPLOAD_BYTES = 2 * 1024 * 1024 * 1024; const UPLOAD_CHUNK_BYTES = 8 * 1024 * 1024; const UPLOAD_RESUME_KEY_PREFIX = "linearvoid-upload-resume:"; const state = { mediaUploading: false, moderationSubmitting: false, playlistSaving: false, }; const uploadProgressWrap = () => document.getElementById("media-upload-progress-wrap") as HTMLElement | null; const uploadProgressBar = () => document.getElementById( "media-upload-progress", ) as HTMLProgressElement | null; const uploadProgressText = () => document.getElementById("media-upload-progress-text") as HTMLElement | null; const setUploadProgress = (value: number, total: number, label: string) => { const wrap = uploadProgressWrap(); const progress = uploadProgressBar(); const text = uploadProgressText(); if (!wrap || !progress || !text) return; wrap.hidden = false; progress.max = total; progress.value = value; const pct = total > 0 ? Math.min(100, Math.round((value / total) * 100)) : 0; text.textContent = `${label} (${pct}%)`; }; const hideUploadProgress = () => { const wrap = uploadProgressWrap(); if (wrap) wrap.hidden = true; }; const getFileFingerprint = (file: File) => `${file.name}:${file.size}:${file.lastModified}`; const getResumeStorageKey = (fingerprint: string) => `${UPLOAD_RESUME_KEY_PREFIX}${fingerprint}`; const getStoredUploadId = (fingerprint: string) => { try { return window.localStorage.getItem(getResumeStorageKey(fingerprint)) || ""; } catch { return ""; } }; const setStoredUploadId = (fingerprint: string, uploadId: string) => { try { window.localStorage.setItem(getResumeStorageKey(fingerprint), uploadId); } catch { // ignore storage failures } }; const clearStoredUploadId = (fingerprint: string) => { try { window.localStorage.removeItem(getResumeStorageKey(fingerprint)); } catch { // ignore storage failures } }; const readErrorMessage = async (response: Response) => { const fallback = `HTTP ${response.status}`; try { const text = (await response.text()).trim(); return text || fallback; } catch { return fallback; } }; const setText = (id: string, text: string) => { const el = document.getElementById(id); if (el) el.textContent = text; }; const setStatus = (id: string, text: string, isError = false) => { const el = document.getElementById(id); if (!el) return; el.textContent = text; el.classList.toggle("status-error", isError); }; const fmtSize = (value?: number) => { if (!value || value <= 0) return "0 B"; const units = ["B", "KB", "MB", "GB"]; let size = value; let unitIndex = 0; while (size >= 1024 && unitIndex < units.length - 1) { size /= 1024; unitIndex += 1; } return `${size.toFixed(size >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`; }; // 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"); triggers.forEach((trigger) => { const active = trigger.dataset.tab === tabName; trigger.classList.toggle("is-active", active); trigger.setAttribute("aria-selected", String(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(); } async function refreshOverview() { try { const response = await fetch("/api/admin/overview"); if (!response.ok) throw new Error(`HTTP ${response.status}`); const payload = await response.json(); setText("stat-viewers", String(payload.viewers ?? 0)); setText("stat-stored-messages", String(payload.storedMessages ?? 0)); setText("stat-moderation-actions", String(payload.moderationActions ?? 0)); setText("stat-media-runtime", payload.mediaTotalRuntime || "00:00:00"); setText("stat-media-count", `${payload.mediaCount || 0} files`); setText("system-stored-messages", String(payload.storedMessages ?? 0)); setText("system-redis-clients", String(payload.redisClients ?? 0)); setText("system-redis-enabled", payload.redisEnabled ? "yes" : "no"); setText("system-redis-memory", payload.redisMemoryUsed || "unavailable"); setText("system-db-driver", payload.databaseDriver || "unavailable"); setText("system-generated-at", payload.generatedAt || "unavailable"); setStatus("overview-status", `Live data loaded at ${payload.generatedAt}`); } catch (error) { console.error("Failed to load admin overview", error); setStatus("overview-status", "Failed to load live admin data.", true); } } async function refreshAnalytics() { setStatus("analytics-status", "Loading analytics..."); try { const response = await fetch("/api/admin/analytics"); if (!response.ok) throw new Error(`HTTP ${response.status}`); const payload = await response.json(); setText("analytics-total-messages", String(payload.totalMessages ?? 0)); setText("analytics-messages-24h", String(payload.messagesLast24Hours ?? 0)); setText("analytics-distinct-rooms", String(payload.distinctRooms ?? 0)); setText( "analytics-moderation-actions", String(payload.moderationActions ?? 0), ); setText("analytics-generated-at", payload.generatedAt || "unavailable"); const tbody = document.getElementById("analytics-table-body"); if (!tbody) return; tbody.innerHTML = ""; const rows = (payload.topRooms || []) as Array<{ room: string; messageCount: number; }>; if (rows.length === 0) { tbody.innerHTML = `No room analytics available yet.`; } else { rows.forEach((row) => { const tr = document.createElement("tr"); tr.innerHTML = `${row.room}${row.messageCount}`; tbody.appendChild(tr); }); } setStatus("analytics-status", "Analytics loaded."); } catch (error) { console.error("Failed to load analytics", error); setStatus("analytics-status", "Failed to load analytics.", true); } } async function refreshPlaylist() { setStatus("playlist-status", "Loading playlist..."); try { const response = await fetch("/api/admin/playlist"); if (!response.ok) throw new Error(`HTTP ${response.status}`); const payload = await response.json(); const content: string = payload.content || ""; const textarea = document.getElementById( "playlist-content", ) as HTMLTextAreaElement | null; if (textarea) textarea.value = content; renderPlaylistSortable(content); await showPlaylistWarnings(content); setStatus("playlist-status", "Playlist loaded."); } catch (error) { console.error("Failed to load playlist", error); setStatus("playlist-status", "Failed to load playlist.", true); } } function renderPlaylistSortable(content: string) { const list = document.getElementById("playlist-sortable"); if (!list) return; const lines = content.split("\n").filter((l) => l.trim() && !l.startsWith("#")); list.innerHTML = lines .map((line, i) => { const path = line.replace(/^file\s+/, "").replace(/['"]/g, "").trim(); return `
  • ${i + 1} ${path}
  • `; }) .join(""); let dragSrc: number | null = null; list.querySelectorAll(".sortable-item").forEach((item) => { item.addEventListener("dragstart", () => { dragSrc = Number(item.dataset.index); item.classList.add("dragging"); }); item.addEventListener("dragend", () => { item.classList.remove("dragging"); dragSrc = null; }); item.addEventListener("dragover", (e) => e.preventDefault()); item.addEventListener("drop", (e) => { e.preventDefault(); const dst = Number(item.dataset.index); if (dragSrc !== null && dragSrc !== dst) reorderPlaylistEntry(dragSrc, dst); }); item.querySelector("[data-remove]")?.addEventListener("click", () => { removePlaylistEntry(Number(item.dataset.index)); }); }); } function getPlaylistLines(): string[] { const ta = document.getElementById("playlist-content") as HTMLTextAreaElement; return ta?.value.split("\n").filter((l) => l.trim() && !l.startsWith("#")) || []; } function syncPlaylistTextarea(lines: string[]) { const ta = document.getElementById("playlist-content") as HTMLTextAreaElement; if (ta) ta.value = lines.join("\n") + "\n"; renderPlaylistSortable(lines.join("\n")); } function reorderPlaylistEntry(from: number, to: number) { const lines = getPlaylistLines(); const [moved] = lines.splice(from, 1); lines.splice(to, 0, moved); syncPlaylistTextarea(lines); } function removePlaylistEntry(index: number) { const lines = getPlaylistLines(); lines.splice(index, 1); syncPlaylistTextarea(lines); } async function showPlaylistWarnings(content: string) { const warnings = document.getElementById("playlist-warnings"); if (!warnings) return; const lines = content.split("\n").filter((l) => l.trim() && !l.startsWith("#")); const msgs: string[] = []; if (lines.length === 0) msgs.push("Playlist is empty. Add files from the media library."); try { const resp = await fetch("/api/admin/media"); if (resp.ok) { const media = await resp.json(); const mediaPaths = new Set((media.files || []).map((f: { path: string }) => f.path)); const nonNorm = (media.files || []).filter((f: { status: string }) => f.status === "needs_normalization"); for (const line of lines) { const path = line.replace(/^file\s+/, "").replace(/['"]/g, "").trim(); if (path && !mediaPaths.has(path)) msgs.push(`Missing from media library: ${path}`); } if (nonNorm.length > 0) msgs.push(`${nonNorm.length} file(s) need normalization. Run scripts/normalize.sh.`); } } catch { /* ignore */ } if (msgs.length > 0) { warnings.innerHTML = msgs.map((m) => `
    ⚠ ${m}
    `).join(""); warnings.hidden = false; } else { warnings.hidden = true; } } async function savePlaylist() { if (state.playlistSaving) return; state.playlistSaving = true; setStatus("playlist-status", "Saving playlist..."); try { const textarea = document.getElementById( "playlist-content", ) as HTMLTextAreaElement | null; const content = textarea?.value || ""; const response = await fetch("/api/admin/playlist", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ content }), }); if (!response.ok) throw new Error(`HTTP ${response.status}`); setStatus("playlist-status", "Playlist saved."); } catch (error) { console.error("Failed to save playlist", error); setStatus("playlist-status", "Failed to save playlist.", true); } finally { state.playlistSaving = false; } } async function refreshSchedule() { setStatus("schedule-status", "Loading schedule..."); try { const response = await fetch("/api/admin/schedule"); if (!response.ok) throw new Error(`HTTP ${response.status}`); const payload = await response.json(); setText("schedule-total-entries", String(payload.totalEntries ?? 0)); setText("schedule-matched-entries", String(payload.matchedEntries ?? 0)); setText("schedule-total-runtime", payload.totalRuntime || "00:00:00"); const tbody = document.getElementById("schedule-table-body"); if (!tbody) return; tbody.innerHTML = ""; const entries = payload.entries || []; if (entries.length === 0) { tbody.innerHTML = `No schedule entries found.`; } else { entries.forEach((entry: any) => { const tr = document.createElement("tr"); tr.innerHTML = `${entry.index}${entry.label}${entry.runtime || "00:00:00"}${entry.status || "missing"}${entry.path}`; tbody.appendChild(tr); }); } setStatus( "schedule-status", `Schedule loaded. Total runtime ${payload.totalRuntime || "00:00:00"}.`, ); } catch (error) { console.error("Failed to load schedule", error); setStatus("schedule-status", "Failed to load schedule.", true); } } async function refreshOverrides() { try { const resp = await fetch("/api/admin/schedule/overrides"); if (!resp.ok) return; const overrides = await resp.json(); const tbody = document.getElementById("override-table-body"); if (!tbody) return; if (!overrides.length) { tbody.innerHTML = `No overrides.`; return; } tbody.innerHTML = overrides .map( (o: { id: number; mediaId: number; startTime: string; endTime: string; reason: string }) => `${o.mediaId}${new Date(o.startTime).toLocaleString()}${new Date(o.endTime).toLocaleString()}${o.reason || "-"}`, ) .join(""); tbody.querySelectorAll("[data-delete-override]").forEach((btn) => { btn.addEventListener("click", () => deleteOverride(Number((btn as HTMLElement).dataset.deleteOverride))); }); } catch { /* non-critical */ } } async function submitOverride(e: Event) { e.preventDefault(); const status = document.getElementById("override-status"); if (status) status.textContent = "Saving..."; const mediaId = (document.getElementById("override-media-id") as HTMLInputElement).value; const startTime = (document.getElementById("override-start") as HTMLInputElement).value; const endTime = (document.getElementById("override-end") as HTMLInputElement).value; const reason = (document.getElementById("override-reason") as HTMLInputElement).value; try { const resp = await fetch("/api/admin/schedule/overrides", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ mediaId: Number(mediaId), startTime: new Date(startTime).toISOString(), endTime: new Date(endTime).toISOString(), reason }), }); if (!resp.ok) { const text = await resp.text(); throw new Error(text); } if (status) { status.textContent = "Override added."; status.className = "status-line"; } refreshOverrides(); } catch (err) { if (status) { status.textContent = `Failed: ${err}`; status.className = "status-line status-error"; } } } async function deleteOverride(id: number) { try { await fetch(`/api/admin/schedule/overrides?id=${id}`, { method: "DELETE" }); refreshOverrides(); } catch { /* ignore */ } } async function refreshSystemSummary() { setStatus("system-status", "Loading system summary..."); try { const response = await fetch("/api/admin/system"); if (!response.ok) throw new Error(`HTTP ${response.status}`); const payload = await response.json(); setText("system-db-driver", payload.databaseDriver || "unavailable"); setText("system-playlist-path", payload.playlistPath || "unavailable"); setText("system-playlist-present", payload.playlistExists ? "yes" : "no"); setText("system-redis-enabled", payload.redisEnabled ? "yes" : "no"); setText("system-redis-memory", payload.redisMemoryUsed || "unavailable"); setText("system-generated-at", payload.generatedAt || "unavailable"); const servicesEl = document.getElementById("system-services"); if (servicesEl) { servicesEl.innerHTML = ""; const services = (payload.services || []) as ServiceHealth[]; services.forEach((service) => { const row = document.createElement("article"); row.className = "service-row"; row.innerHTML = `

    ${service.name}

    ${service.checked}

    ${service.status}

    ${service.detail}

    `; servicesEl.appendChild(row); }); } setStatus("system-status", "System summary loaded."); } catch (error) { console.error("Failed to load system summary", error); setStatus("system-status", "Failed to load system summary.", true); } } function renderMediaRows(files: MediaEntry[]) { const tbody = document.getElementById("media-table-body"); if (!tbody) return; tbody.innerHTML = ""; if (files.length === 0) { tbody.innerHTML = `No media files available.`; return; } files.forEach((file) => { const tr = document.createElement("tr"); tr.innerHTML = ` ${file.name} ${file.title || "-"} ${file.runtime || "00:00:00"} ${file.status || "unknown"} ${fmtSize(file.size)} ${file.modifiedAt || "-"} `; tr.querySelector('[data-action="edit"]')?.addEventListener("click", () => editMedia(file), ); tr.querySelector('[data-action="delete"]')?.addEventListener("click", () => deleteMedia(file), ); tbody.appendChild(tr); }); } async function refreshMediaLibrary() { setStatus("media-status", "Loading media library..."); try { const response = await fetch("/api/admin/media"); if (!response.ok) throw new Error(`HTTP ${response.status}`); const payload = await response.json(); renderMediaRows(payload.files || []); setText("media-generated-at", payload.generatedAt || "unavailable"); setStatus("media-status", "Media library loaded."); } catch (error) { console.error("Failed to load media library", error); setStatus("media-status", "Failed to load media library.", true); } } async function uploadVideo(file: File) { if (state.mediaUploading) return; if (file.size <= 0) { setStatus("media-status", "Cannot upload an empty file.", true); return; } if (file.size > MAX_UPLOAD_BYTES) { setStatus("media-status", "File exceeds 2GB upload limit.", true); return; } state.mediaUploading = true; setStatus("media-status", `Preparing upload for ${file.name}...`); try { const totalChunks = Math.ceil(file.size / UPLOAD_CHUNK_BYTES); const fingerprint = getFileFingerprint(file); const initResponse = await fetch("/api/admin/media/uploads", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ uploadId: getStoredUploadId(fingerprint), fileName: file.name, fileSize: file.size, mimeType: file.type, chunkSize: UPLOAD_CHUNK_BYTES, totalChunks, }), }); if (!initResponse.ok) { throw new Error(await readErrorMessage(initResponse)); } const initPayload = await initResponse.json(); const uploadId = String(initPayload.uploadId || ""); if (!uploadId) { throw new Error("Upload session could not be initialized."); } setStoredUploadId(fingerprint, uploadId); const alreadyUploaded = Number(initPayload.uploadedChunks || 0); const effectiveTotalChunks = Number(initPayload.totalChunks || totalChunks); const resumeFrom = Math.min( Math.max(alreadyUploaded, 0), effectiveTotalChunks, ); setUploadProgress(resumeFrom, effectiveTotalChunks, "Uploading chunks"); 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); const chunkResponse = await fetch( `/api/admin/media/uploads/${encodeURIComponent(uploadId)}/chunk?index=${chunkIndex}`, { method: "PUT", headers: { "Content-Type": "application/octet-stream" }, body: chunk, }, ); if (!chunkResponse.ok) { throw new Error(await readErrorMessage(chunkResponse)); } setUploadProgress( chunkIndex + 1, effectiveTotalChunks, "Uploading chunks", ); setStatus( "media-status", `Uploading ${file.name} (${chunkIndex + 1}/${effectiveTotalChunks} chunks)...`, ); } const completeResponse = await fetch( `/api/admin/media/uploads/${encodeURIComponent(uploadId)}/complete`, { method: "POST", }, ); if (!completeResponse.ok) { throw new Error(await readErrorMessage(completeResponse)); } setUploadProgress(effectiveTotalChunks, effectiveTotalChunks, "Finalizing"); const payload = await completeResponse.json(); renderMediaRows(payload.files || []); setText("media-generated-at", payload.generatedAt || "unavailable"); setStatus("media-status", `${file.name} uploaded.`); clearStoredUploadId(fingerprint); await refreshOverview(); await refreshSchedule(); } catch (error) { console.error("Failed to upload media", error); const details = error instanceof Error ? error.message : "Unknown upload error."; setStatus( "media-status", `Upload interrupted. Re-select the same file to resume. (${details})`, true, ); } finally { state.mediaUploading = false; setTimeout(() => hideUploadProgress(), 1200); } } async function editMedia(file: MediaEntry) { if (!file.id) { setStatus("media-status", "Cannot edit media item without id.", true); return; } const title = window.prompt("Title", file.title || file.name || ""); if (title === null) return; const status = window.prompt("Status", file.status || "ready"); if (status === null) return; const tags = window.prompt("Tags (comma-separated)", file.tags || ""); if (tags === null) return; setStatus("media-status", `Saving metadata for ${file.name}...`); try { const response = await fetch(`/api/admin/media/${file.id}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ title, status, tags }), }); if (!response.ok) throw new Error(`HTTP ${response.status}`); setStatus("media-status", "Media metadata updated."); await refreshMediaLibrary(); await refreshOverview(); await refreshSchedule(); } catch (error) { console.error("Failed to update media metadata", error); setStatus("media-status", "Failed to update media metadata.", true); } } async function deleteMedia(file: MediaEntry) { if (!file.id) { setStatus("media-status", "Cannot delete media item without id.", true); return; } if (!window.confirm(`Delete ${file.name}? This removes file and metadata.`)) { return; } setStatus("media-status", `Deleting ${file.name}...`); try { const response = await fetch(`/api/admin/media/${file.id}`, { method: "DELETE", }); if (!response.ok) throw new Error(`HTTP ${response.status}`); setStatus("media-status", `${file.name} deleted.`); await refreshMediaLibrary(); await refreshOverview(); await refreshSchedule(); } catch (error) { console.error("Failed to delete media", error); setStatus("media-status", "Failed to delete media file.", true); } } async function refreshModerationLogs() { setStatus("moderation-status", "Loading moderation logs..."); try { const response = await fetch( `/api/admin/moderation/logs?limit=${adminConfig.moderationLogLimit}`, ); if (!response.ok) throw new Error(`HTTP ${response.status}`); const payload = await response.json(); const tbody = document.getElementById("moderation-table-body"); if (!tbody) return; tbody.innerHTML = ""; const rows = payload.logs || []; if (rows.length === 0) { tbody.innerHTML = `No moderation actions logged yet.`; } else { rows.forEach((entry: any) => { const tr = document.createElement("tr"); tr.innerHTML = `${entry.timestamp}${entry.action}${entry.target}${entry.moderator}${entry.reason}`; tbody.appendChild(tr); }); } setStatus("moderation-status", "Moderation logs loaded."); } catch (error) { console.error("Failed to load moderation logs", error); setStatus("moderation-status", "Failed to load moderation logs.", true); } } async function submitModerationAction(event: Event) { event.preventDefault(); if (state.moderationSubmitting) return; state.moderationSubmitting = true; setStatus("moderation-status", "Submitting moderation action..."); const payload = { action: (document.getElementById("mod-action") as HTMLSelectElement).value, target: (document.getElementById("mod-target") as HTMLInputElement).value, moderator: (document.getElementById("mod-moderator") as HTMLInputElement) .value, reason: (document.getElementById("mod-reason") as HTMLTextAreaElement) .value, room: (document.getElementById("mod-room") as HTMLInputElement).value, }; try { const response = await fetch("/api/admin/moderation/actions", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); if (!response.ok) throw new Error(`HTTP ${response.status}`); setStatus("moderation-status", "Moderation action recorded."); (document.getElementById("mod-target") as HTMLInputElement).value = ""; (document.getElementById("mod-reason") as HTMLTextAreaElement).value = ""; await refreshModerationLogs(); await refreshOverview(); } catch (error) { console.error("Failed to submit moderation action", error); setStatus("moderation-status", "Failed to submit moderation action.", true); } finally { state.moderationSubmitting = false; } } // --- 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="chat-mute"]')?.addEventListener("click", () => { userAction("mute", entry.username); }); tr.querySelector('[data-action="chat-ban"]')?.addEventListener("click", () => { if (confirm(`Ban ${entry.username}?`)) userAction("ban", entry.username); }); 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 { const response = await fetch("/api/admin/users"); if (!response.ok) throw new Error(`HTTP ${response.status}`); const payload = await response.json(); const tbody = document.getElementById("users-table-body"); if (!tbody) return; tbody.innerHTML = ""; const users = payload.users || []; if (users.length === 0) { tbody.innerHTML = `No active users reported.`; } else { users.forEach((user: any) => { const tr = document.createElement("tr"); tr.innerHTML = `${user.username}${user.room}${user.online ? "yes" : "no"} `; tbody.appendChild(tr); }); tbody.querySelectorAll(".user-mute").forEach(b => b.addEventListener("click", () => userAction("mute", (b as HTMLElement).dataset.user!))); tbody.querySelectorAll(".user-ban").forEach(b => b.addEventListener("click", () => userAction("ban", (b as HTMLElement).dataset.user!))); tbody.querySelectorAll(".user-unmute").forEach(b => b.addEventListener("click", () => userAction("unmute", (b as HTMLElement).dataset.user!))); tbody.querySelectorAll(".user-unban").forEach(b => b.addEventListener("click", () => userAction("unban", (b as HTMLElement).dataset.user!))); } setStatus( "users-status", payload.redisEnabled ? "Active users loaded." : "Redis disabled; no live presence data available.", ); } catch (error) { console.error("Failed to load users", error); setStatus("users-status", "Failed to load active users.", true); } } async function userAction(action: string, username: string) { try { const resp = await fetch("/api/admin/users", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action, username, duration: 10 }), }); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); setStatus("users-status", `${action} ${username} OK.`); } catch (err) { setStatus("users-status", `Failed: ${err}`, true); } } async function loadConfig() { try { const resp = await fetch("/api/admin/config"); if (!resp.ok) return; const cfg = await resp.json(); (document.getElementById("config-rate-max") as HTMLInputElement).value = cfg.rateLimitMax; (document.getElementById("config-rate-window") as HTMLInputElement).value = cfg.rateLimitWindowSec; } catch { /* non-critical */ } } async function saveConfig(e: Event) { e.preventDefault(); const status = document.getElementById("config-status"); if (status) status.textContent = "Saving..."; try { const resp = await fetch("/api/admin/config", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ rateLimitMax: Number((document.getElementById("config-rate-max") as HTMLInputElement).value), rateLimitWindowSec: Number((document.getElementById("config-rate-window") as HTMLInputElement).value), }), }); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); if (status) { status.textContent = "Config saved."; status.className = "status-line"; } } catch (err) { if (status) { status.textContent = `Failed: ${err}`; status.className = "status-line status-error"; } } } async function restartService(service: string) { if (!confirm(`Restart ${service}? This may cause brief interruption.`)) return; const status = document.getElementById("restart-status"); if (status) status.textContent = `Requesting restart of ${service}...`; try { const resp = await fetch(`/api/admin/restart?service=${service}`, { method: "POST" }); const data = await resp.json(); if (status) status.textContent = data.message; } catch (err) { if (status) status.textContent = `Failed: ${err}`; } } function bindEvents() { document .querySelectorAll(".tab-trigger") .forEach((button) => { button.addEventListener("click", () => { const tab = button.dataset.tab; if (tab) switchTab(tab); }); }); document.getElementById("logout-btn")?.addEventListener("click", async () => { try { await fetch("/api/admin/logout", { method: "POST" }); } finally { window.location.href = "/admin/login/"; } }); } // 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()); 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; case "playlist": document .getElementById("reload-playlist-btn") ?.addEventListener("click", refreshPlaylist); document .getElementById("save-playlist-btn") ?.addEventListener("click", savePlaylist); break; case "schedule": document .getElementById("reload-schedule-btn") ?.addEventListener("click", refreshSchedule); document .getElementById("override-form") ?.addEventListener("submit", submitOverride); refreshOverrides(); break; case "moderation": document .getElementById("reload-moderation-btn") ?.addEventListener("click", refreshModerationLogs); document .getElementById("export-chat-log-btn") ?.addEventListener("click", () => { window.open("/api/admin/chat-log/export", "_blank"); }); 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); document .getElementById("config-form") ?.addEventListener("submit", saveConfig); document.querySelectorAll(".restart-btn").forEach((btn) => btn.addEventListener("click", () => restartService((btn as HTMLElement).dataset.service!)) ); loadConfig(); break; } } async function init() { bindEvents(); // Load dashboard as the initial tab await switchTab("dashboard"); } document.addEventListener("DOMContentLoaded", () => { init().catch((error) => console.error("Admin initialization failed", error)); });