feat: add chat log admin management and refactor admin panel to lazy-load tab content

This commit is contained in:
zwitschi committed 2026-08-05 11:29:11 +02:00
1 parent f650a3a94e
commit 74133ec589
12 files changed
+921 -387

No files matched your search

+405 -80
View File
@@ -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<string, string> = {};
async function loadPageContent(tabName: string): Promise<string> {
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<HTMLButtonElement>(".tab-trigger");
const panels = document.querySelectorAll<HTMLElement>(".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 = `<p class="status-error">Failed to load ${tabName} page.</p>`;
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<number>(),
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 = `<tr><td colspan="8">No chat log entries found.</td></tr>`;
return;
}
chatLogState.entries.forEach((entry) => {
const tr = document.createElement("tr");
const checked = chatLogState.selected.has(entry.id) ? "checked" : "";
tr.innerHTML = `
<td><input type="checkbox" class="chat-log-checkbox" data-id="${entry.id}" ${checked} /></td>
<td>${entry.id}</td>
<td>${entry.room}</td>
<td>${entry.username}</td>
<td>${entry.text}</td>
<td>${entry.type}</td>
<td>${entry.timestamp}</td>
<td class="action-cell">
<button type="button" class="btn btn-danger btn-small" data-action="delete-chat" data-id="${entry.id}">Delete</button>
</td>
`;
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", () => {