diff --git a/bun.lockb b/bun.lockb index 04ee2b7..aece00d 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/docs/superpowers/plans/2026-05-13-react-ssr-dashboard.md b/docs/superpowers/plans/2026-05-13-react-ssr-dashboard.md new file mode 100644 index 0000000..0a5bda3 --- /dev/null +++ b/docs/superpowers/plans/2026-05-13-react-ssr-dashboard.md @@ -0,0 +1,60 @@ +# React SSR Dashboard Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace static client-rendered homepage with React server-side rendering while keeping live WebSocket/voice behavior as progressive enhancement. + +**Architecture:** Express `GET /` builds dashboard data, renders React component to HTML with `react-dom/server`, injects bootstrap JSON for client script. CSS/JS move to static assets; React owns initial markup only, lightweight browser JS handles tab switching, voice bridge, WebSocket updates, and async thread discovery. + +**Tech Stack:** React, ReactDOM server, Bun, Express, TypeScript, vanilla browser JS for progressive enhancement. + +--- + +### Task 1: Add React dependencies + +**Files:** +- Modify: `package.json` +- Modify: `bun.lockb` + +- [ ] Run `bun add react react-dom`. +- [ ] Run `bun add -d @types/react @types/react-dom`. +- [ ] Verify `bun run typecheck`. + +### Task 2: Extract dashboard assets + +**Files:** +- Create: `public/dashboard.css` +- Create: `public/dashboard.js` +- Modify: `public/index.html` + +- [ ] Move current ` - - -
-
-
-
Discord moderation command center
-

Voice. Text. One Watch Floor.

-

Single-page watcher for live voice bridge and captured Discord messages, including stickers, embeds, replies, and uploaded image evidence inline.

-
-
-
WebSocketConnecting
-
Voice LinkNot connected
-
Active TabVoice
-
-
- - - -
- -
-
-
-

Voice Control

bridge
-
- - -
-
- - -
-
- - -
-
Idle
-
- -
-

Live Audio

speaker off
-
- - -
-
-
-
- -
-

Participants

speaking now
-
-
-
- -
-
-

Text Watch

