feat: add chat log admin management and refactor admin panel to lazy-load tab content
This commit is contained in:
1 parent
f650a3a94e
commit
74133ec589
12 files changed
+921
-387
No files matched your search
@@ -0,0 +1,45 @@
|
||||
<div class="panel-header-row">
|
||||
<div>
|
||||
<h1>Analytics</h1>
|
||||
<p>Basic activity counters from chat history and moderation audit logs.</p>
|
||||
</div>
|
||||
<button id="reload-analytics-btn" type="button" class="btn btn-secondary">Reload analytics</button>
|
||||
</div>
|
||||
|
||||
<div class="stat-grid">
|
||||
<article class="stat-card">
|
||||
<h2>Total Messages</h2>
|
||||
<p id="analytics-total-messages" class="stat-value">0</p>
|
||||
</article>
|
||||
<article class="stat-card">
|
||||
<h2>Last 24 Hours</h2>
|
||||
<p id="analytics-messages-24h" class="stat-value">0</p>
|
||||
</article>
|
||||
<article class="stat-card">
|
||||
<h2>Distinct Rooms</h2>
|
||||
<p id="analytics-distinct-rooms" class="stat-value">0</p>
|
||||
</article>
|
||||
<article class="stat-card">
|
||||
<h2>Moderation Actions</h2>
|
||||
<p id="analytics-moderation-actions" class="stat-value">0</p>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="panel-card">
|
||||
<div class="panel-header-row">
|
||||
<h2>Top Rooms</h2>
|
||||
<p id="analytics-generated-at" class="small-muted">loading...</p>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Room</th>
|
||||
<th>Messages</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="analytics-table-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p id="analytics-status" class="status-line" aria-live="polite">Loading analytics...</p>
|
||||
</div>
|
||||
@@ -0,0 +1,46 @@
|
||||
<div class="panel-header-row">
|
||||
<div>
|
||||
<h1>Chat Log</h1>
|
||||
<p>Browse and manage stored chat messages.</p>
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<button id="reload-chat-log-btn" type="button" class="btn btn-secondary">Reload</button>
|
||||
<button id="bulk-delete-chat-log-btn" type="button" class="btn btn-danger" disabled>Delete Selected</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-card">
|
||||
<div class="chat-log-filters">
|
||||
<input id="chat-log-filter-room" class="text-input" type="text" placeholder="Room" />
|
||||
<input id="chat-log-filter-username" class="text-input" type="text" placeholder="Username" />
|
||||
<input id="chat-log-filter-text" class="text-input" type="text" placeholder="Message text" />
|
||||
<button id="chat-log-filter-btn" type="button" class="btn btn-secondary">Filter</button>
|
||||
<button id="chat-log-clear-filter-btn" type="button" class="btn btn-secondary">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-card">
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><input type="checkbox" id="chat-log-select-all" title="Select all" /></th>
|
||||
<th>ID</th>
|
||||
<th>Room</th>
|
||||
<th>Username</th>
|
||||
<th>Message</th>
|
||||
<th>Type</th>
|
||||
<th>Timestamp</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="chat-log-table-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="chat-log-pagination">
|
||||
<button id="chat-log-prev-btn" type="button" class="btn btn-secondary" disabled>Previous</button>
|
||||
<span id="chat-log-page-info" class="small-muted">Page 1</span>
|
||||
<button id="chat-log-next-btn" type="button" class="btn btn-secondary" disabled>Next</button>
|
||||
</div>
|
||||
<p id="chat-log-status" class="status-line" aria-live="polite">Loading chat log...</p>
|
||||
</div>
|
||||
@@ -0,0 +1,21 @@
|
||||
<h1>Dashboard</h1>
|
||||
<div class="stat-grid">
|
||||
<article class="stat-card">
|
||||
<h2>Concurrent Viewers</h2>
|
||||
<p id="stat-viewers" class="stat-value">0</p>
|
||||
</article>
|
||||
<article class="stat-card">
|
||||
<h2>Stored Messages</h2>
|
||||
<p id="stat-stored-messages" class="stat-value">0</p>
|
||||
</article>
|
||||
<article class="stat-card">
|
||||
<h2>Moderation Actions</h2>
|
||||
<p id="stat-moderation-actions" class="stat-value">0</p>
|
||||
</article>
|
||||
<article class="stat-card">
|
||||
<h2>Media Runtime</h2>
|
||||
<p id="stat-media-runtime" class="stat-value">00:00:00</p>
|
||||
<p id="stat-media-count" class="stat-caption">0 files</p>
|
||||
</article>
|
||||
</div>
|
||||
<p id="overview-status" class="status-line" aria-live="polite">Loading live admin overview...</p>
|
||||
+2
-307
@@ -24,6 +24,7 @@
|
||||
<button type="button" id="tab-trigger-playlist" role="tab" class="tab-trigger" data-tab="playlist" aria-controls="tab-playlist" aria-selected="false">Playlist</button>
|
||||
<button type="button" id="tab-trigger-schedule" role="tab" class="tab-trigger" data-tab="schedule" aria-controls="tab-schedule" aria-selected="false">Schedule</button>
|
||||
<button type="button" id="tab-trigger-moderation" role="tab" class="tab-trigger" data-tab="moderation" aria-controls="tab-moderation" aria-selected="false">Moderation</button>
|
||||
<button type="button" id="tab-trigger-chat-log" role="tab" class="tab-trigger" data-tab="chat-log" aria-controls="tab-chat-log" aria-selected="false">Chat Log</button>
|
||||
<button type="button" id="tab-trigger-users" role="tab" class="tab-trigger" data-tab="users" aria-controls="tab-users" aria-selected="false">Users</button>
|
||||
<button type="button" id="tab-trigger-analytics" role="tab" class="tab-trigger" data-tab="analytics" aria-controls="tab-analytics" aria-selected="false">Analytics</button>
|
||||
<button type="button" id="tab-trigger-system" role="tab" class="tab-trigger" data-tab="system" aria-controls="tab-system" aria-selected="false">System</button>
|
||||
@@ -31,313 +32,7 @@
|
||||
</aside>
|
||||
|
||||
<main class="admin-main" id="admin-main" tabindex="-1">
|
||||
<section id="tab-dashboard" class="tab-panel is-active" role="tabpanel" aria-labelledby="tab-trigger-dashboard">
|
||||
<h1>Dashboard</h1>
|
||||
<div class="stat-grid">
|
||||
<article class="stat-card">
|
||||
<h2>Concurrent Viewers</h2>
|
||||
<p id="stat-viewers" class="stat-value">0</p>
|
||||
</article>
|
||||
<article class="stat-card">
|
||||
<h2>Stored Messages</h2>
|
||||
<p id="stat-stored-messages" class="stat-value">0</p>
|
||||
</article>
|
||||
<article class="stat-card">
|
||||
<h2>Moderation Actions</h2>
|
||||
<p id="stat-moderation-actions" class="stat-value">0</p>
|
||||
</article>
|
||||
<article class="stat-card">
|
||||
<h2>Media Runtime</h2>
|
||||
<p id="stat-media-runtime" class="stat-value">00:00:00</p>
|
||||
<p id="stat-media-count" class="stat-caption">0 files</p>
|
||||
</article>
|
||||
</div>
|
||||
<p id="overview-status" class="status-line" aria-live="polite">Loading live admin overview...</p>
|
||||
</section>
|
||||
|
||||
<section id="tab-upload" class="tab-panel" role="tabpanel" aria-labelledby="tab-trigger-upload" hidden>
|
||||
<div class="panel-header-row">
|
||||
<div>
|
||||
<h1>Upload Video</h1>
|
||||
<p>Upload media files into the local media library mounted for playout.</p>
|
||||
</div>
|
||||
<button id="reload-media-btn" type="button" class="btn btn-secondary">Reload library</button>
|
||||
</div>
|
||||
|
||||
<div id="upload-dropzone" class="upload-dropzone">
|
||||
<input type="file" id="video-upload" accept="video/*,.mp4,.mov,.mkv,.webm" hidden />
|
||||
<button id="upload-trigger-btn" type="button" class="btn btn-primary btn-large">Drag and Drop or Click to Upload</button>
|
||||
<p class="small-muted">Resumable chunk upload enabled • up to 2GB per file.</p>
|
||||
<div id="media-upload-progress-wrap" class="upload-progress" hidden>
|
||||
<progress id="media-upload-progress" value="0" max="100"></progress>
|
||||
<p id="media-upload-progress-text" class="small-muted">Uploading (0%)</p>
|
||||
</div>
|
||||
<p id="media-status" class="status-line" aria-live="polite">Loading media library...</p>
|
||||
</div>
|
||||
|
||||
<div class="panel-card">
|
||||
<div class="panel-header-row">
|
||||
<h2>Media Library</h2>
|
||||
<p id="media-generated-at" class="small-muted">loading...</p>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>File</th>
|
||||
<th>Title</th>
|
||||
<th>Runtime</th>
|
||||
<th>Status</th>
|
||||
<th>Size</th>
|
||||
<th>Modified</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="media-table-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="tab-schedule" class="tab-panel" role="tabpanel" aria-labelledby="tab-trigger-schedule" hidden>
|
||||
<div class="panel-header-row">
|
||||
<div>
|
||||
<h1>Schedule</h1>
|
||||
<p>Read-only view of the currently configured playout order.</p>
|
||||
</div>
|
||||
<button id="reload-schedule-btn" type="button" class="btn btn-secondary">Reload schedule</button>
|
||||
</div>
|
||||
|
||||
<div class="panel-card">
|
||||
<p class="small-muted"><span id="schedule-matched-entries">0</span>/<span id="schedule-total-entries">0</span> matched • total runtime <span id="schedule-total-runtime">00:00:00</span></p>
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Label</th>
|
||||
<th>Runtime</th>
|
||||
<th>Status</th>
|
||||
<th>Path</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="schedule-table-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p id="schedule-status" class="status-line" aria-live="polite">Loading schedule...</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="tab-playlist" class="tab-panel" role="tabpanel" aria-labelledby="tab-trigger-playlist" hidden>
|
||||
<div class="panel-header-row">
|
||||
<div>
|
||||
<h1>Playlist</h1>
|
||||
<p>Edit the active concat playlist used by the playout service.</p>
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<button id="reload-playlist-btn" type="button" class="btn btn-secondary">Reload</button>
|
||||
<button id="save-playlist-btn" type="button" class="btn btn-primary">Save playlist</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-card">
|
||||
<label for="playlist-content" class="sr-only">Playlist content</label>
|
||||
<textarea id="playlist-content" class="text-input text-area" spellcheck="false"></textarea>
|
||||
<p id="playlist-status" class="status-line" aria-live="polite">Loading playlist...</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="tab-moderation" class="tab-panel" role="tabpanel" aria-labelledby="tab-trigger-moderation" hidden>
|
||||
<div class="panel-header-row">
|
||||
<div>
|
||||
<h1>Moderation</h1>
|
||||
<p>Create moderation actions and review the recent immutable audit log.</p>
|
||||
</div>
|
||||
<button id="reload-moderation-btn" type="button" class="btn btn-secondary">Reload logs</button>
|
||||
</div>
|
||||
|
||||
<div class="moderation-grid">
|
||||
<section class="panel-card">
|
||||
<h2>New Action</h2>
|
||||
<form id="moderation-form" class="form-stack">
|
||||
<label>Action
|
||||
<select id="mod-action" class="text-input">
|
||||
<option value="mute">mute</option>
|
||||
<option value="ban">ban</option>
|
||||
<option value="delete">delete</option>
|
||||
<option value="timeout">timeout</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Target
|
||||
<input id="mod-target" class="text-input" type="text" placeholder="username or message id" required />
|
||||
</label>
|
||||
<label>Moderator
|
||||
<input id="mod-moderator" class="text-input" type="text" placeholder="admin" />
|
||||
</label>
|
||||
<label>Room
|
||||
<input id="mod-room" class="text-input" type="text" placeholder="main" />
|
||||
</label>
|
||||
<label>Reason
|
||||
<textarea id="mod-reason" class="text-input text-area" placeholder="Explain why this action is needed" required></textarea>
|
||||
</label>
|
||||
<button id="mod-submit" type="submit" class="btn btn-primary">Submit action</button>
|
||||
<p id="moderation-status" class="status-line" aria-live="polite">Loading moderation logs...</p>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="panel-card">
|
||||
<div class="panel-header-row">
|
||||
<h2>Recent Audit Log</h2>
|
||||
<p class="small-muted">Latest entries</p>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>When</th>
|
||||
<th>Action</th>
|
||||
<th>Target</th>
|
||||
<th>Moderator</th>
|
||||
<th>Reason</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="moderation-table-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="tab-analytics" class="tab-panel" role="tabpanel" aria-labelledby="tab-trigger-analytics" hidden>
|
||||
<div class="panel-header-row">
|
||||
<div>
|
||||
<h1>Analytics</h1>
|
||||
<p>Basic activity counters from chat history and moderation audit logs.</p>
|
||||
</div>
|
||||
<button id="reload-analytics-btn" type="button" class="btn btn-secondary">Reload analytics</button>
|
||||
</div>
|
||||
|
||||
<div class="stat-grid">
|
||||
<article class="stat-card">
|
||||
<h2>Total Messages</h2>
|
||||
<p id="analytics-total-messages" class="stat-value">0</p>
|
||||
</article>
|
||||
<article class="stat-card">
|
||||
<h2>Last 24 Hours</h2>
|
||||
<p id="analytics-messages-24h" class="stat-value">0</p>
|
||||
</article>
|
||||
<article class="stat-card">
|
||||
<h2>Distinct Rooms</h2>
|
||||
<p id="analytics-distinct-rooms" class="stat-value">0</p>
|
||||
</article>
|
||||
<article class="stat-card">
|
||||
<h2>Moderation Actions</h2>
|
||||
<p id="analytics-moderation-actions" class="stat-value">0</p>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="panel-card">
|
||||
<div class="panel-header-row">
|
||||
<h2>Top Rooms</h2>
|
||||
<p id="analytics-generated-at" class="small-muted">loading...</p>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Room</th>
|
||||
<th>Messages</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="analytics-table-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p id="analytics-status" class="status-line" aria-live="polite">Loading analytics...</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="tab-system" class="tab-panel" role="tabpanel" aria-labelledby="tab-trigger-system" hidden>
|
||||
<h1>System Configuration</h1>
|
||||
<div class="panel-card split-grid">
|
||||
<section>
|
||||
<h2>Redis Stats</h2>
|
||||
<dl class="kv-list">
|
||||
<div>
|
||||
<dt>Memory Used</dt>
|
||||
<dd id="system-redis-memory">loading...</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Connected Clients</dt>
|
||||
<dd id="system-redis-clients">0</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Redis Enabled</dt>
|
||||
<dd id="system-redis-enabled">no</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Storage</h2>
|
||||
<dl class="kv-list">
|
||||
<div>
|
||||
<dt>Database Driver</dt>
|
||||
<dd id="system-db-driver">loading...</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Stored Messages</dt>
|
||||
<dd id="system-stored-messages">0</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Last Refresh</dt>
|
||||
<dd id="system-generated-at">loading...</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Playlist Path</dt>
|
||||
<dd id="system-playlist-path">loading...</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Playlist Present</dt>
|
||||
<dd id="system-playlist-present">no</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="panel-card">
|
||||
<div class="panel-header-row">
|
||||
<h2>Service Health</h2>
|
||||
<button id="reload-system-btn" type="button" class="btn btn-secondary">Reload health</button>
|
||||
</div>
|
||||
<div id="system-services" class="service-list"></div>
|
||||
<p id="system-status" class="status-line" aria-live="polite">Loading system summary...</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="tab-users" class="tab-panel" role="tabpanel" aria-labelledby="tab-trigger-users" hidden>
|
||||
<div class="panel-header-row">
|
||||
<div>
|
||||
<h1>Users</h1>
|
||||
<p>Current room presence derived from Redis presence keys.</p>
|
||||
</div>
|
||||
<button id="reload-users-btn" type="button" class="btn btn-secondary">Reload users</button>
|
||||
</div>
|
||||
|
||||
<div class="panel-card">
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
<th>Room</th>
|
||||
<th>Online</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="users-table-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p id="users-status" class="status-line" aria-live="polite">Loading active users...</p>
|
||||
</div>
|
||||
</section>
|
||||
<div id="tab-content-container" aria-live="polite">Loading...</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<div class="panel-header-row">
|
||||
<div>
|
||||
<h1>Moderation</h1>
|
||||
<p>Create moderation actions and review the recent immutable audit log.</p>
|
||||
</div>
|
||||
<button id="reload-moderation-btn" type="button" class="btn btn-secondary">Reload logs</button>
|
||||
</div>
|
||||
|
||||
<div class="moderation-grid">
|
||||
<section class="panel-card">
|
||||
<h2>New Action</h2>
|
||||
<form id="moderation-form" class="form-stack">
|
||||
<label>Action
|
||||
<select id="mod-action" class="text-input">
|
||||
<option value="mute">mute</option>
|
||||
<option value="ban">ban</option>
|
||||
<option value="delete">delete</option>
|
||||
<option value="timeout">timeout</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Target
|
||||
<input id="mod-target" class="text-input" type="text" placeholder="username or message id" required />
|
||||
</label>
|
||||
<label>Moderator
|
||||
<input id="mod-moderator" class="text-input" type="text" placeholder="admin" />
|
||||
</label>
|
||||
<label>Room
|
||||
<input id="mod-room" class="text-input" type="text" placeholder="main" />
|
||||
</label>
|
||||
<label>Reason
|
||||
<textarea id="mod-reason" class="text-input text-area" placeholder="Explain why this action is needed" required></textarea>
|
||||
</label>
|
||||
<button id="mod-submit" type="submit" class="btn btn-primary">Submit action</button>
|
||||
<p id="moderation-status" class="status-line" aria-live="polite">Loading moderation logs...</p>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="panel-card">
|
||||
<div class="panel-header-row">
|
||||
<h2>Recent Audit Log</h2>
|
||||
<p class="small-muted">Latest entries</p>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>When</th>
|
||||
<th>Action</th>
|
||||
<th>Target</th>
|
||||
<th>Moderator</th>
|
||||
<th>Reason</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="moderation-table-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
@@ -0,0 +1,16 @@
|
||||
<div class="panel-header-row">
|
||||
<div>
|
||||
<h1>Playlist</h1>
|
||||
<p>Edit the active concat playlist used by the playout service.</p>
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<button id="reload-playlist-btn" type="button" class="btn btn-secondary">Reload</button>
|
||||
<button id="save-playlist-btn" type="button" class="btn btn-primary">Save playlist</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-card">
|
||||
<label for="playlist-content" class="sr-only">Playlist content</label>
|
||||
<textarea id="playlist-content" class="text-input text-area" spellcheck="false"></textarea>
|
||||
<p id="playlist-status" class="status-line" aria-live="polite">Loading playlist...</p>
|
||||
</div>
|
||||
@@ -0,0 +1,26 @@
|
||||
<div class="panel-header-row">
|
||||
<div>
|
||||
<h1>Schedule</h1>
|
||||
<p>Read-only view of the currently configured playout order.</p>
|
||||
</div>
|
||||
<button id="reload-schedule-btn" type="button" class="btn btn-secondary">Reload schedule</button>
|
||||
</div>
|
||||
|
||||
<div class="panel-card">
|
||||
<p class="small-muted"><span id="schedule-matched-entries">0</span>/<span id="schedule-total-entries">0</span> matched • total runtime <span id="schedule-total-runtime">00:00:00</span></p>
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Label</th>
|
||||
<th>Runtime</th>
|
||||
<th>Status</th>
|
||||
<th>Path</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="schedule-table-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p id="schedule-status" class="status-line" aria-live="polite">Loading schedule...</p>
|
||||
</div>
|
||||
@@ -0,0 +1,54 @@
|
||||
<h1>System Configuration</h1>
|
||||
<div class="panel-card split-grid">
|
||||
<section>
|
||||
<h2>Redis Stats</h2>
|
||||
<dl class="kv-list">
|
||||
<div>
|
||||
<dt>Memory Used</dt>
|
||||
<dd id="system-redis-memory">loading...</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Connected Clients</dt>
|
||||
<dd id="system-redis-clients">0</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Redis Enabled</dt>
|
||||
<dd id="system-redis-enabled">no</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Storage</h2>
|
||||
<dl class="kv-list">
|
||||
<div>
|
||||
<dt>Database Driver</dt>
|
||||
<dd id="system-db-driver">loading...</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Stored Messages</dt>
|
||||
<dd id="system-stored-messages">0</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Last Refresh</dt>
|
||||
<dd id="system-generated-at">loading...</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Playlist Path</dt>
|
||||
<dd id="system-playlist-path">loading...</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Playlist Present</dt>
|
||||
<dd id="system-playlist-present">no</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="panel-card">
|
||||
<div class="panel-header-row">
|
||||
<h2>Service Health</h2>
|
||||
<button id="reload-system-btn" type="button" class="btn btn-secondary">Reload health</button>
|
||||
</div>
|
||||
<div id="system-services" class="service-list"></div>
|
||||
<p id="system-status" class="status-line" aria-live="polite">Loading system summary...</p>
|
||||
</div>
|
||||
@@ -0,0 +1,41 @@
|
||||
<div class="panel-header-row">
|
||||
<div>
|
||||
<h1>Upload Video</h1>
|
||||
<p>Upload media files into the local media library mounted for playout.</p>
|
||||
</div>
|
||||
<button id="reload-media-btn" type="button" class="btn btn-secondary">Reload library</button>
|
||||
</div>
|
||||
|
||||
<div id="upload-dropzone" class="upload-dropzone">
|
||||
<input type="file" id="video-upload" accept="video/*,.mp4,.mov,.mkv,.webm" hidden />
|
||||
<button id="upload-trigger-btn" type="button" class="btn btn-primary btn-large">Drag and Drop or Click to Upload</button>
|
||||
<p class="small-muted">Resumable chunk upload enabled • up to 2GB per file.</p>
|
||||
<div id="media-upload-progress-wrap" class="upload-progress" hidden>
|
||||
<progress id="media-upload-progress" value="0" max="100"></progress>
|
||||
<p id="media-upload-progress-text" class="small-muted">Uploading (0%)</p>
|
||||
</div>
|
||||
<p id="media-status" class="status-line" aria-live="polite">Loading media library...</p>
|
||||
</div>
|
||||
|
||||
<div class="panel-card">
|
||||
<div class="panel-header-row">
|
||||
<h2>Media Library</h2>
|
||||
<p id="media-generated-at" class="small-muted">loading...</p>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>File</th>
|
||||
<th>Title</th>
|
||||
<th>Runtime</th>
|
||||
<th>Status</th>
|
||||
<th>Size</th>
|
||||
<th>Modified</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="media-table-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,23 @@
|
||||
<div class="panel-header-row">
|
||||
<div>
|
||||
<h1>Users</h1>
|
||||
<p>Current room presence derived from Redis presence keys.</p>
|
||||
</div>
|
||||
<button id="reload-users-btn" type="button" class="btn btn-secondary">Reload users</button>
|
||||
</div>
|
||||
|
||||
<div class="panel-card">
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
<th>Room</th>
|
||||
<th>Online</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="users-table-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p id="users-status" class="status-line" aria-live="polite">Loading active users...</p>
|
||||
</div>
|
||||
+405
-80
@@ -47,7 +47,9 @@ const uploadProgressWrap = () =>
|
||||
document.getElementById("media-upload-progress-wrap") as HTMLElement | null;
|
||||
|
||||
const uploadProgressBar = () =>
|
||||
document.getElementById("media-upload-progress") as HTMLProgressElement | null;
|
||||
document.getElementById(
|
||||
"media-upload-progress",
|
||||
) as HTMLProgressElement | null;
|
||||
|
||||
const uploadProgressText = () =>
|
||||
document.getElementById("media-upload-progress-text") as HTMLElement | null;
|
||||
@@ -135,9 +137,21 @@ const fmtSize = (value?: number) => {
|
||||
return `${size.toFixed(size >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`;
|
||||
};
|
||||
|
||||
function switchTab(tabName: string) {
|
||||
// Page content cache: maps tab name to loaded HTML string
|
||||
const pageCache: Record<string, string> = {};
|
||||
|
||||
async function loadPageContent(tabName: string): Promise<string> {
|
||||
if (pageCache[tabName]) return pageCache[tabName];
|
||||
|
||||
const response = await fetch(`/admin/${tabName}.html`);
|
||||
if (!response.ok) throw new Error(`Failed to load page: ${tabName}`);
|
||||
const html = await response.text();
|
||||
pageCache[tabName] = html;
|
||||
return html;
|
||||
}
|
||||
|
||||
async function switchTab(tabName: string) {
|
||||
const triggers = document.querySelectorAll<HTMLButtonElement>(".tab-trigger");
|
||||
const panels = document.querySelectorAll<HTMLElement>(".tab-panel");
|
||||
|
||||
triggers.forEach((trigger) => {
|
||||
const active = trigger.dataset.tab === tabName;
|
||||
@@ -145,16 +159,27 @@ function switchTab(tabName: string) {
|
||||
trigger.setAttribute("aria-selected", String(active));
|
||||
});
|
||||
|
||||
panels.forEach((panel) => {
|
||||
const active = panel.id === `tab-${tabName}`;
|
||||
panel.hidden = !active;
|
||||
panel.classList.toggle("is-active", active);
|
||||
});
|
||||
const container = document.getElementById("tab-content-container");
|
||||
if (!container) return;
|
||||
|
||||
try {
|
||||
const html = await loadPageContent(tabName);
|
||||
container.innerHTML = html;
|
||||
} catch (error) {
|
||||
console.error("Failed to load tab content", error);
|
||||
container.innerHTML = `<p class="status-error">Failed to load ${tabName} page.</p>`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Re-bind events for the newly loaded page content
|
||||
bindPageEvents(tabName);
|
||||
|
||||
if (tabName === "dashboard") refreshOverview();
|
||||
if (tabName === "upload") refreshMediaLibrary();
|
||||
if (tabName === "playlist") refreshPlaylist();
|
||||
if (tabName === "schedule") refreshSchedule();
|
||||
if (tabName === "moderation") refreshModerationLogs();
|
||||
if (tabName === "chat-log") refreshChatLog();
|
||||
if (tabName === "users") refreshUsers();
|
||||
if (tabName === "analytics") refreshAnalytics();
|
||||
if (tabName === "system") refreshSystemSummary();
|
||||
@@ -446,11 +471,18 @@ async function uploadVideo(file: File) {
|
||||
|
||||
const alreadyUploaded = Number(initPayload.uploadedChunks || 0);
|
||||
const effectiveTotalChunks = Number(initPayload.totalChunks || totalChunks);
|
||||
const resumeFrom = Math.min(Math.max(alreadyUploaded, 0), effectiveTotalChunks);
|
||||
const resumeFrom = Math.min(
|
||||
Math.max(alreadyUploaded, 0),
|
||||
effectiveTotalChunks,
|
||||
);
|
||||
|
||||
setUploadProgress(resumeFrom, effectiveTotalChunks, "Uploading chunks");
|
||||
|
||||
for (let chunkIndex = resumeFrom; chunkIndex < effectiveTotalChunks; chunkIndex += 1) {
|
||||
for (
|
||||
let chunkIndex = resumeFrom;
|
||||
chunkIndex < effectiveTotalChunks;
|
||||
chunkIndex += 1
|
||||
) {
|
||||
const start = chunkIndex * UPLOAD_CHUNK_BYTES;
|
||||
const end = Math.min(file.size, start + UPLOAD_CHUNK_BYTES);
|
||||
const chunk = file.slice(start, end);
|
||||
@@ -468,7 +500,11 @@ async function uploadVideo(file: File) {
|
||||
throw new Error(await readErrorMessage(chunkResponse));
|
||||
}
|
||||
|
||||
setUploadProgress(chunkIndex + 1, effectiveTotalChunks, "Uploading chunks");
|
||||
setUploadProgress(
|
||||
chunkIndex + 1,
|
||||
effectiveTotalChunks,
|
||||
"Uploading chunks",
|
||||
);
|
||||
setStatus(
|
||||
"media-status",
|
||||
`Uploading ${file.name} (${chunkIndex + 1}/${effectiveTotalChunks} chunks)...`,
|
||||
@@ -497,7 +533,8 @@ async function uploadVideo(file: File) {
|
||||
await refreshSchedule();
|
||||
} catch (error) {
|
||||
console.error("Failed to upload media", error);
|
||||
const details = error instanceof Error ? error.message : "Unknown upload error.";
|
||||
const details =
|
||||
error instanceof Error ? error.message : "Unknown upload error.";
|
||||
setStatus(
|
||||
"media-status",
|
||||
`Upload interrupted. Re-select the same file to resume. (${details})`,
|
||||
@@ -637,6 +674,245 @@ async function submitModerationAction(event: Event) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Chat Log ---
|
||||
|
||||
type ChatLogEntry = {
|
||||
id: number;
|
||||
room: string;
|
||||
username: string;
|
||||
text: string;
|
||||
timestamp: string;
|
||||
type: string;
|
||||
};
|
||||
|
||||
const chatLogState = {
|
||||
entries: [] as ChatLogEntry[],
|
||||
total: 0,
|
||||
limit: 50,
|
||||
before: "",
|
||||
nextBefore: "",
|
||||
selected: new Set<number>(),
|
||||
room: "",
|
||||
username: "",
|
||||
text: "",
|
||||
};
|
||||
|
||||
function getChatLogBefore(): string {
|
||||
if (chatLogState.entries.length === 0) return "";
|
||||
const oldest = chatLogState.entries[chatLogState.entries.length - 1];
|
||||
return oldest.timestamp;
|
||||
}
|
||||
|
||||
async function refreshChatLog() {
|
||||
setStatus("chat-log-status", "Loading chat log...");
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
params.set("limit", String(chatLogState.limit));
|
||||
if (chatLogState.before) params.set("before", chatLogState.before);
|
||||
if (chatLogState.room) params.set("room", chatLogState.room);
|
||||
if (chatLogState.username) params.set("username", chatLogState.username);
|
||||
if (chatLogState.text) params.set("text", chatLogState.text);
|
||||
|
||||
const response = await fetch(`/api/admin/chat-log?${params}`);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
|
||||
const payload = await response.json();
|
||||
chatLogState.entries = payload.entries || [];
|
||||
chatLogState.total = payload.total || 0;
|
||||
chatLogState.selected.clear();
|
||||
|
||||
renderChatLogTable();
|
||||
updateChatLogPagination();
|
||||
updateBulkDeleteButton();
|
||||
setStatus(
|
||||
"chat-log-status",
|
||||
`Loaded ${chatLogState.entries.length} entries.`,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to load chat log", error);
|
||||
setStatus("chat-log-status", "Failed to load chat log.", true);
|
||||
}
|
||||
}
|
||||
|
||||
function renderChatLogTable() {
|
||||
const tbody = document.getElementById("chat-log-table-body");
|
||||
if (!tbody) return;
|
||||
tbody.innerHTML = "";
|
||||
|
||||
if (chatLogState.entries.length === 0) {
|
||||
tbody.innerHTML = `<tr><td colspan="8">No chat log entries found.</td></tr>`;
|
||||
return;
|
||||
}
|
||||
|
||||
chatLogState.entries.forEach((entry) => {
|
||||
const tr = document.createElement("tr");
|
||||
const checked = chatLogState.selected.has(entry.id) ? "checked" : "";
|
||||
tr.innerHTML = `
|
||||
<td><input type="checkbox" class="chat-log-checkbox" data-id="${entry.id}" ${checked} /></td>
|
||||
<td>${entry.id}</td>
|
||||
<td>${entry.room}</td>
|
||||
<td>${entry.username}</td>
|
||||
<td>${entry.text}</td>
|
||||
<td>${entry.type}</td>
|
||||
<td>${entry.timestamp}</td>
|
||||
<td class="action-cell">
|
||||
<button type="button" class="btn btn-danger btn-small" data-action="delete-chat" data-id="${entry.id}">Delete</button>
|
||||
</td>
|
||||
`;
|
||||
tr.querySelector('[data-action="delete-chat"]')?.addEventListener(
|
||||
"click",
|
||||
() => deleteChatEntry(entry.id),
|
||||
);
|
||||
tr.querySelector(".chat-log-checkbox")?.addEventListener("change", (e) => {
|
||||
const cb = e.target as HTMLInputElement;
|
||||
if (cb.checked) {
|
||||
chatLogState.selected.add(entry.id);
|
||||
} else {
|
||||
chatLogState.selected.delete(entry.id);
|
||||
}
|
||||
updateBulkDeleteButton();
|
||||
updateSelectAllCheckbox();
|
||||
});
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
|
||||
updateSelectAllCheckbox();
|
||||
}
|
||||
|
||||
function updateSelectAllCheckbox() {
|
||||
const selectAll = document.getElementById(
|
||||
"chat-log-select-all",
|
||||
) as HTMLInputElement | null;
|
||||
if (!selectAll) return;
|
||||
const allSelected =
|
||||
chatLogState.entries.length > 0 &&
|
||||
chatLogState.entries.every((e) => chatLogState.selected.has(e.id));
|
||||
selectAll.checked = allSelected;
|
||||
}
|
||||
|
||||
function updateBulkDeleteButton() {
|
||||
const btn = document.getElementById(
|
||||
"bulk-delete-chat-log-btn",
|
||||
) as HTMLButtonElement | null;
|
||||
if (!btn) return;
|
||||
btn.disabled = chatLogState.selected.size === 0;
|
||||
}
|
||||
|
||||
function updateChatLogPagination() {
|
||||
const prevBtn = document.getElementById(
|
||||
"chat-log-prev-btn",
|
||||
) as HTMLButtonElement | null;
|
||||
const nextBtn = document.getElementById(
|
||||
"chat-log-next-btn",
|
||||
) as HTMLButtonElement | null;
|
||||
const info = document.getElementById("chat-log-page-info");
|
||||
|
||||
if (prevBtn) prevBtn.disabled = !chatLogState.before;
|
||||
if (nextBtn)
|
||||
nextBtn.disabled = chatLogState.entries.length < chatLogState.limit;
|
||||
if (info)
|
||||
info.textContent = `Showing ${chatLogState.entries.length} of ${chatLogState.total}`;
|
||||
}
|
||||
|
||||
function chatLogNextPage() {
|
||||
if (chatLogState.entries.length === 0) return;
|
||||
chatLogState.before = getChatLogBefore();
|
||||
refreshChatLog();
|
||||
}
|
||||
|
||||
function chatLogPrevPage() {
|
||||
chatLogState.before = "";
|
||||
refreshChatLog();
|
||||
}
|
||||
|
||||
function chatLogApplyFilter() {
|
||||
chatLogState.room =
|
||||
(document.getElementById("chat-log-filter-room") as HTMLInputElement)
|
||||
?.value || "";
|
||||
chatLogState.username =
|
||||
(document.getElementById("chat-log-filter-username") as HTMLInputElement)
|
||||
?.value || "";
|
||||
chatLogState.text =
|
||||
(document.getElementById("chat-log-filter-text") as HTMLInputElement)
|
||||
?.value || "";
|
||||
chatLogState.before = "";
|
||||
refreshChatLog();
|
||||
}
|
||||
|
||||
function chatLogClearFilter() {
|
||||
const roomEl = document.getElementById(
|
||||
"chat-log-filter-room",
|
||||
) as HTMLInputElement | null;
|
||||
const userEl = document.getElementById(
|
||||
"chat-log-filter-username",
|
||||
) as HTMLInputElement | null;
|
||||
const textEl = document.getElementById(
|
||||
"chat-log-filter-text",
|
||||
) as HTMLInputElement | null;
|
||||
if (roomEl) roomEl.value = "";
|
||||
if (userEl) userEl.value = "";
|
||||
if (textEl) textEl.value = "";
|
||||
chatLogState.room = "";
|
||||
chatLogState.username = "";
|
||||
chatLogState.text = "";
|
||||
chatLogState.before = "";
|
||||
refreshChatLog();
|
||||
}
|
||||
|
||||
async function deleteChatEntry(id: number) {
|
||||
if (!window.confirm(`Delete chat message #${id}? This cannot be undone.`))
|
||||
return;
|
||||
|
||||
setStatus("chat-log-status", `Deleting message #${id}...`);
|
||||
try {
|
||||
const response = await fetch(`/api/admin/chat-log/${id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
setStatus("chat-log-status", `Message #${id} deleted.`);
|
||||
await refreshChatLog();
|
||||
await refreshOverview();
|
||||
} catch (error) {
|
||||
console.error("Failed to delete chat entry", error);
|
||||
setStatus("chat-log-status", "Failed to delete message.", true);
|
||||
}
|
||||
}
|
||||
|
||||
async function bulkDeleteChatLog() {
|
||||
if (chatLogState.selected.size === 0) return;
|
||||
|
||||
if (
|
||||
!window.confirm(
|
||||
`Delete ${chatLogState.selected.size} selected messages? This cannot be undone.`,
|
||||
)
|
||||
)
|
||||
return;
|
||||
|
||||
setStatus(
|
||||
"chat-log-status",
|
||||
`Deleting ${chatLogState.selected.size} messages...`,
|
||||
);
|
||||
try {
|
||||
const ids = Array.from(chatLogState.selected);
|
||||
const response = await fetch("/api/admin/chat-log/bulk", {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ids }),
|
||||
});
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const payload = await response.json();
|
||||
setStatus(
|
||||
"chat-log-status",
|
||||
`${payload.deleted || ids.length} messages deleted.`,
|
||||
);
|
||||
await refreshChatLog();
|
||||
await refreshOverview();
|
||||
} catch (error) {
|
||||
console.error("Failed to bulk delete chat entries", error);
|
||||
setStatus("chat-log-status", "Failed to delete messages.", true);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshUsers() {
|
||||
setStatus("users-status", "Loading active users...");
|
||||
try {
|
||||
@@ -688,84 +964,133 @@ function bindEvents() {
|
||||
window.location.href = "/admin/login/";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document
|
||||
.getElementById("reload-media-btn")
|
||||
?.addEventListener("click", refreshMediaLibrary);
|
||||
document
|
||||
.getElementById("reload-schedule-btn")
|
||||
?.addEventListener("click", refreshSchedule);
|
||||
document
|
||||
.getElementById("reload-playlist-btn")
|
||||
?.addEventListener("click", refreshPlaylist);
|
||||
document
|
||||
.getElementById("save-playlist-btn")
|
||||
?.addEventListener("click", savePlaylist);
|
||||
document
|
||||
.getElementById("reload-moderation-btn")
|
||||
?.addEventListener("click", refreshModerationLogs);
|
||||
document
|
||||
.getElementById("reload-analytics-btn")
|
||||
?.addEventListener("click", refreshAnalytics);
|
||||
document
|
||||
.getElementById("reload-users-btn")
|
||||
?.addEventListener("click", refreshUsers);
|
||||
document
|
||||
.getElementById("reload-system-btn")
|
||||
?.addEventListener("click", refreshSystemSummary);
|
||||
// bindPageEvents wires up page-specific DOM listeners after HTML is loaded
|
||||
function bindPageEvents(tabName: string) {
|
||||
switch (tabName) {
|
||||
case "upload":
|
||||
document
|
||||
.getElementById("reload-media-btn")
|
||||
?.addEventListener("click", refreshMediaLibrary);
|
||||
|
||||
const uploadInput = document.getElementById(
|
||||
"video-upload",
|
||||
) as HTMLInputElement | null;
|
||||
const uploadDropzone = document.getElementById("upload-dropzone");
|
||||
document
|
||||
.getElementById("upload-trigger-btn")
|
||||
?.addEventListener("click", () => uploadInput?.click());
|
||||
const uploadInput = document.getElementById(
|
||||
"video-upload",
|
||||
) as HTMLInputElement | null;
|
||||
const uploadDropzone = document.getElementById("upload-dropzone");
|
||||
document
|
||||
.getElementById("upload-trigger-btn")
|
||||
?.addEventListener("click", () => uploadInput?.click());
|
||||
|
||||
uploadDropzone?.addEventListener("dragover", (event) => {
|
||||
event.preventDefault();
|
||||
uploadDropzone.classList.add("is-dragover");
|
||||
});
|
||||
uploadDropzone?.addEventListener("dragleave", () => {
|
||||
uploadDropzone.classList.remove("is-dragover");
|
||||
});
|
||||
uploadDropzone?.addEventListener("drop", async (event) => {
|
||||
event.preventDefault();
|
||||
uploadDropzone.classList.remove("is-dragover");
|
||||
const droppedFiles = event.dataTransfer?.files;
|
||||
if (!droppedFiles || droppedFiles.length === 0) return;
|
||||
await uploadVideo(droppedFiles[0]);
|
||||
});
|
||||
uploadDropzone?.addEventListener("dragover", (event) => {
|
||||
event.preventDefault();
|
||||
uploadDropzone.classList.add("is-dragover");
|
||||
});
|
||||
uploadDropzone?.addEventListener("dragleave", () => {
|
||||
uploadDropzone.classList.remove("is-dragover");
|
||||
});
|
||||
uploadDropzone?.addEventListener("drop", async (event) => {
|
||||
event.preventDefault();
|
||||
uploadDropzone.classList.remove("is-dragover");
|
||||
const droppedFiles = event.dataTransfer?.files;
|
||||
if (!droppedFiles || droppedFiles.length === 0) return;
|
||||
await uploadVideo(droppedFiles[0]);
|
||||
});
|
||||
uploadInput?.addEventListener("change", async (event) => {
|
||||
const target = event.target as HTMLInputElement;
|
||||
if (!target.files || target.files.length === 0) return;
|
||||
await uploadVideo(target.files[0]);
|
||||
target.value = "";
|
||||
});
|
||||
break;
|
||||
|
||||
uploadInput?.addEventListener("change", async (event) => {
|
||||
const target = event.target as HTMLInputElement;
|
||||
if (!target.files || target.files.length === 0) return;
|
||||
await uploadVideo(target.files[0]);
|
||||
target.value = "";
|
||||
});
|
||||
case "playlist":
|
||||
document
|
||||
.getElementById("reload-playlist-btn")
|
||||
?.addEventListener("click", refreshPlaylist);
|
||||
document
|
||||
.getElementById("save-playlist-btn")
|
||||
?.addEventListener("click", savePlaylist);
|
||||
break;
|
||||
|
||||
document
|
||||
.getElementById("moderation-form")
|
||||
?.addEventListener("submit", submitModerationAction);
|
||||
case "schedule":
|
||||
document
|
||||
.getElementById("reload-schedule-btn")
|
||||
?.addEventListener("click", refreshSchedule);
|
||||
break;
|
||||
|
||||
(document.getElementById("mod-moderator") as HTMLInputElement).value =
|
||||
adminConfig.defaultModerator;
|
||||
(document.getElementById("mod-room") as HTMLInputElement).value =
|
||||
adminConfig.defaultRoom;
|
||||
case "moderation":
|
||||
document
|
||||
.getElementById("reload-moderation-btn")
|
||||
?.addEventListener("click", refreshModerationLogs);
|
||||
document
|
||||
.getElementById("moderation-form")
|
||||
?.addEventListener("submit", submitModerationAction);
|
||||
(document.getElementById("mod-moderator") as HTMLInputElement).value =
|
||||
adminConfig.defaultModerator;
|
||||
(document.getElementById("mod-room") as HTMLInputElement).value =
|
||||
adminConfig.defaultRoom;
|
||||
break;
|
||||
|
||||
case "chat-log":
|
||||
document
|
||||
.getElementById("reload-chat-log-btn")
|
||||
?.addEventListener("click", refreshChatLog);
|
||||
document
|
||||
.getElementById("bulk-delete-chat-log-btn")
|
||||
?.addEventListener("click", bulkDeleteChatLog);
|
||||
document
|
||||
.getElementById("chat-log-filter-btn")
|
||||
?.addEventListener("click", chatLogApplyFilter);
|
||||
document
|
||||
.getElementById("chat-log-clear-filter-btn")
|
||||
?.addEventListener("click", chatLogClearFilter);
|
||||
document
|
||||
.getElementById("chat-log-prev-btn")
|
||||
?.addEventListener("click", chatLogPrevPage);
|
||||
document
|
||||
.getElementById("chat-log-next-btn")
|
||||
?.addEventListener("click", chatLogNextPage);
|
||||
document
|
||||
.getElementById("chat-log-select-all")
|
||||
?.addEventListener("change", (e) => {
|
||||
const checked = (e.target as HTMLInputElement).checked;
|
||||
chatLogState.entries.forEach((entry) => {
|
||||
if (checked) {
|
||||
chatLogState.selected.add(entry.id);
|
||||
} else {
|
||||
chatLogState.selected.delete(entry.id);
|
||||
}
|
||||
});
|
||||
updateBulkDeleteButton();
|
||||
renderChatLogTable();
|
||||
});
|
||||
break;
|
||||
|
||||
case "analytics":
|
||||
document
|
||||
.getElementById("reload-analytics-btn")
|
||||
?.addEventListener("click", refreshAnalytics);
|
||||
break;
|
||||
|
||||
case "users":
|
||||
document
|
||||
.getElementById("reload-users-btn")
|
||||
?.addEventListener("click", refreshUsers);
|
||||
break;
|
||||
|
||||
case "system":
|
||||
document
|
||||
.getElementById("reload-system-btn")
|
||||
?.addEventListener("click", refreshSystemSummary);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
async function init() {
|
||||
bindEvents();
|
||||
await Promise.all([
|
||||
refreshOverview(),
|
||||
refreshAnalytics(),
|
||||
refreshMediaLibrary(),
|
||||
refreshPlaylist(),
|
||||
refreshSchedule(),
|
||||
refreshModerationLogs(),
|
||||
refreshSystemSummary(),
|
||||
refreshUsers(),
|
||||
]);
|
||||
// Load dashboard as the initial tab
|
||||
await switchTab("dashboard");
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
|
||||
Reference in new issue
Block a user