Files
dc-recorder/src/web/dashboardPage.tsx

218 lines
9.3 KiB
TypeScript
Raw Normal View History

import { renderToString } from "react-dom/server";
import type { MessageRecord } from "../moderation/types";
import type { ChannelSummary, GuildSummary, VoiceChannelSummary, VoiceStatus } from "../voiceController";
interface DashboardProps {
guilds: GuildSummary[];
voiceChannels: VoiceChannelSummary[];
watchChannels: ChannelSummary[];
selectedGuildId: string;
selectedChannelId: string;
messages: MessageRecord[];
status: VoiceStatus;
}
function parseMetadata(value: string | null): any {
if (!value) return {};
try {
return JSON.parse(value);
} catch {
return {};
}
}
function safeJson(value: unknown): string {
return JSON.stringify(value).replace(/</g, "\\u003c");
}
function MessageCard({ message }: { message: MessageRecord }) {
const metadata = parseMetadata(message.metadata);
const content = message.edited_content || message.content || "(empty message)";
return (
<article className="event-card" data-message-id={message.id}>
<div className="event-head">
<div className="author">
<div className="avatar">
{message.avatar_url ? <img src={message.avatar_url} alt="" /> : null}
</div>
<div className="name">{message.username || message.user_id}</div>
</div>
<div className="time">{new Date(message.created_at).toLocaleString()}</div>
</div>
<div className="message-text">{content}</div>
{metadata.stickers?.length ? (
<div className="sticker-strip">
{metadata.stickers.map((sticker: any) => (
<img key={sticker.id} className="sticker-img" src={sticker.url} alt={sticker.name} />
))}
</div>
) : null}
{metadata.embeds?.length ? (
<div className="feed">
{metadata.embeds.map((embed: any, index: number) => (
<div key={index} className="embed-card">
{embed.title ? (
embed.url ? (
<a className="embed-title" href={embed.url} target="_blank" rel="noreferrer">{embed.title}</a>
) : (
<div className="embed-title">{embed.title}</div>
)
) : null}
{embed.description ? <div className="embed-description">{embed.description}</div> : null}
{embed.fields?.map((field: any, fieldIndex: number) => (
<div key={fieldIndex} className="embed-description">{field.name}: {field.value}</div>
))}
{embed.image || embed.thumbnail ? (
<img className="embed-image" src={embed.image || embed.thumbnail} alt={embed.title || "embed image"} />
) : null}
</div>
))}
</div>
) : null}
{metadata.attachments?.length ? (
<div className="attachment-strip">
{metadata.attachments.map((attachment: any) => (
<a key={attachment.id} className="attachment-chip" href={attachment.url} target="_blank" rel="noreferrer">
{attachment.name} ({(attachment.size / 1024).toFixed(1)}KB)
</a>
))}
</div>
) : null}
<div className="badges">
{metadata.reference?.messageId ? <span className="badge">reply</span> : null}
{message.thread_id ? (
<span className="badge">{metadata.channel?.threadName ? `thread: ${metadata.channel.threadName}` : "thread"}</span>
) : null}
{message.edited_at ? <span className="badge edit">edited</span> : null}
{message.deleted_at ? <span className="badge delete">deleted</span> : null}
</div>
</article>
);
}
function DashboardPage(props: DashboardProps) {
return (
<main className="shell">
<section className="hero">
<div className="brand-card">
<div className="eyebrow"><span className="pulse" /> Discord moderation command center</div>
<h1>Voice. Text. One Watch Floor.</h1>
<p className="subtitle">Single-page watcher for live voice bridge and captured Discord messages, including stickers, embeds, replies, and uploaded image evidence inline.</p>
</div>
<div className="status-card">
<div className="status-row"><span className="status-label">WebSocket</span><span className="status-value"><span id="wsDot" className="dot" /><span id="wsStatusText">Connecting</span></span></div>
<div className="status-row"><span className="status-label">Voice Link</span><span id="voiceStatusText" className="status-value">{props.status.connected ? props.status.activeChannelName || "Connected" : "Not connected"}</span></div>
<div className="status-row"><span className="status-label">Active Tab</span><span id="activeTabLabel" className="status-value">Voice</span></div>
</div>
</section>
<nav className="tab-panel">
<div className="tabs">
<button className="tab-btn active" data-tab="voice">Voice</button>
<button className="tab-btn" data-tab="text">Text</button>
</div>
<div className="filter-row">
<span>Channel / Thread</span>
<select id="channelFilter" defaultValue={props.selectedChannelId}>
<option value="">Select channel</option>
{props.watchChannels.map((channel) => (
<option key={channel.id} value={channel.id}>{channel.name}</option>
))}
</select>
</div>
</nav>
<div id="errorBox" className="error" />
<section id="voice" className="tab-content active">
<div className="voice-layout">
<div className="content-card">
<div className="card-title"><h2>Voice Control</h2><span className="mini">bridge</span></div>
<div className="field-group">
<label htmlFor="guildSelect">Guild</label>
<select id="guildSelect" defaultValue={props.selectedGuildId}>
<option value="">Select guild</option>
{props.guilds.map((guild) => <option key={guild.id} value={guild.id}>{guild.name}</option>)}
</select>
</div>
<div className="field-group">
<label htmlFor="channelSelect">Voice Channel</label>
<select id="channelSelect">
<option value="">Select voice channel</option>
{props.voiceChannels.map((channel) => <option key={channel.id} value={channel.id}>{channel.name}</option>)}
</select>
</div>
<div className="button-row">
<button id="joinVoiceBtn" className="btn btn-success">Join</button>
<button id="disconnectVoiceBtn" className="btn btn-danger">Disconnect</button>
</div>
<div className="voice-status" id="voiceStatusNote">{props.status.connected ? `Connected to ${props.status.activeChannelName}` : "Idle"}</div>
</div>
<div className="content-card">
<div className="card-title"><h2>Live Audio</h2><span className="mini" id="listenStatus">speaker off</span></div>
<div style={{ display: "grid", gap: 12, gridTemplateColumns: "1fr 1fr", marginBottom: 14 }}>
<button id="toggleBtn" className="btn btn-primary">Start Transmitting</button>
<button id="listenBtn" className="btn btn-success">Join Listen Channel</button>
</div>
<div className="visualizer" id="visualizer" />
</div>
</div>
<div className="content-card" style={{ marginTop: 18 }}>
<div className="card-title"><h2>Participants</h2><span className="mini">speaking now</span></div>
<div id="userList" className="participants" />
</div>
</section>
<section id="text" className="tab-content">
<div className="content-card">
<div className="card-title"><h2>Text Watch</h2><span className="mini">create / edit / delete</span></div>
<div id="textList" className="feed">
{!props.selectedChannelId ? <div className="empty">Select channel to view text captures</div> : null}
{props.selectedChannelId && props.messages.length === 0 ? <div className="empty">No text captures yet</div> : null}
{props.messages.map((message) => <MessageCard key={message.id} message={message} />)}
</div>
</div>
</section>
</main>
);
}
export function renderDashboardPage(props: DashboardProps): string {
const app = renderToString(<DashboardPage {...props} />);
const bootstrap = safeJson({
guilds: props.guilds,
voiceChannels: props.voiceChannels,
watchChannels: props.watchChannels,
selectedGuildId: props.selectedGuildId,
selectedChannelId: props.selectedChannelId,
messages: props.messages,
status: props.status,
});
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Discord Moderation Watcher</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Archivo+Black&family=JetBrains+Mono:wght@400;600;700&family=Manrope:wght@500;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/dashboard.css">
</head>
<body>
<div id="root">${app}</div>
<script id="__DASHBOARD_DATA__" type="application/json">${bootstrap}</script>
<script src="/dashboard.js" defer></script>
</body>
</html>`;
}