Refactor frontend: Enhance chat UI and player initialization

- Updated main.ts to streamline player and chat initialization, removing Alpine.js dependency.
- Improved message rendering with new CSS classes for better styling and layout.
- Added a function to simulate "now playing" titles at regular intervals.
- Revamped style.css with a new design system using CSS variables for better maintainability.
- Removed tailwind.config.js as Tailwind CSS is no longer used.
This commit is contained in:
zwitschi committed 2026-08-04 19:36:11 +02:00
1 parent 7cfddfc7ef
commit fca4286d87
10 files changed
+1831 -1525

No files matched your search

+604
View File
@@ -0,0 +1,604 @@
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 state = {
mediaUploading: false,
moderationSubmitting: false,
playlistSaving: false,
};
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]}`;
};
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;
trigger.classList.toggle("is-active", active);
trigger.setAttribute("aria-selected", String(active));
});
panels.forEach((panel) => {
const active = panel.id === `tab-${tabName}`;
panel.hidden = !active;
panel.classList.toggle("is-active", active);
});
if (tabName === "upload") refreshMediaLibrary();
if (tabName === "playlist") refreshPlaylist();
if (tabName === "schedule") refreshSchedule();
if (tabName === "moderation") refreshModerationLogs();
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 textarea = document.getElementById(
"playlist-content",
) as HTMLTextAreaElement | null;
if (textarea) textarea.value = payload.content || "";
setStatus("playlist-status", "Playlist loaded.");
} catch (error) {
console.error("Failed to load playlist", error);
setStatus("playlist-status", "Failed to load playlist.", 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 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;
state.mediaUploading = true;
setStatus("media-status", `Uploading ${file.name}...`);
try {
const formData = new FormData();
formData.append("file", file);
const response = await fetch("/api/admin/media", {
method: "POST",
body: formData,
});
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", `${file.name} uploaded.`);
await refreshOverview();
await refreshSchedule();
} catch (error) {
console.error("Failed to upload media", error);
setStatus("media-status", "Failed to upload media file.", true);
} finally {
state.mediaUploading = false;
}
}
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;
}
}
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>`;
tbody.appendChild(tr);
});
}
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);
}
}
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/";
}
});
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);
const uploadInput = document.getElementById(
"video-upload",
) as HTMLInputElement | null;
document
.getElementById("upload-trigger-btn")
?.addEventListener("click", () => uploadInput?.click());
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 = "";
});
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;
}
async function init() {
bindEvents();
await Promise.all([
refreshOverview(),
refreshAnalytics(),
refreshMediaLibrary(),
refreshPlaylist(),
refreshSchedule(),
refreshModerationLogs(),
refreshSystemSummary(),
refreshUsers(),
]);
}
document.addEventListener("DOMContentLoaded", () => {
init().catch((error) => console.error("Admin initialization failed", error));
});