Files
dc-recorder/public/dashboard.html
MythEclipse 6d353c1753 feat: add moderation dashboard UI
- Create responsive dashboard with dark theme
- Implement three tabs: Text Messages, Images, Voice
- Add channel/thread filtering
- Real-time WebSocket updates with polling fallback
- Display message metadata (author, timestamp, edits, deletions)
- Show image previews with upload status and URLs
2026-05-13 19:34:39 +07:00

529 lines
12 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Moderation Dashboard</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: #0f0f0f;
color: #e0e0e0;
padding: 20px;
}
.container {
max-width: 1400px;
margin: 0 auto;
}
header {
margin-bottom: 30px;
border-bottom: 2px solid #333;
padding-bottom: 20px;
}
h1 {
font-size: 28px;
margin-bottom: 10px;
color: #fff;
}
.status {
display: flex;
gap: 20px;
font-size: 14px;
color: #999;
}
.status-item {
display: flex;
align-items: center;
gap: 8px;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #666;
}
.status-dot.connected {
background: #4ade80;
}
.controls {
display: flex;
gap: 15px;
margin-bottom: 20px;
align-items: center;
}
select {
padding: 8px 12px;
background: #1a1a1a;
border: 1px solid #333;
color: #e0e0e0;
border-radius: 4px;
font-size: 14px;
cursor: pointer;
}
select:hover {
border-color: #555;
}
.tabs {
display: flex;
gap: 10px;
margin-bottom: 20px;
border-bottom: 1px solid #333;
}
.tab {
padding: 12px 20px;
background: none;
border: none;
color: #999;
cursor: pointer;
font-size: 14px;
font-weight: 500;
border-bottom: 2px solid transparent;
transition: all 0.2s;
}
.tab:hover {
color: #e0e0e0;
}
.tab.active {
color: #fff;
border-bottom-color: #4ade80;
}
.content {
display: none;
}
.content.active {
display: block;
}
.message-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.message-item {
background: #1a1a1a;
border: 1px solid #333;
border-radius: 6px;
padding: 15px;
transition: all 0.2s;
}
.message-item:hover {
border-color: #555;
background: #222;
}
.message-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 10px;
}
.avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background: #333;
flex-shrink: 0;
}
.avatar img {
width: 100%;
height: 100%;
border-radius: 50%;
}
.user-info {
flex: 1;
}
.username {
font-weight: 600;
color: #fff;
font-size: 14px;
}
.timestamp {
font-size: 12px;
color: #666;
}
.message-content {
color: #e0e0e0;
font-size: 14px;
line-height: 1.5;
margin-bottom: 10px;
word-break: break-word;
}
.message-meta {
display: flex;
gap: 15px;
font-size: 12px;
color: #666;
}
.badge {
display: inline-block;
padding: 2px 8px;
background: #333;
border-radius: 3px;
font-size: 11px;
color: #999;
}
.badge.edited {
background: #4a3a00;
color: #ffd700;
}
.badge.deleted {
background: #3a0000;
color: #ff6b6b;
}
.image-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 15px;
}
.image-item {
background: #1a1a1a;
border: 1px solid #333;
border-radius: 6px;
overflow: hidden;
transition: all 0.2s;
}
.image-item:hover {
border-color: #555;
}
.image-preview {
width: 100%;
height: 150px;
background: #0a0a0a;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.image-preview img {
width: 100%;
height: 100%;
object-fit: cover;
}
.image-info {
padding: 12px;
border-top: 1px solid #333;
}
.image-filename {
font-size: 12px;
color: #e0e0e0;
margin-bottom: 8px;
word-break: break-all;
}
.image-meta {
display: flex;
justify-content: space-between;
font-size: 11px;
color: #666;
}
.image-url {
color: #4ade80;
cursor: pointer;
text-decoration: none;
}
.image-url:hover {
text-decoration: underline;
}
.empty {
text-align: center;
padding: 40px;
color: #666;
}
.error {
background: #3a0000;
border: 1px solid #660000;
color: #ff6b6b;
padding: 12px;
border-radius: 4px;
margin-bottom: 20px;
}
</style>
</head>
<body>
<div class="container">
<header>
<h1>🛡️ Moderation Dashboard</h1>
<div class="status">
<div class="status-item">
<div class="status-dot" id="wsStatus"></div>
<span id="wsStatusText">Connecting...</span>
</div>
</div>
</header>
<div class="controls">
<label for="channelFilter">Filter by Channel:</label>
<select id="channelFilter">
<option value="">All Channels</option>
</select>
</div>
<div class="tabs">
<button class="tab active" data-tab="text">💬 Text Messages</button>
<button class="tab" data-tab="image">🖼️ Images</button>
<button class="tab" data-tab="voice">🎙️ Voice</button>
</div>
<div id="error" class="error" style="display: none;"></div>
<div id="text" class="content active">
<div class="message-list" id="textList"></div>
</div>
<div id="image" class="content">
<div class="image-grid" id="imageGrid"></div>
</div>
<div id="voice" class="content">
<div class="message-list" id="voiceList"></div>
</div>
</div>
<script>
const API_BASE = window.location.origin;
let ws = null;
let selectedChannel = '';
let messageCache = { text: [], image: [], voice: [] };
function connectWebSocket() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
ws = new WebSocket(`${protocol}//${window.location.host}/ws`);
ws.onopen = () => {
updateWSStatus(true);
console.log('WebSocket connected');
};
ws.onmessage = (event) => {
try {
if (typeof event.data === 'string') {
const message = JSON.parse(event.data);
handleWebSocketMessage(message);
}
} catch (e) {
console.error('Failed to parse WebSocket message:', e);
}
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
updateWSStatus(false);
};
ws.onclose = () => {
updateWSStatus(false);
setTimeout(connectWebSocket, 3000);
};
}
function updateWSStatus(connected) {
const dot = document.getElementById('wsStatus');
const text = document.getElementById('wsStatusText');
if (connected) {
dot.classList.add('connected');
text.textContent = 'Connected';
} else {
dot.classList.remove('connected');
text.textContent = 'Disconnected';
}
}
function handleWebSocketMessage(message) {
const { type, data } = message;
if (type === 'message_created') {
messageCache.text.unshift(data);
renderMessages();
} else if (type === 'message_updated') {
const msg = messageCache.text.find(m => m.id === data.id);
if (msg) {
msg.edited_content = data.edited_content;
msg.edited_at = data.edited_at;
}
renderMessages();
} else if (type === 'message_deleted') {
messageCache.text = messageCache.text.filter(m => m.id !== data.id);
renderMessages();
} else if (type === 'attachment_uploaded') {
messageCache.image.unshift(data);
renderImages();
}
}
async function fetchMessages() {
try {
const params = new URLSearchParams();
if (selectedChannel) params.append('channel', selectedChannel);
params.append('type', 'text');
params.append('limit', '50');
const response = await fetch(`${API_BASE}/api/messages?${params}`);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const result = await response.json();
messageCache.text = result.data || [];
renderMessages();
} catch (error) {
showError(`Failed to fetch messages: ${error.message}`);
}
}
async function fetchImages() {
try {
const params = new URLSearchParams();
if (selectedChannel) params.append('channel', selectedChannel);
params.append('type', 'image');
params.append('limit', '50');
const response = await fetch(`${API_BASE}/api/messages?${params}`);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const result = await response.json();
messageCache.image = result.data || [];
renderImages();
} catch (error) {
showError(`Failed to fetch images: ${error.message}`);
}
}
function renderMessages() {
const list = document.getElementById('textList');
if (messageCache.text.length === 0) {
list.innerHTML = '<div class="empty">No messages</div>';
return;
}
list.innerHTML = messageCache.text
.map(msg => `
<div class="message-item">
<div class="message-header">
<div class="avatar">
${msg.avatar_url ? `<img src="${msg.avatar_url}" alt="${msg.username}">` : ''}
</div>
<div class="user-info">
<div class="username">${escapeHtml(msg.username)}</div>
<div class="timestamp">${new Date(msg.created_at).toLocaleString()}</div>
</div>
</div>
<div class="message-content">${escapeHtml(msg.content)}</div>
<div class="message-meta">
${msg.type === 'edited' ? '<span class="badge edited">Edited</span>' : ''}
${msg.type === 'deleted' ? '<span class="badge deleted">Deleted</span>' : ''}
${msg.edited_at ? `<span class="badge">Edited at ${new Date(msg.edited_at).toLocaleString()}</span>` : ''}
</div>
</div>
`)
.join('');
}
function renderImages() {
const grid = document.getElementById('imageGrid');
if (messageCache.image.length === 0) {
grid.innerHTML = '<div class="empty">No images</div>';
return;
}
grid.innerHTML = messageCache.image
.map(img => `
<div class="image-item">
<div class="image-preview">
${img.uploaded_url ? `<img src="${img.uploaded_url}" alt="${img.filename}">` : '<span>Uploading...</span>'}
</div>
<div class="image-info">
<div class="image-filename">${escapeHtml(img.filename)}</div>
<div class="image-meta">
<span>${(img.size / 1024).toFixed(1)}KB</span>
${img.uploaded_url ? `<a href="${img.uploaded_url}" target="_blank" class="image-url">View</a>` : '<span>Pending</span>'}
</div>
</div>
</div>
`)
.join('');
}
function showError(message) {
const errorDiv = document.getElementById('error');
errorDiv.textContent = message;
errorDiv.style.display = 'block';
setTimeout(() => {
errorDiv.style.display = 'none';
}, 5000);
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
document.querySelectorAll('.tab').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.content').forEach(c => c.classList.remove('active'));
tab.classList.add('active');
const tabName = tab.dataset.tab;
document.getElementById(tabName).classList.add('active');
if (tabName === 'text') fetchMessages();
else if (tabName === 'image') fetchImages();
});
});
document.getElementById('channelFilter').addEventListener('change', (e) => {
selectedChannel = e.target.value;
fetchMessages();
fetchImages();
});
connectWebSocket();
fetchMessages();
</script>
</body>
</html>