create / edit / delete
-
-
-
-
- - - - + diff --git a/src/recorder.ts b/src/recorder.ts index 8c9b0ef..68aa0d0 100644 --- a/src/recorder.ts +++ b/src/recorder.ts @@ -176,6 +176,7 @@ export async function startRecording( onPacket: (chunk) => { if (chunk.length < 8) return; segmentManager.rotateIfNeeded(oggPacketStream); + broadcaster.broadcastOpusToWeb?.(chunk, userId); if (!broadcaster.broadcastPcmToWeb) return; decoder.rotateIfNeeded(); decoder.write(chunk); diff --git a/src/types.ts b/src/types.ts index 4c8ff95..0358ad2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -42,6 +42,7 @@ export interface SegmentMetadata extends UserMetadata { export interface PcmBroadcaster { broadcastPcmToWeb?: (chunk: Buffer, userId: string) => void; + broadcastOpusToWeb?: (chunk: Buffer, userId: string) => void; updateActiveUser?: ( userId: string, data: { username: string; avatar: string; speaking: boolean }, diff --git a/src/web/dashboardPage.tsx b/src/web/dashboardPage.tsx new file mode 100644 index 0000000..6bd5d11 --- /dev/null +++ b/src/web/dashboardPage.tsx @@ -0,0 +1,217 @@ +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(/ +
+
+
+ {message.avatar_url ? : null} +
+
{message.username || message.user_id}
+
+
{new Date(message.created_at).toLocaleString()}
+
+ +
{content}
+ + {metadata.stickers?.length ? ( +
+ {metadata.stickers.map((sticker: any) => ( + {sticker.name} + ))} +
+ ) : null} + + {metadata.embeds?.length ? ( +
+ {metadata.embeds.map((embed: any, index: number) => ( +
+ {embed.title ? ( + embed.url ? ( + {embed.title} + ) : ( +
{embed.title}
+ ) + ) : null} + {embed.description ?
{embed.description}
: null} + {embed.fields?.map((field: any, fieldIndex: number) => ( +
{field.name}: {field.value}
+ ))} + {embed.image || embed.thumbnail ? ( + {embed.title + ) : null} +
+ ))} +
+ ) : null} + + {metadata.attachments?.length ? ( +
+ {metadata.attachments.map((attachment: any) => ( + + {attachment.name} ({(attachment.size / 1024).toFixed(1)}KB) + + ))} +
+ ) : null} + +
+ {metadata.reference?.messageId ? reply : null} + {message.thread_id ? ( + {metadata.channel?.threadName ? `thread: ${metadata.channel.threadName}` : "thread"} + ) : null} + {message.edited_at ? edited : null} + {message.deleted_at ? deleted : null} +
+ + ); +} + +function DashboardPage(props: DashboardProps) { + return ( +
+
+
+
Discord moderation command center
+

Voice. Text. One Watch Floor.

+

Single-page watcher for live voice bridge and captured Discord messages, including stickers, embeds, replies, and uploaded image evidence inline.

+
+
+
WebSocketConnecting
+
Voice Link{props.status.connected ? props.status.activeChannelName || "Connected" : "Not connected"}
+
Active TabVoice
+
+
+ + + +
+ +
+
+
+

Voice Control

bridge
+
+ + +
+
+ + +
+
+ + +
+
{props.status.connected ? `Connected to ${props.status.activeChannelName}` : "Idle"}
+
+ +
+

Live Audio

speaker off
+
+ + +
+
+
+
+ +
+

Participants

speaking now
+
+
+
+ +
+
+

Text Watch

create / edit / delete
+
+ {!props.selectedChannelId ?
Select channel to view text captures
: null} + {props.selectedChannelId && props.messages.length === 0 ?
No text captures yet
: null} + {props.messages.map((message) => )} +
+
+
+
+ ); +} + +export function renderDashboardPage(props: DashboardProps): string { + const app = renderToString(); + 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 ` + + + + + Discord Moderation Watcher + + + + + + +
${app}
+ + + +`; +} diff --git a/src/webserver.ts b/src/webserver.ts index 19565c0..47fd47d 100644 --- a/src/webserver.ts +++ b/src/webserver.ts @@ -10,6 +10,7 @@ import { AppError } from "./errors"; import { createChildLogger, logger } from "./logger"; import { getMetrics, uptimeGauge } from "./metrics"; import { discordPlayer } from "./player"; +import { renderDashboardPage } from "./web/dashboardPage"; import type { VoiceController } from "./voiceController"; import { getDatabase } from "./muxer-queue"; import { getMessagesByChannel, getAttachmentsByChannel } from "./moderation/messageStore"; @@ -70,6 +71,39 @@ export function startWebserver( app.use(pinoHttp({ logger })); app.use(express.json()); + app.get("/", async (req, res, next) => { + try { + const guilds = voiceController.listGuilds(); + const selectedGuildId = + typeof req.query.guild === "string" ? req.query.guild : guilds[0]?.id || ""; + const selectedChannelId = + typeof req.query.channel === "string" ? req.query.channel : ""; + const [voiceChannels, watchChannels] = selectedGuildId + ? await Promise.all([ + voiceController.listVoiceChannels(selectedGuildId), + voiceController.listWatchableChannels(selectedGuildId), + ]) + : [[], []]; + const messages = selectedChannelId + ? getMessagesByChannel(getDatabase(), selectedChannelId, 80, 0) + : []; + + res.type("html").send( + renderDashboardPage({ + guilds, + voiceChannels, + watchChannels, + selectedGuildId, + selectedChannelId, + messages, + status: voiceController.getStatus(), + }), + ); + } catch (error) { + next(error); + } + }); + app.use(express.static(path.join(__dirname, "../public"))); // Health check endpoint @@ -204,6 +238,22 @@ export function startWebserver( }); }; + // Inbound: Discord Opus → tagged chunks → browser (WebCodecs decode) + (global as any).broadcastOpusToWeb = (chunk: Buffer, userId: string) => { + let hash = 0; + for (let i = 0; i < userId.length; i++) { + hash = (hash << 5) - hash + userId.charCodeAt(i); + hash |= 0; + } + const header = Buffer.alloc(5); + header.writeUInt8(1, 0); // mode: 1 = Opus + header.writeInt32LE(hash, 1); + const packet = Buffer.concat([header, chunk]); + wsClients.forEach((client) => { + if (client.readyState === 1) client.send(packet); + }); + }; + (global as any).updateActiveUser = ( userId: string, data: { username: string; avatar: string; speaking: boolean }, diff --git a/tsconfig.json b/tsconfig.json index b648b77..74fc610 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,7 +10,8 @@ "outDir": "dist", "rootDir": "src", "experimentalDecorators": true, - "emitDecoratorMetadata": true + "emitDecoratorMetadata": true, + "jsx": "react-jsx" }, "include": ["src/**/*"], "exclude": ["node_modules", "dist"]