Refactor dashboard page: replace React component with static HTML, remove unused dashboardPage.tsx, and update webserver to serve new index.html
This commit is contained in:
@@ -1,42 +0,0 @@
|
||||
class MicrophoneProcessor extends AudioWorkletProcessor {
|
||||
constructor() {
|
||||
super();
|
||||
this.noiseGateThreshold = 0.01;
|
||||
this.noiseGateHoldFrames = 3;
|
||||
this.noiseGateHold = 0;
|
||||
}
|
||||
|
||||
process(inputs, outputs, parameters) {
|
||||
const input = inputs[0];
|
||||
if (!input || input.length === 0) return true;
|
||||
|
||||
const inputData = input[0];
|
||||
const output = outputs[0];
|
||||
if (output && output.length > 0) {
|
||||
output[0].set(inputData);
|
||||
}
|
||||
|
||||
let sum = 0;
|
||||
for (let i = 0; i < inputData.length; i++) {
|
||||
sum += inputData[i] * inputData[i];
|
||||
}
|
||||
const rms = Math.sqrt(sum / inputData.length);
|
||||
|
||||
if (rms < this.noiseGateThreshold && this.noiseGateHold <= 0) {
|
||||
this.port.postMessage({ type: 'audio', rms: 0, data: null });
|
||||
return true;
|
||||
}
|
||||
|
||||
this.noiseGateHold = rms >= this.noiseGateThreshold ? this.noiseGateHoldFrames : this.noiseGateHold - 1;
|
||||
|
||||
const pcm = new Int16Array(inputData.length);
|
||||
for (let i = 0; i < inputData.length; i++) {
|
||||
pcm[i] = Math.max(-1, Math.min(1, inputData[i])) * 32767;
|
||||
}
|
||||
|
||||
this.port.postMessage({ type: 'audio', rms, data: pcm.buffer }, [pcm.buffer]);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
registerProcessor('microphone-processor', MicrophoneProcessor);
|
||||
@@ -1,528 +0,0 @@
|
||||
<!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>
|
||||
@@ -1,496 +0,0 @@
|
||||
// Tone.js loaded via CDN as global object
|
||||
const bootstrapData = JSON.parse(document.getElementById('__DASHBOARD_DATA__')?.textContent || '{}');
|
||||
const state = {
|
||||
socket: null,
|
||||
activeTab: 'voice',
|
||||
selectedChannel: bootstrapData.selectedChannelId || '',
|
||||
text: bootstrapData.messages || [],
|
||||
isStreaming: false,
|
||||
isListening: false,
|
||||
audioContextTransmit: null,
|
||||
audioContextListen: null,
|
||||
processor: null,
|
||||
nextStartTime: 0,
|
||||
userTimelines: new Map(),
|
||||
};
|
||||
|
||||
const SAMPLE_RATE = 24000;
|
||||
const CHANNELS = 1;
|
||||
const NOISE_GATE_THRESHOLD = 0.01;
|
||||
|
||||
const el = {
|
||||
wsDot: document.getElementById('wsDot'),
|
||||
wsStatusText: document.getElementById('wsStatusText'),
|
||||
activeTabLabel: document.getElementById('activeTabLabel'),
|
||||
errorBox: document.getElementById('errorBox'),
|
||||
guildSelect: document.getElementById('guildSelect'),
|
||||
channelSelect: document.getElementById('channelSelect'),
|
||||
channelFilter: document.getElementById('channelFilter'),
|
||||
joinVoiceBtn: document.getElementById('joinVoiceBtn'),
|
||||
disconnectVoiceBtn: document.getElementById('disconnectVoiceBtn'),
|
||||
voiceStatusText: document.getElementById('voiceStatusText'),
|
||||
voiceStatusNote: document.getElementById('voiceStatusNote'),
|
||||
toggleBtn: document.getElementById('toggleBtn'),
|
||||
listenBtn: document.getElementById('listenBtn'),
|
||||
listenStatus: document.getElementById('listenStatus'),
|
||||
visualizer: document.getElementById('visualizer'),
|
||||
userList: document.getElementById('userList'),
|
||||
textList: document.getElementById('textList'),
|
||||
};
|
||||
|
||||
for (let i = 0; i < 32; i++) {
|
||||
const bar = document.createElement('div');
|
||||
bar.className = 'bar';
|
||||
el.visualizer.appendChild(bar);
|
||||
}
|
||||
const bars = [...document.querySelectorAll('.bar')];
|
||||
|
||||
async function apiRequest(url, options = {}) {
|
||||
const response = await fetch(url, {
|
||||
headers: { 'Content-Type': 'application/json', ...(options.headers || {}) },
|
||||
...options,
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ message: response.statusText }));
|
||||
throw new Error(error.message || response.statusText);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
function showError(message) {
|
||||
el.errorBox.textContent = message;
|
||||
el.errorBox.style.display = 'block';
|
||||
setTimeout(() => { el.errorBox.style.display = 'none'; }, 4500);
|
||||
}
|
||||
|
||||
function renderOptions(select, items, placeholder) {
|
||||
select.replaceChildren();
|
||||
const first = document.createElement('option');
|
||||
first.value = '';
|
||||
first.textContent = placeholder;
|
||||
select.appendChild(first);
|
||||
for (const item of items) {
|
||||
const option = document.createElement('option');
|
||||
option.value = item.id;
|
||||
option.textContent = item.name;
|
||||
select.appendChild(option);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadGuilds() {
|
||||
const guilds = bootstrapData.guilds || await apiRequest('/api/guilds');
|
||||
renderOptions(el.guildSelect, guilds, 'Select guild');
|
||||
const guildId = bootstrapData.selectedGuildId || guilds[0]?.id || '';
|
||||
if (guildId) {
|
||||
el.guildSelect.value = guildId;
|
||||
await loadChannels(guildId);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadChannels(guildId) {
|
||||
const useBootstrap = guildId === bootstrapData.selectedGuildId;
|
||||
const [voiceChannels, watchChannels] = await Promise.all([
|
||||
useBootstrap && bootstrapData.voiceChannels ? bootstrapData.voiceChannels : apiRequest(`/api/guilds/${guildId}/voice-channels`),
|
||||
useBootstrap && bootstrapData.watchChannels ? bootstrapData.watchChannels : apiRequest(`/api/guilds/${guildId}/channels`),
|
||||
]);
|
||||
renderOptions(el.channelSelect, voiceChannels, 'Select voice channel');
|
||||
renderOptions(el.channelFilter, watchChannels, 'Select channel');
|
||||
el.channelFilter.value = state.selectedChannel;
|
||||
apiRequest(`/api/guilds/${guildId}/threads`)
|
||||
.then((threads) => appendOptions(el.channelFilter, threads))
|
||||
.catch((error) => showError(`Thread discovery failed: ${error.message}`));
|
||||
}
|
||||
|
||||
function appendOptions(select, items) {
|
||||
const existing = new Set([...select.options].map((option) => option.value));
|
||||
for (const item of items) {
|
||||
if (existing.has(item.id)) continue;
|
||||
const option = document.createElement('option');
|
||||
option.value = item.id;
|
||||
option.textContent = item.name;
|
||||
select.appendChild(option);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshStatus() {
|
||||
try {
|
||||
const status = await apiRequest('/api/status');
|
||||
el.voiceStatusText.textContent = status.connected ? status.activeChannelName || 'Connected' : 'Not connected';
|
||||
el.voiceStatusNote.textContent = status.connected ? `Connected to ${status.activeChannelName}` : 'Idle';
|
||||
} catch (error) {
|
||||
showError(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function connectVoice() {
|
||||
const guildId = el.guildSelect.value;
|
||||
const channelId = el.channelSelect.value;
|
||||
if (!guildId || !channelId) return showError('Select guild and voice channel first');
|
||||
const status = await apiRequest('/api/connect', { method: 'POST', body: JSON.stringify({ guildId, channelId }) });
|
||||
el.voiceStatusText.textContent = status.activeChannelName || 'Connected';
|
||||
el.voiceStatusNote.textContent = `Connected to ${status.activeChannelName}`;
|
||||
}
|
||||
|
||||
async function disconnectVoice() {
|
||||
await apiRequest('/api/disconnect', { method: 'POST' });
|
||||
await refreshStatus();
|
||||
}
|
||||
|
||||
function connectWebSocket() {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
state.socket = new WebSocket(`${protocol}//${window.location.host}/ws`);
|
||||
state.socket.binaryType = 'arraybuffer';
|
||||
|
||||
state.socket.onopen = () => {
|
||||
el.wsDot.classList.add('on');
|
||||
el.wsStatusText.textContent = 'Connected';
|
||||
};
|
||||
|
||||
state.socket.onclose = () => {
|
||||
el.wsDot.classList.remove('on');
|
||||
el.wsStatusText.textContent = 'Reconnecting';
|
||||
setTimeout(connectWebSocket, 2500);
|
||||
};
|
||||
|
||||
state.socket.onerror = () => {
|
||||
el.wsDot.classList.remove('on');
|
||||
el.wsDot.classList.add('warn');
|
||||
el.wsStatusText.textContent = 'Socket error';
|
||||
};
|
||||
|
||||
state.socket.onmessage = (event) => {
|
||||
if (typeof event.data === 'string') {
|
||||
handleJsonEvent(event.data);
|
||||
return;
|
||||
}
|
||||
console.log('[listen] Received PCM packet:', event.data.byteLength, 'bytes, isListening:', state.isListening);
|
||||
if (state.isListening) {
|
||||
console.log('[listen] Playing PCM...');
|
||||
playPcm(event.data);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function handleJsonEvent(raw) {
|
||||
const message = JSON.parse(raw);
|
||||
if (message.type === 'user_state') return renderUsers(message.users || []);
|
||||
if (message.type === 'message_created') {
|
||||
state.text.unshift(message.data);
|
||||
renderText();
|
||||
}
|
||||
if (message.type === 'message_updated') {
|
||||
const item = state.text.find((entry) => entry.id === message.data.id);
|
||||
if (item) Object.assign(item, { edited_content: message.data.edited_content, edited_at: message.data.edited_at, type: 'edited' });
|
||||
renderText();
|
||||
}
|
||||
if (message.type === 'message_deleted') {
|
||||
const item = state.text.find((entry) => entry.id === message.data.id);
|
||||
if (item) Object.assign(item, { deleted_at: message.data.deleted_at, type: 'deleted' });
|
||||
renderText();
|
||||
}
|
||||
if (message.type === 'attachment_uploaded') fetchText();
|
||||
}
|
||||
|
||||
function renderUsers(users) {
|
||||
el.userList.replaceChildren();
|
||||
if (users.length === 0) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'empty';
|
||||
empty.textContent = 'No active speakers';
|
||||
el.userList.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
for (const user of users) {
|
||||
const row = document.createElement('div');
|
||||
row.className = `user-item${user.speaking ? ' speaking' : ''}`;
|
||||
const img = document.createElement('img');
|
||||
img.src = user.avatar || '';
|
||||
img.alt = '';
|
||||
const name = document.createElement('span');
|
||||
name.textContent = user.username;
|
||||
row.append(img, name);
|
||||
el.userList.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchText() {
|
||||
if (!state.selectedChannel) return renderText();
|
||||
const result = await apiRequest(`/api/messages?channel=${encodeURIComponent(state.selectedChannel)}&type=text&limit=80`);
|
||||
state.text = result.data || [];
|
||||
renderText();
|
||||
}
|
||||
|
||||
function parseMetadata(value) {
|
||||
if (!value) return {};
|
||||
try { return JSON.parse(value); } catch { return {}; }
|
||||
}
|
||||
|
||||
function renderText() {
|
||||
el.textList.replaceChildren();
|
||||
if (!state.selectedChannel) return appendEmpty(el.textList, 'Select channel to view text captures');
|
||||
if (state.text.length === 0) return appendEmpty(el.textList, 'No text captures yet');
|
||||
for (const msg of state.text) {
|
||||
const metadata = parseMetadata(msg.metadata);
|
||||
const card = document.createElement('article');
|
||||
card.className = 'event-card';
|
||||
const head = document.createElement('div');
|
||||
head.className = 'event-head';
|
||||
const author = document.createElement('div');
|
||||
author.className = 'author';
|
||||
const avatar = document.createElement('div');
|
||||
avatar.className = 'avatar';
|
||||
if (msg.avatar_url) {
|
||||
const img = document.createElement('img');
|
||||
img.src = msg.avatar_url;
|
||||
img.alt = '';
|
||||
avatar.appendChild(img);
|
||||
}
|
||||
const name = document.createElement('div');
|
||||
name.className = 'name';
|
||||
name.textContent = msg.username || msg.user_id;
|
||||
author.append(avatar, name);
|
||||
const time = document.createElement('div');
|
||||
time.className = 'time';
|
||||
time.textContent = new Date(msg.created_at).toLocaleString();
|
||||
head.append(author, time);
|
||||
const text = document.createElement('div');
|
||||
text.className = 'message-text';
|
||||
text.textContent = msg.edited_content || msg.content || '(empty message)';
|
||||
const stickers = renderStickers(metadata.stickers || []);
|
||||
const embeds = renderEmbeds(metadata.embeds || []);
|
||||
const attachments = renderAttachments(metadata.attachments || []);
|
||||
const badges = document.createElement('div');
|
||||
badges.className = 'badges';
|
||||
if (metadata.reference?.messageId) appendBadge(badges, 'reply', '');
|
||||
if (msg.thread_id) appendBadge(badges, metadata.channel?.threadName ? `thread: ${metadata.channel.threadName}` : 'thread', '');
|
||||
if (msg.edited_at) appendBadge(badges, 'edited', 'edit');
|
||||
if (msg.deleted_at) appendBadge(badges, 'deleted', 'delete');
|
||||
card.append(head, text);
|
||||
if (stickers.childElementCount > 0) card.appendChild(stickers);
|
||||
if (embeds.childElementCount > 0) card.appendChild(embeds);
|
||||
if (attachments.childElementCount > 0) card.appendChild(attachments);
|
||||
card.appendChild(badges);
|
||||
el.textList.appendChild(card);
|
||||
}
|
||||
}
|
||||
|
||||
function renderStickers(stickers) {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'sticker-strip';
|
||||
for (const sticker of stickers) {
|
||||
const img = document.createElement('img');
|
||||
img.className = 'sticker-img';
|
||||
img.src = sticker.url;
|
||||
img.alt = sticker.name;
|
||||
wrap.appendChild(img);
|
||||
}
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function renderEmbeds(embeds) {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'feed';
|
||||
for (const embed of embeds) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'embed-card';
|
||||
if (embed.title) {
|
||||
const title = document.createElement(embed.url ? 'a' : 'div');
|
||||
title.className = 'embed-title';
|
||||
title.textContent = embed.title;
|
||||
if (embed.url) {
|
||||
title.href = embed.url;
|
||||
title.target = '_blank';
|
||||
title.rel = 'noreferrer';
|
||||
}
|
||||
card.appendChild(title);
|
||||
}
|
||||
if (embed.description) {
|
||||
const desc = document.createElement('div');
|
||||
desc.className = 'embed-description';
|
||||
desc.textContent = embed.description;
|
||||
card.appendChild(desc);
|
||||
}
|
||||
for (const field of embed.fields || []) {
|
||||
const fieldNode = document.createElement('div');
|
||||
fieldNode.className = 'embed-description';
|
||||
fieldNode.textContent = `${field.name}: ${field.value}`;
|
||||
card.appendChild(fieldNode);
|
||||
}
|
||||
if (embed.image || embed.thumbnail) {
|
||||
const img = document.createElement('img');
|
||||
img.className = 'embed-image';
|
||||
img.src = embed.image || embed.thumbnail;
|
||||
img.alt = embed.title || 'embed image';
|
||||
card.appendChild(img);
|
||||
}
|
||||
wrap.appendChild(card);
|
||||
}
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function renderAttachments(attachments) {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'attachment-strip';
|
||||
for (const attachment of attachments) {
|
||||
const link = document.createElement('a');
|
||||
link.className = 'attachment-chip';
|
||||
link.href = attachment.url;
|
||||
link.target = '_blank';
|
||||
link.rel = 'noreferrer';
|
||||
link.textContent = `${attachment.name} (${(attachment.size / 1024).toFixed(1)}KB)`;
|
||||
wrap.appendChild(link);
|
||||
}
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function appendBadge(parent, label, className) {
|
||||
const badge = document.createElement('span');
|
||||
badge.className = `badge ${className}`;
|
||||
badge.textContent = label;
|
||||
parent.appendChild(badge);
|
||||
}
|
||||
|
||||
function appendEmpty(parent, message) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'empty';
|
||||
empty.textContent = message;
|
||||
parent.appendChild(empty);
|
||||
}
|
||||
|
||||
async function startStreaming() {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
state.isStreaming = true;
|
||||
el.toggleBtn.textContent = 'Stop Transmitting';
|
||||
|
||||
state.audioContextTransmit = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: SAMPLE_RATE });
|
||||
const source = state.audioContextTransmit.createMediaStreamSource(stream);
|
||||
|
||||
const analyser = state.audioContextTransmit.createAnalyser();
|
||||
analyser.fftSize = 64;
|
||||
source.connect(analyser);
|
||||
const dataArray = new Uint8Array(analyser.frequencyBinCount);
|
||||
|
||||
state.processor = state.audioContextTransmit.createScriptProcessor(4096, 1, 1);
|
||||
source.connect(state.processor);
|
||||
state.processor.connect(state.audioContextTransmit.destination);
|
||||
|
||||
state.processor.onaudioprocess = (e) => {
|
||||
if (!state.isStreaming || state.socket?.readyState !== WebSocket.OPEN) return;
|
||||
|
||||
const inputData = e.inputBuffer.getChannelData(0);
|
||||
const pcmData = new Int16Array(inputData.length);
|
||||
for (let i = 0; i < inputData.length; i++) {
|
||||
pcmData[i] = Math.max(-1, Math.min(1, inputData[i])) * 32767;
|
||||
}
|
||||
state.socket.send(pcmData.buffer);
|
||||
|
||||
analyser.getByteFrequencyData(dataArray);
|
||||
bars.forEach((bar, index) => {
|
||||
const percent = (dataArray[index] / 255) * 100;
|
||||
bar.style.height = `${Math.max(2, percent)}%`;
|
||||
});
|
||||
};
|
||||
} catch (err) {
|
||||
showError(`Microphone access denied: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function stopStreaming() {
|
||||
state.isStreaming = false;
|
||||
if (state.processor) state.processor.disconnect();
|
||||
if (state.audioContextTransmit) state.audioContextTransmit.close();
|
||||
state.processor = null;
|
||||
state.audioContextTransmit = null;
|
||||
el.toggleBtn.textContent = 'Start Transmitting';
|
||||
bars.forEach(bar => bar.style.height = '2px');
|
||||
}
|
||||
|
||||
function toggleListen() {
|
||||
state.isListening = !state.isListening;
|
||||
console.log('[listen] Toggle listen:', state.isListening);
|
||||
if (state.isListening) {
|
||||
state.audioContextListen = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: SAMPLE_RATE });
|
||||
state.userTimelines.clear();
|
||||
console.log('[listen] AudioContext created, sampleRate:', SAMPLE_RATE);
|
||||
el.listenBtn.textContent = 'Leave Listen Channel';
|
||||
el.listenStatus.textContent = 'speaker on';
|
||||
} else {
|
||||
state.audioContextListen?.close();
|
||||
state.audioContextListen = null;
|
||||
state.userTimelines.clear();
|
||||
el.listenBtn.textContent = 'Join Listen Channel';
|
||||
el.listenStatus.textContent = 'speaker off';
|
||||
}
|
||||
}
|
||||
|
||||
function playPcm(arrayBuffer) {
|
||||
console.log('[listen] playPcm called, isListening:', state.isListening, 'hasContext:', !!state.audioContextListen);
|
||||
if (!state.isListening || !state.audioContextListen) return;
|
||||
|
||||
const headerView = new DataView(arrayBuffer, 0, 4);
|
||||
const userIdHash = headerView.getInt32(0, true);
|
||||
const audioData = arrayBuffer.slice(4);
|
||||
console.log('[listen] userIdHash:', userIdHash, 'audioDataLength:', audioData.byteLength);
|
||||
|
||||
const int16Array = new Int16Array(audioData);
|
||||
const float32Array = new Float32Array(int16Array.length);
|
||||
for (let i = 0; i < int16Array.length; i++) float32Array[i] = int16Array[i] / 32768;
|
||||
|
||||
const audioBuffer = state.audioContextListen.createBuffer(CHANNELS, float32Array.length / CHANNELS, SAMPLE_RATE);
|
||||
const nowBuffering = audioBuffer.getChannelData(0);
|
||||
for (let i = 0; i < audioBuffer.length; i++) nowBuffering[i] = float32Array[i];
|
||||
|
||||
const source = state.audioContextListen.createBufferSource();
|
||||
source.buffer = audioBuffer;
|
||||
source.connect(state.audioContextListen.destination);
|
||||
|
||||
const currentTime = state.audioContextListen.currentTime;
|
||||
let userNextStartTime = state.userTimelines.get(userIdHash) || 0;
|
||||
|
||||
if (userNextStartTime < currentTime) userNextStartTime = currentTime + 0.05;
|
||||
console.log('[listen] Starting playback at:', userNextStartTime, 'duration:', audioBuffer.duration);
|
||||
source.start(userNextStartTime);
|
||||
userNextStartTime += audioBuffer.duration;
|
||||
state.userTimelines.set(userIdHash, userNextStartTime);
|
||||
}
|
||||
|
||||
function updateVisualizer(level) {
|
||||
bars.forEach((bar, index) => {
|
||||
const wave = Math.sin(index * 0.55 + Date.now() / 140) * 0.35 + 0.65;
|
||||
bar.style.height = `${Math.max(3, level * 190 * wave)}px`;
|
||||
});
|
||||
}
|
||||
|
||||
document.querySelectorAll('.tab-btn').forEach((button) => {
|
||||
button.addEventListener('click', async () => {
|
||||
document.querySelectorAll('.tab-btn').forEach((item) => item.classList.remove('active'));
|
||||
document.querySelectorAll('.tab-content').forEach((item) => item.classList.remove('active'));
|
||||
button.classList.add('active');
|
||||
state.activeTab = button.dataset.tab;
|
||||
document.getElementById(state.activeTab).classList.add('active');
|
||||
el.activeTabLabel.textContent = button.textContent;
|
||||
if (state.activeTab === 'text') await fetchText();
|
||||
});
|
||||
});
|
||||
|
||||
el.guildSelect.addEventListener('change', () => loadChannels(el.guildSelect.value).catch((error) => showError(error.message)));
|
||||
el.joinVoiceBtn.addEventListener('click', () => connectVoice().catch((error) => showError(error.message)));
|
||||
el.disconnectVoiceBtn.addEventListener('click', () => disconnectVoice().catch((error) => showError(error.message)));
|
||||
el.toggleBtn.addEventListener('click', () => state.isStreaming ? stopStreaming() : startStreaming());
|
||||
el.listenBtn.addEventListener('click', toggleListen);
|
||||
el.channelFilter.addEventListener('change', async () => {
|
||||
state.selectedChannel = el.channelFilter.value;
|
||||
const url = new URL(window.location.href);
|
||||
if (state.selectedChannel) url.searchParams.set('channel', state.selectedChannel);
|
||||
else url.searchParams.delete('channel');
|
||||
if (el.guildSelect.value) url.searchParams.set('guild', el.guildSelect.value);
|
||||
window.history.replaceState({}, '', url);
|
||||
await fetchText().catch((error) => showError(error.message));
|
||||
});
|
||||
|
||||
connectWebSocket();
|
||||
loadGuilds().then(refreshStatus).catch((error) => showError(error.message));
|
||||
setInterval(() => {
|
||||
if (state.activeTab === 'text') fetchText().catch(() => {});
|
||||
}, 7000);
|
||||
+101
-1
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user