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:
1 parent
7cfddfc7ef
commit
fca4286d87
10 files changed
+1831
-1525
No files matched your search
@@ -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));
|
||||
});
|
||||
+47
-64
@@ -1,10 +1,8 @@
|
||||
import Hls from "hls.js";
|
||||
import "./style.css";
|
||||
|
||||
// Main application state with Alpine.js
|
||||
declare global {
|
||||
interface Window {
|
||||
app: () => any;
|
||||
switchRoom: (room: string) => void;
|
||||
sendMessage: () => void;
|
||||
togglePrivateDM: () => void;
|
||||
@@ -89,17 +87,21 @@ function addMessage(
|
||||
) {
|
||||
const messages = document.getElementById("messages")!;
|
||||
const div = document.createElement("div");
|
||||
div.className = `message flex ${type === "system" ? "justify-center" : sender === username ? "justify-end" : "justify-start"}`;
|
||||
div.className = "message-row";
|
||||
|
||||
if (type === "system") {
|
||||
div.innerHTML = `<div class="text-xs text-slate-400 bg-slate-800 px-3 py-1 rounded-full">${text}</div>`;
|
||||
div.classList.add("message-system-row");
|
||||
div.innerHTML = `<div class="message-system">${text}</div>`;
|
||||
} else {
|
||||
div.classList.add(
|
||||
sender === username ? "message-self-row" : "message-other-row",
|
||||
);
|
||||
div.innerHTML = `
|
||||
<div class="${sender === username ? "bg-blue-600" : "bg-slate-700"} px-4 py-2 rounded-3xl max-w-[75%]">
|
||||
${sender && sender !== username ? `<div class="text-xs text-blue-300">${sender}</div>` : ""}
|
||||
<div>${text}</div>
|
||||
<div class="text-[10px] text-slate-400 mt-1">${new Date(timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}</div>
|
||||
</div>
|
||||
<article class="message-bubble ${sender === username ? "message-self" : "message-other"}">
|
||||
${sender && sender !== username ? `<div class="message-sender">${sender}</div>` : ""}
|
||||
<div class="message-text">${text}</div>
|
||||
<time class="message-time">${new Date(timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}</time>
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -107,6 +109,41 @@ function addMessage(
|
||||
messages.scrollTop = messages.scrollHeight;
|
||||
}
|
||||
|
||||
function initPlayerAndChat() {
|
||||
const video = document.getElementById("player") as HTMLVideoElement;
|
||||
const manifestUrl = resolveManifestUrl();
|
||||
|
||||
if (Hls.isSupported()) {
|
||||
hls = new Hls();
|
||||
hls.loadSource(manifestUrl);
|
||||
hls.attachMedia(video);
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, () => video.play());
|
||||
} else if (video.canPlayType("application/vnd.apple.mpegurl")) {
|
||||
video.src = manifestUrl;
|
||||
video.addEventListener("loadedmetadata", () => video.play());
|
||||
}
|
||||
|
||||
connectWebSocket("main");
|
||||
|
||||
setInterval(() => {
|
||||
const titles = [
|
||||
"Midnight Jazz Lounge",
|
||||
"Synthwave Nights",
|
||||
"Classic Cinema",
|
||||
"Ambient Void",
|
||||
];
|
||||
const title = titles[Math.floor(Math.random() * titles.length)];
|
||||
document.getElementById("now-playing")!.textContent = title;
|
||||
const overlay = document.getElementById("now-playing-overlay")!;
|
||||
overlay.classList.remove("hidden");
|
||||
document.getElementById("overlay-title")!.textContent = title;
|
||||
}, 15000);
|
||||
|
||||
console.log(
|
||||
"Linear Void Frontend initialized - HLS Player + Chat Sidebar ready",
|
||||
);
|
||||
}
|
||||
|
||||
function sendMessage() {
|
||||
const input = document.getElementById("message-input") as HTMLInputElement;
|
||||
const text = input.value.trim();
|
||||
@@ -141,64 +178,10 @@ function togglePrivateDM() {
|
||||
}
|
||||
}
|
||||
|
||||
// Alpine.js app
|
||||
window.app = () => {
|
||||
return {
|
||||
init() {
|
||||
// Initialize Tailwind
|
||||
// @ts-ignore
|
||||
tailwind.config = {
|
||||
content: [],
|
||||
theme: { extend: {} },
|
||||
};
|
||||
|
||||
// Initialize HLS Player
|
||||
const video = document.getElementById("player") as HTMLVideoElement;
|
||||
const manifestUrl = resolveManifestUrl();
|
||||
|
||||
if (Hls.isSupported()) {
|
||||
hls = new Hls();
|
||||
hls.loadSource(manifestUrl);
|
||||
hls.attachMedia(video);
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, () => video.play());
|
||||
} else if (video.canPlayType("application/vnd.apple.mpegurl")) {
|
||||
video.src = manifestUrl;
|
||||
video.addEventListener("loadedmetadata", () => video.play());
|
||||
}
|
||||
|
||||
// Connect to default chat room
|
||||
connectWebSocket("main");
|
||||
|
||||
// Simulate now playing
|
||||
setInterval(() => {
|
||||
const titles = [
|
||||
"Midnight Jazz Lounge",
|
||||
"Synthwave Nights",
|
||||
"Classic Cinema",
|
||||
"Ambient Void",
|
||||
];
|
||||
document.getElementById("now-playing")!.textContent =
|
||||
titles[Math.floor(Math.random() * titles.length)];
|
||||
const overlay = document.getElementById("now-playing-overlay")!;
|
||||
overlay.classList.remove("hidden");
|
||||
document.getElementById("overlay-title")!.textContent =
|
||||
titles[Math.floor(Math.random() * titles.length)];
|
||||
}, 15000);
|
||||
|
||||
console.log(
|
||||
"Linear Void Frontend initialized - HLS Player + Chat Sidebar ready",
|
||||
);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
// Make functions global for onclick handlers
|
||||
window.switchRoom = switchRoom;
|
||||
window.sendMessage = sendMessage;
|
||||
window.togglePrivateDM = togglePrivateDM;
|
||||
|
||||
// Auto initialize when loaded
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
// @ts-ignore
|
||||
window.app().init();
|
||||
initPlayerAndChat();
|
||||
});
|
||||
+814
-289
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user