Playout: slate fallback, strict ffprobe validation, Prometheus metrics Chat: history on join, DM offline delivery, env-config rate limits, Redis auto-reconnect, heartbeat, ban/mute enforcement, DM metrics Viewer: presence indicators, schedule overlay, public /api/schedule Admin: playlist drag-reorder, schedule overrides CRUD, user actions, inline mod buttons, CSV export, config editor, restart buttons, upload normalization gate, analytics time range nginx: CORS preflight, JSON health, stub_status Docs: test-lab vs production distinction across all configs/docs Infra: Redis maxmemory, CD health IP-agnostic, slate volume mount
1331 lines
45 KiB
TypeScript
1331 lines
45 KiB
TypeScript
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<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");
|
|
|
|
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 = `<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();
|
|
}
|
|
|
|
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 = `<tr><td colspan="2">No room analytics available yet.</td></tr>`;
|
|
} else {
|
|
rows.forEach((row) => {
|
|
const tr = document.createElement("tr");
|
|
tr.innerHTML = `<td>${row.room}</td><td>${row.messageCount}</td>`;
|
|
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 `<li class="sortable-item" draggable="true" data-index="${i}">
|
|
<span class="drag-handle" aria-hidden="true">⋮⋮</span>
|
|
<span class="sortable-index">${i + 1}</span>
|
|
<span class="sortable-path">${path}</span>
|
|
<button type="button" class="btn btn-secondary btn-sm" data-remove="${i}">✕</button>
|
|
</li>`;
|
|
})
|
|
.join("");
|
|
|
|
let dragSrc: number | null = null;
|
|
list.querySelectorAll<HTMLElement>(".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) => `<div class="warning-msg">⚠ ${m}</div>`).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 = `<tr><td colspan="5">No schedule entries found.</td></tr>`;
|
|
} else {
|
|
entries.forEach((entry: any) => {
|
|
const tr = document.createElement("tr");
|
|
tr.innerHTML = `<td>${entry.index}</td><td>${entry.label}</td><td>${entry.runtime || "00:00:00"}</td><td>${entry.status || "missing"}</td><td>${entry.path}</td>`;
|
|
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 = `<tr><td colspan="5">No overrides.</td></tr>`;
|
|
return;
|
|
}
|
|
tbody.innerHTML = overrides
|
|
.map(
|
|
(o: { id: number; mediaId: number; startTime: string; endTime: string; reason: string }) =>
|
|
`<tr><td>${o.mediaId}</td><td>${new Date(o.startTime).toLocaleString()}</td><td>${new Date(o.endTime).toLocaleString()}</td><td>${o.reason || "-"}</td><td><button class="btn btn-secondary btn-sm" data-delete-override="${o.id}">✕</button></td></tr>`,
|
|
)
|
|
.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 = `
|
|
<div>
|
|
<h3>${service.name}</h3>
|
|
<p class="small-muted">${service.checked}</p>
|
|
</div>
|
|
<div class="service-row-right">
|
|
<p class="service-status service-status-${service.status}">${service.status}</p>
|
|
<p>${service.detail}</p>
|
|
</div>
|
|
`;
|
|
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 = `<tr><td colspan="7">No media files available.</td></tr>`;
|
|
return;
|
|
}
|
|
|
|
files.forEach((file) => {
|
|
const tr = document.createElement("tr");
|
|
tr.innerHTML = `
|
|
<td>${file.name}</td>
|
|
<td>${file.title || "-"}</td>
|
|
<td>${file.runtime || "00:00:00"}</td>
|
|
<td>${file.status || "unknown"}</td>
|
|
<td>${fmtSize(file.size)}</td>
|
|
<td>${file.modifiedAt || "-"}</td>
|
|
<td class="action-cell">
|
|
<button type="button" class="btn btn-secondary btn-small" data-action="edit" data-id="${file.id || ""}">Edit</button>
|
|
<button type="button" class="btn btn-danger btn-small" data-action="delete" data-id="${file.id || ""}">Delete</button>
|
|
</td>
|
|
`;
|
|
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 = `<tr><td colspan="5">No moderation actions logged yet.</td></tr>`;
|
|
} else {
|
|
rows.forEach((entry: any) => {
|
|
const tr = document.createElement("tr");
|
|
tr.innerHTML = `<td>${entry.timestamp}</td><td>${entry.action}</td><td>${entry.target}</td><td>${entry.moderator}</td><td>${entry.reason}</td>`;
|
|
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<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-secondary btn-sm" data-action="chat-mute" data-user="${entry.username}">Mute</button>
|
|
<button type="button" class="btn btn-secondary btn-sm" data-action="chat-ban" data-user="${entry.username}">Ban</button>
|
|
<button type="button" class="btn btn-danger btn-sm" data-action="delete-chat" data-id="${entry.id}">Del</button>
|
|
</td>
|
|
`;
|
|
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 = `<tr><td colspan="3">No active users reported.</td></tr>`;
|
|
} else {
|
|
users.forEach((user: any) => {
|
|
const tr = document.createElement("tr");
|
|
tr.innerHTML = `<td>${user.username}</td><td>${user.room}</td><td>${user.online ? "yes" : "no"}</td>
|
|
<td>
|
|
<button class="btn btn-secondary btn-sm user-mute" data-user="${user.username}">Mute 10m</button>
|
|
<button class="btn btn-secondary btn-sm user-ban" data-user="${user.username}">Ban</button>
|
|
<button class="btn btn-secondary btn-sm user-unmute" data-user="${user.username}">Unmute</button>
|
|
<button class="btn btn-secondary btn-sm user-unban" data-user="${user.username}">Unban</button>
|
|
</td>`;
|
|
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<HTMLButtonElement>(".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));
|
|
});
|