feat: runtime config, Matomo analytics, chat accounts, DM history/search, and Prometheus retention
This commit is contained in:
1 parent
06ab5b4655
commit
121fdbacdc
27 files changed
+1841
-161
No files matched your search
+102
-70
@@ -1,81 +1,113 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Linear Void - 24/7 Synchronized Channel</title>
|
||||
<link rel="stylesheet" href="/src/style.css" />
|
||||
</head>
|
||||
|
||||
<body class="lv-body">
|
||||
<main class="viewer-shell" aria-label="Live player and chat">
|
||||
<header class="viewer-header">
|
||||
<div class="viewer-brand-group">
|
||||
<div class="brand-badge" aria-hidden="true">LV</div>
|
||||
<div>
|
||||
<h1 class="viewer-title">Linear Void</h1>
|
||||
<p class="viewer-subtitle">24/7 Synchronized Channel • Live</p>
|
||||
</div>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Linear Void - 24/7 Synchronized Channel</title>
|
||||
<link rel="stylesheet" href="/src/style.css" />
|
||||
</head>
|
||||
|
||||
<body class="lv-body">
|
||||
<main class="viewer-shell" aria-label="Live player and chat">
|
||||
<header class="viewer-header">
|
||||
<div class="viewer-brand-group">
|
||||
<div class="brand-badge" aria-hidden="true">LV</div>
|
||||
<div>
|
||||
<h1 class="viewer-title">Linear Void</h1>
|
||||
<p class="viewer-subtitle">24/7 Synchronized Channel • Live</p>
|
||||
</div>
|
||||
<button
|
||||
id="chat-toggle"
|
||||
type="button"
|
||||
class="btn btn-secondary chat-toggle-btn"
|
||||
onclick="toggleChatPanel()"
|
||||
aria-controls="chat-panel"
|
||||
aria-expanded="true"
|
||||
>
|
||||
Hide Chat
|
||||
</button>
|
||||
</header>
|
||||
</div>
|
||||
<button id="chat-toggle" type="button" class="btn btn-secondary chat-toggle-btn" onclick="toggleChatPanel()" aria-controls="chat-panel" aria-expanded="true">
|
||||
Hide Chat
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<section class="viewer-content" aria-label="Live stream and chat layout">
|
||||
<section class="player-stage" aria-label="Video stage">
|
||||
<video id="player" class="player" controls autoplay muted playsinline></video>
|
||||
<div id="now-playing" class="now-playing-overlay" aria-live="polite">
|
||||
<span id="np-title">Loading...</span>
|
||||
<span id="np-next" class="np-next"></span>
|
||||
<section class="viewer-content" aria-label="Live stream and chat layout">
|
||||
<section class="player-stage" aria-label="Video stage">
|
||||
<video id="player" class="player" controls autoplay muted playsinline></video>
|
||||
<div id="now-playing" class="now-playing-overlay" aria-live="polite">
|
||||
<span id="np-title">Loading...</span>
|
||||
<span id="np-next" class="np-next"></span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside id="chat-panel" class="chat-sidebar" aria-label="Chat panel">
|
||||
<header class="chat-header">
|
||||
<div class="chat-room-title">
|
||||
<span class="chat-live-marker" aria-hidden="true">●</span>
|
||||
<span id="room-name">Main Channel</span>
|
||||
</div>
|
||||
<div class="chat-header-actions">
|
||||
<span id="chat-user-label" class="chat-user-label" hidden></span>
|
||||
<button id="auth-toggle-btn" type="button" class="btn btn-secondary">Sign in</button>
|
||||
<button id="auth-logout-btn" type="button" class="btn btn-secondary" hidden>Sign out</button> <button id="users-toggle-btn" type="button" class="btn btn-secondary" aria-controls="presence-panel" aria-expanded="true">Users</button> <button type="button" class="btn btn-secondary" onclick="togglePrivateDM()">New DM</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div id="auth-panel" class="auth-panel" hidden>
|
||||
<input id="auth-username" type="text" class="input" placeholder="Username" autocomplete="username" />
|
||||
<input id="auth-password" type="password" class="input" placeholder="Password" autocomplete="current-password" />
|
||||
<div class="button-row">
|
||||
<button id="auth-login-btn" type="button" class="btn btn-primary">Log in</button>
|
||||
<button id="auth-register-btn" type="button" class="btn btn-secondary">Register</button>
|
||||
</div>
|
||||
<p id="auth-status" class="status-line" aria-live="polite"></p>
|
||||
</div>
|
||||
|
||||
<div id="dm-search-row" class="dm-search-row" hidden>
|
||||
<input id="dm-search-input" type="text" class="input" placeholder="Search this conversation..." />
|
||||
<button id="dm-search-btn" type="button" class="btn btn-secondary">Search</button>
|
||||
</div>
|
||||
|
||||
<div id="presence-list" class="presence-list"></div>
|
||||
</section>
|
||||
|
||||
<aside id="chat-panel" class="chat-sidebar" aria-label="Chat panel">
|
||||
<header class="chat-header">
|
||||
<div class="chat-room-title">
|
||||
<span class="chat-live-marker" aria-hidden="true">●</span>
|
||||
<span id="room-name">Main Channel</span>
|
||||
</div>
|
||||
<button type="button" class="btn btn-secondary" onclick="togglePrivateDM()">New DM</button>
|
||||
</header>
|
||||
|
||||
<section id="messages" class="chat-messages" aria-live="polite" aria-label="Message stream"></section>
|
||||
|
||||
<section id="presence-panel" class="presence-panel" aria-label="Online users">
|
||||
<div id="presence-list" class="presence-list"></div>
|
||||
</section>
|
||||
|
||||
<div class="chat-input-area">
|
||||
<div class="chat-input-row">
|
||||
<label for="message-input" class="sr-only">Message input</label>
|
||||
<input
|
||||
id="message-input"
|
||||
type="text"
|
||||
placeholder="Type a message..."
|
||||
class="input"
|
||||
onkeydown="if (event.key === 'Enter') sendMessage();"
|
||||
/>
|
||||
<button type="button" class="btn btn-primary" onclick="sendMessage()">Send</button>
|
||||
</div>
|
||||
<p class="chat-note">Synced with video • Moderation active</p>
|
||||
<div class="chat-input-area">
|
||||
<div class="chat-input-row">
|
||||
<label for="message-input" class="sr-only">Message input</label>
|
||||
<input id="message-input" type="text" placeholder="Type a message..." class="input" onkeydown="if (event.key === 'Enter') sendMessage();" />
|
||||
<button type="button" class="btn btn-primary" onclick="sendMessage()">Send</button>
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
</main>
|
||||
<p class="chat-note">Synced with video • Moderation active</p>
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div id="reconnecting-overlay" class="reconnecting-overlay hidden" role="alert" aria-live="assertive">
|
||||
<div class="reconnecting-spinner"></div>
|
||||
<span id="reconnecting-text">Reconnecting...</span>
|
||||
</div>
|
||||
<div id="reconnecting-overlay" class="reconnecting-overlay hidden" role="alert" aria-live="assertive">
|
||||
<div class="reconnecting-spinner"></div>
|
||||
<span id="reconnecting-text">Reconnecting...</span>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
<script>
|
||||
(function () {
|
||||
// Load Matomo analytics when configured via the admin runtime config.
|
||||
fetch("/api/matomo")
|
||||
.then(function (r) {
|
||||
return r.ok ? r.json() : null;
|
||||
})
|
||||
.then(function (cfg) {
|
||||
if (!cfg || !cfg.matomoUrl || !cfg.matomoSiteId) return;
|
||||
var _paq = (window._paq = window._paq || []);
|
||||
_paq.push(["trackPageView"]);
|
||||
_paq.push(["enableLinkTracking"]);
|
||||
var base = String(cfg.matomoUrl).replace(/\/+$/, "");
|
||||
_paq.push(["setTrackerUrl", base + "/matomo.php"]);
|
||||
_paq.push(["setSiteId", String(cfg.matomoSiteId)]);
|
||||
var d = document;
|
||||
var g = d.createElement("script");
|
||||
var s = d.getElementsByTagName("script")[0];
|
||||
g.async = true;
|
||||
g.src = base + "/matomo.js";
|
||||
s.parentNode.insertBefore(g, s);
|
||||
})
|
||||
.catch(function () {
|
||||
/* analytics is optional */
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -56,8 +56,13 @@
|
||||
<div class="panel-card">
|
||||
<h2>Runtime Configuration</h2>
|
||||
<form id="config-form" class="form-stack" style="max-width: 400px">
|
||||
<label>HLS Segment Duration (seconds) <input id="config-hls-duration" type="number" min="2" max="15" class="text-input" /></label>
|
||||
<label>HLS Window Size (segments) <input id="config-hls-window" type="number" min="3" max="100" class="text-input" /></label>
|
||||
<label>Matomo URL <input id="config-matomo-url" type="url" class="text-input" placeholder="https://..." /></label>
|
||||
<label>Matomo Site ID <input id="config-matomo-site-id" type="text" class="text-input" /></label>
|
||||
<label>Rate Limit Max <input id="config-rate-max" type="number" class="text-input" /></label>
|
||||
<label>Rate Limit Window (seconds) <input id="config-rate-window" type="number" class="text-input" /></label>
|
||||
<p class="small-muted">HLS changes take effect after restarting the playout service.</p>
|
||||
<button type="submit" class="btn btn-primary">Save Config</button>
|
||||
</form>
|
||||
<p id="config-status" class="status-line" aria-live="polite"></p>
|
||||
|
||||
+95
-6
@@ -16,6 +16,7 @@ type MediaEntry = {
|
||||
runtime?: string;
|
||||
modifiedAt?: string;
|
||||
tags?: string;
|
||||
path?: string;
|
||||
};
|
||||
|
||||
const bodyDataset = document.body.dataset;
|
||||
@@ -246,7 +247,8 @@ async function refreshPlaylist() {
|
||||
const content: string = payload.content || "";
|
||||
const textarea = document.getElementById("playlist-content") as HTMLTextAreaElement | null;
|
||||
if (textarea) textarea.value = content;
|
||||
renderPlaylistSortable(content);
|
||||
const statusMap = await fetchMediaStatusMap();
|
||||
renderPlaylistSortable(content, statusMap);
|
||||
await showPlaylistWarnings(content);
|
||||
setStatus("playlist-status", "Playlist loaded.");
|
||||
} catch (error) {
|
||||
@@ -255,7 +257,7 @@ async function refreshPlaylist() {
|
||||
}
|
||||
}
|
||||
|
||||
export function renderPlaylistSortable(content: string) {
|
||||
export function renderPlaylistSortable(content: string, statusMap?: Map<string, string>) {
|
||||
const list = document.getElementById("playlist-sortable");
|
||||
if (!list) return;
|
||||
const lines = content.split("\n").filter((l) => l.trim() && !l.startsWith("#"));
|
||||
@@ -265,10 +267,12 @@ export function renderPlaylistSortable(content: string) {
|
||||
.replace(/^file\s+/, "")
|
||||
.replace(/['"]/g, "")
|
||||
.trim();
|
||||
const badge = statusMap ? statusBadge(statusMap.get(path)) : "";
|
||||
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>
|
||||
${badge}
|
||||
<button type="button" class="btn btn-secondary btn-sm" data-remove="${i}">✕</button>
|
||||
</li>`;
|
||||
})
|
||||
@@ -352,6 +356,80 @@ async function showPlaylistWarnings(content: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function statusBadge(status: string | undefined): string {
|
||||
if (!status) return `<span class="badge badge-muted">unknown</span>`;
|
||||
switch (status) {
|
||||
case "ready":
|
||||
return `<span class="badge badge-ok">ready</span>`;
|
||||
case "needs_normalization":
|
||||
return `<span class="badge badge-warn">needs normalization</span>`;
|
||||
case "normalizing":
|
||||
return `<span class="badge badge-info">normalizing</span>`;
|
||||
case "normalization_failed":
|
||||
return `<span class="badge badge-danger">failed</span>`;
|
||||
default:
|
||||
return `<span class="badge badge-muted">${status}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchMediaStatusMap(): Promise<Map<string, string>> {
|
||||
const map = new Map<string, string>();
|
||||
try {
|
||||
const resp = await fetch("/api/admin/media");
|
||||
if (!resp.ok) return map;
|
||||
const payload = await resp.json();
|
||||
for (const f of payload.files || []) {
|
||||
if (f.path) map.set(f.path, f.status || "unknown");
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
async function addMediaToPlaylist(file: MediaEntry) {
|
||||
if (file.status !== "ready") {
|
||||
setStatus("media-status", "Only ready media can be added to the playlist.", true);
|
||||
return;
|
||||
}
|
||||
if (!file.path) {
|
||||
setStatus("media-status", "Cannot add media without a path.", true);
|
||||
return;
|
||||
}
|
||||
setStatus("media-status", `Adding ${file.name} to playlist...`);
|
||||
try {
|
||||
const getResp = await fetch("/api/admin/playlist");
|
||||
if (!getResp.ok) throw new Error(await readErrorMessage(getResp));
|
||||
const payload = await getResp.json();
|
||||
const content: string = payload.content || "";
|
||||
const lines = content.split("\n").filter((l) => l.trim());
|
||||
const entry = `file '${file.path}'`;
|
||||
const exists = lines.some(
|
||||
(l) =>
|
||||
l
|
||||
.replace(/^file\s+/, "")
|
||||
.replace(/['"]/g, "")
|
||||
.trim() === file.path,
|
||||
);
|
||||
if (exists) {
|
||||
setStatus("media-status", `${file.name} is already in the playlist.`, true);
|
||||
return;
|
||||
}
|
||||
lines.push(entry);
|
||||
const putResp = await fetch("/api/admin/playlist", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ content: lines.join("\n") + "\n" }),
|
||||
});
|
||||
if (!putResp.ok) throw new Error(await readErrorMessage(putResp));
|
||||
setStatus("media-status", `${file.name} added to playlist.`);
|
||||
} catch (error) {
|
||||
console.error("Failed to add media to playlist", error);
|
||||
const details = error instanceof Error ? error.message : "Unknown error.";
|
||||
setStatus("media-status", `Failed to add ${file.name} to playlist. (${details})`, true);
|
||||
}
|
||||
}
|
||||
|
||||
export async function savePlaylist() {
|
||||
if (state.playlistSaving) return;
|
||||
state.playlistSaving = true;
|
||||
@@ -556,16 +634,18 @@ function renderMediaRows(files: MediaEntry[]) {
|
||||
files.forEach((file) => {
|
||||
const tr = document.createElement("tr");
|
||||
const canNormalize = file.status === "needs_normalization" || file.status === "normalization_failed";
|
||||
const canAdd = file.status === "ready";
|
||||
tr.innerHTML = `
|
||||
<td>${file.name}</td>
|
||||
<td>${file.title || "-"}</td>
|
||||
<td>${file.runtime || "00:00:00"}</td>
|
||||
<td>${file.status || "unknown"}</td>
|
||||
<td>${statusBadge(file.status)}</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>
|
||||
${canNormalize ? `<button type="button" class="btn btn-secondary btn-small" data-action="normalize" data-id="${file.id || ""}">Normalize</button>` : ""}
|
||||
${canAdd ? `<button type="button" class="btn btn-secondary btn-small" data-action="add" data-id="${file.id || ""}">Add to playlist</button>` : ""}
|
||||
<button type="button" class="btn btn-danger btn-small" data-action="delete" data-id="${file.id || ""}">Delete</button>
|
||||
</td>
|
||||
`;
|
||||
@@ -573,6 +653,9 @@ function renderMediaRows(files: MediaEntry[]) {
|
||||
if (canNormalize) {
|
||||
tr.querySelector('[data-action="normalize"]')?.addEventListener("click", () => normalizeMedia(file));
|
||||
}
|
||||
if (canAdd) {
|
||||
tr.querySelector('[data-action="add"]')?.addEventListener("click", () => addMediaToPlaylist(file));
|
||||
}
|
||||
tr.querySelector('[data-action="delete"]')?.addEventListener("click", () => deleteMedia(file));
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
@@ -1143,6 +1226,10 @@ async function loadConfig() {
|
||||
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;
|
||||
(document.getElementById("config-hls-duration") as HTMLInputElement).value = cfg.hlsSegmentDuration;
|
||||
(document.getElementById("config-hls-window") as HTMLInputElement).value = cfg.hlsWindowSize;
|
||||
(document.getElementById("config-matomo-url") as HTMLInputElement).value = cfg.matomoUrl || "";
|
||||
(document.getElementById("config-matomo-site-id") as HTMLInputElement).value = cfg.matomoSiteId || "";
|
||||
} catch {
|
||||
/* non-critical */
|
||||
}
|
||||
@@ -1157,11 +1244,13 @@ async function saveConfig(e: Event) {
|
||||
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),
|
||||
hlsSegmentDuration: Number((document.getElementById("config-hls-duration") as HTMLInputElement).value),
|
||||
hlsWindowSize: Number((document.getElementById("config-hls-window") as HTMLInputElement).value),
|
||||
matomoUrl: (document.getElementById("config-matomo-url") as HTMLInputElement).value.trim(),
|
||||
matomoSiteId: (document.getElementById("config-matomo-site-id") as HTMLInputElement).value.trim(),
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
if (!resp.ok) throw new Error(await readErrorMessage(resp));
|
||||
if (status) {
|
||||
status.textContent = "Config saved.";
|
||||
status.className = "status-line";
|
||||
|
||||
+185
-1
@@ -15,7 +15,13 @@ let hls: InstanceType<HlsClass> | null = null;
|
||||
let ws: WebSocket | null = null;
|
||||
let currentRoom = "main";
|
||||
let username = "viewer" + Math.floor(Math.random() * 1000);
|
||||
let authenticated = false;
|
||||
let currentDMTarget = "";
|
||||
let chatVisible = true;
|
||||
let usersVisible = true;
|
||||
let typingUser = "";
|
||||
let lastTypingSent = 0;
|
||||
let typingTimeout: number | undefined;
|
||||
const onlineUsers = new Set<string>();
|
||||
|
||||
// Player error recovery state (STREAMING.md contract)
|
||||
@@ -28,7 +34,11 @@ export function resetViewerStateForTests() {
|
||||
ws = null;
|
||||
currentRoom = "main";
|
||||
username = "viewer";
|
||||
authenticated = false;
|
||||
chatVisible = true;
|
||||
usersVisible = true;
|
||||
typingUser = "";
|
||||
lastTypingSent = 0;
|
||||
onlineUsers.clear();
|
||||
playerErrorCount = 0;
|
||||
}
|
||||
@@ -81,6 +91,11 @@ function connectWebSocket(room: string) {
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.type === "typing") {
|
||||
showTyping(msg.username);
|
||||
return;
|
||||
}
|
||||
hideTyping(msg.username);
|
||||
addMessage(msg.type || "public", msg.text, msg.username, msg.timestamp);
|
||||
|
||||
// Track presence from system join/leave messages
|
||||
@@ -183,7 +198,9 @@ export async function initPlayerAndChat(loadHlsModule: () => Promise<{ default:
|
||||
case Hls.ErrorTypes.NETWORK_ERROR:
|
||||
if (playerErrorCount <= PLAYER_MAX_RETRIES) {
|
||||
const delay = PLAYER_BASE_DELAY_MS * Math.pow(2, playerErrorCount - 1);
|
||||
console.warn(`HLS network error (attempt ${playerErrorCount}/${PLAYER_MAX_RETRIES}), retrying in ${delay}ms...`);
|
||||
console.warn(
|
||||
`HLS network error (attempt ${playerErrorCount}/${PLAYER_MAX_RETRIES}), retrying in ${delay}ms...`,
|
||||
);
|
||||
showReconnecting("Reconnecting...");
|
||||
setTimeout(() => {
|
||||
hls?.startLoad();
|
||||
@@ -282,25 +299,192 @@ function sendMessage() {
|
||||
|
||||
function switchRoom(room: string) {
|
||||
currentRoom = room;
|
||||
const isDM = room.startsWith("dm:");
|
||||
document.getElementById("room-name")!.textContent =
|
||||
room === "main" ? "Main Channel" : room.charAt(0).toUpperCase() + room.slice(1) + " Room";
|
||||
addMessage("system", `Switched to ${room} room`);
|
||||
const searchRow = document.getElementById("dm-search-row");
|
||||
if (searchRow) searchRow.hidden = !isDM;
|
||||
connectWebSocket(room);
|
||||
}
|
||||
|
||||
function togglePrivateDM() {
|
||||
const target = prompt("Enter username to start private chat with:");
|
||||
if (target && target !== username) {
|
||||
currentDMTarget = target;
|
||||
const dmRoom = `dm:${[username, target].sort().join("-")}`;
|
||||
switchRoom(dmRoom);
|
||||
}
|
||||
}
|
||||
|
||||
async function searchDM() {
|
||||
const input = document.getElementById("dm-search-input") as HTMLInputElement;
|
||||
const q = input.value.trim();
|
||||
if (!q || !currentDMTarget) return;
|
||||
try {
|
||||
const res = await fetch(`/api/dm/search?with=${encodeURIComponent(currentDMTarget)}&q=${encodeURIComponent(q)}`);
|
||||
if (!res.ok) return;
|
||||
const results = await res.json();
|
||||
addMessage("system", `Search results (${results.length}) for "${q}":`);
|
||||
for (const m of results) {
|
||||
addMessage(m.type || "private", m.text, m.username, m.timestamp);
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
window.switchRoom = switchRoom;
|
||||
window.sendMessage = sendMessage;
|
||||
window.togglePrivateDM = togglePrivateDM;
|
||||
window.toggleChatPanel = toggleChatPanel;
|
||||
|
||||
function updateAuthUI() {
|
||||
const label = document.getElementById("chat-user-label");
|
||||
const toggle = document.getElementById("auth-toggle-btn");
|
||||
const logoutBtn = document.getElementById("auth-logout-btn");
|
||||
const panel = document.getElementById("auth-panel");
|
||||
if (label) {
|
||||
label.textContent = authenticated ? username : "";
|
||||
label.hidden = !authenticated;
|
||||
}
|
||||
if (toggle) toggle.hidden = authenticated;
|
||||
if (logoutBtn) logoutBtn.hidden = !authenticated;
|
||||
if (panel) panel.hidden = true;
|
||||
}
|
||||
|
||||
async function refreshUserSession() {
|
||||
try {
|
||||
const res = await fetch("/api/auth/session");
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
const wasAnonymous = username.startsWith("viewer");
|
||||
if (data.authenticated && data.username) {
|
||||
authenticated = true;
|
||||
username = data.username;
|
||||
updateAuthUI();
|
||||
if (wasAnonymous) connectWebSocket(currentRoom);
|
||||
} else {
|
||||
authenticated = false;
|
||||
updateAuthUI();
|
||||
}
|
||||
} catch {
|
||||
/* non-critical */
|
||||
}
|
||||
}
|
||||
|
||||
async function submitAuth(path: string) {
|
||||
const u = (document.getElementById("auth-username") as HTMLInputElement).value.trim();
|
||||
const p = (document.getElementById("auth-password") as HTMLInputElement).value;
|
||||
const status = document.getElementById("auth-status");
|
||||
if (!u || !p) {
|
||||
if (status) {
|
||||
status.textContent = "Username and password required.";
|
||||
status.className = "status-line status-error";
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(path, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username: u, password: p }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = (await res.text()).trim();
|
||||
throw new Error(text || `HTTP ${res.status}`);
|
||||
}
|
||||
const data = await res.json();
|
||||
username = data.username || u;
|
||||
authenticated = true;
|
||||
updateAuthUI();
|
||||
if (status) {
|
||||
status.textContent = `Signed in as ${username}.`;
|
||||
status.className = "status-line";
|
||||
}
|
||||
connectWebSocket(currentRoom);
|
||||
} catch (err) {
|
||||
if (status) {
|
||||
status.textContent = `Failed: ${err instanceof Error ? err.message : String(err)}`;
|
||||
status.className = "status-line status-error";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function login() {
|
||||
void submitAuth("/api/auth/login");
|
||||
}
|
||||
|
||||
function register() {
|
||||
void submitAuth("/api/auth/register");
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
try {
|
||||
await fetch("/api/auth/logout", { method: "POST" });
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
authenticated = false;
|
||||
username = "viewer" + Math.floor(Math.random() * 1000);
|
||||
updateAuthUI();
|
||||
connectWebSocket(currentRoom);
|
||||
}
|
||||
|
||||
function toggleUserList() {
|
||||
const panel = document.getElementById("presence-panel");
|
||||
const btn = document.getElementById("users-toggle-btn");
|
||||
if (!panel) return;
|
||||
usersVisible = !usersVisible;
|
||||
panel.hidden = !usersVisible;
|
||||
if (btn) btn.setAttribute("aria-expanded", String(usersVisible));
|
||||
}
|
||||
|
||||
function showTyping(name: string) {
|
||||
if (name === username) return;
|
||||
const el = document.getElementById("typing-indicator");
|
||||
if (!el) return;
|
||||
typingUser = name;
|
||||
el.textContent = `${name} is typing...`;
|
||||
el.hidden = false;
|
||||
window.clearTimeout(typingTimeout);
|
||||
typingTimeout = window.setTimeout(() => {
|
||||
typingUser = "";
|
||||
el.hidden = true;
|
||||
el.textContent = "";
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
function hideTyping(name: string) {
|
||||
if (name !== typingUser) return;
|
||||
const el = document.getElementById("typing-indicator");
|
||||
typingUser = "";
|
||||
window.clearTimeout(typingTimeout);
|
||||
if (el) {
|
||||
el.hidden = true;
|
||||
el.textContent = "";
|
||||
}
|
||||
}
|
||||
|
||||
function sendTyping() {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
const now = Date.now();
|
||||
if (now - lastTypingSent < 2000) return;
|
||||
lastTypingSent = now;
|
||||
ws.send(JSON.stringify({ room: currentRoom, username, text: "", type: "typing" }));
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
void initPlayerAndChat();
|
||||
void refreshUserSession();
|
||||
document.getElementById("auth-toggle-btn")?.addEventListener("click", () => {
|
||||
const panel = document.getElementById("auth-panel");
|
||||
if (panel) panel.hidden = !panel.hidden;
|
||||
});
|
||||
document.getElementById("auth-login-btn")?.addEventListener("click", login);
|
||||
document.getElementById("auth-register-btn")?.addEventListener("click", register);
|
||||
document.getElementById("auth-logout-btn")?.addEventListener("click", logout);
|
||||
document.getElementById("dm-search-btn")?.addEventListener("click", searchDM);
|
||||
document.getElementById("users-toggle-btn")?.addEventListener("click", toggleUserList);
|
||||
document.getElementById("message-input")?.addEventListener("input", sendTyping);
|
||||
});
|
||||
+76
-1
@@ -253,6 +253,79 @@ select {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.1rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.5;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.badge-ok {
|
||||
color: var(--ok);
|
||||
background: rgba(4, 101, 13, 0.12);
|
||||
}
|
||||
|
||||
.badge-warn {
|
||||
color: var(--warn);
|
||||
background: rgba(212, 150, 36, 0.12);
|
||||
}
|
||||
|
||||
.badge-info {
|
||||
color: var(--brand);
|
||||
background: rgba(88, 119, 146, 0.14);
|
||||
}
|
||||
|
||||
.badge-danger {
|
||||
color: var(--danger);
|
||||
background: rgba(196, 74, 94, 0.12);
|
||||
}
|
||||
|
||||
.badge-muted {
|
||||
color: var(--ink-soft);
|
||||
background: var(--bg-2);
|
||||
}
|
||||
|
||||
.chat-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.chat-user-label {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
color: var(--ink-soft);
|
||||
max-width: 10rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.auth-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
border-top: 1px solid var(--bg-2);
|
||||
}
|
||||
|
||||
.dm-search-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-top: 1px solid var(--bg-2);
|
||||
}
|
||||
|
||||
.typing-indicator {
|
||||
font-size: 0.78rem;
|
||||
font-style: italic;
|
||||
color: var(--ink-soft);
|
||||
padding: 0.25rem 0.75rem 0;
|
||||
}
|
||||
|
||||
.btn {
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-1);
|
||||
@@ -1085,7 +1158,9 @@ select {
|
||||
}
|
||||
|
||||
@keyframes lv-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
#reconnecting-text {
|
||||
|
||||
Reference in new issue
Block a user