feat: migrate and redesign dashboard to modern React

- Full rewrite of legacy vanilla JS UI into React SPA
- Implement modern design system using Tailwind CSS and shadcn/ui primitives
- Create typed API modules and hooks for voice, media, and moderation
- Add new features: separated Music and Screen Share panels, Image Grid
- Implement unified WebSocket hook for real-time state and PCM audio
- Improve visualizer with smooth CSS transitions and live state sync
- Add __dirname polyfill for ES module compatibility
- Ensure responsive layout for mobile and desktop

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
MythEclipse
2026-05-16 19:17:34 +07:00
co-authored by Claude Opus 4.7
parent 3c7d722973
commit 82025a19b2
51 changed files with 2865 additions and 1204 deletions
+154 -109
View File
@@ -1,124 +1,169 @@
import { useEffect, useRef, useState } from "react";
import { listMessages, reanalyzeMessage } from "./api/client";
import { connectDashboardSocket } from "./ws/client";
import type { MessageRecord } from "./api/client";
import type { DashboardEvent } from "./ws/client";
import { MessageFeed } from "./components/messages/MessageFeed";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { DashboardLayout } from "./components/layout/DashboardLayout";
import { MediaPanel } from "./components/media/MediaPanel";
import { MessagesPanel } from "./components/messages/MessagesPanel";
import { ReviewPanel } from "./components/review/ReviewPanel";
import { Tabs, TabsContent } from "./components/ui/tabs";
import { VoicePanel } from "./components/voice/VoicePanel";
import { useDashboardSocket } from "./hooks/useDashboardSocket";
import { mergeMessages, useMessages } from "./hooks/useMessages";
import { useMediaControl } from "./hooks/useMediaControl";
import { useUIState } from "./hooks/useUIState";
import { useVoiceControl } from "./hooks/useVoiceControl";
import type { MessageRecord } from "./types/messages";
import type { DashboardTab } from "./types/ui";
import type { ActiveSpeaker } from "./types/voice";
function mergeMessages(
current: MessageRecord[],
incoming: MessageRecord[],
): MessageRecord[] {
const byId = new Map(current.map((message) => [message.id, message]));
for (const message of incoming) {
byId.set(message.id, { ...byId.get(message.id), ...message });
}
return Array.from(byId.values())
.sort((a, b) => b.created_at - a.created_at || b.id.localeCompare(a.id))
.slice(0, 200);
}
const SAMPLE_RATE = 24000;
const CHANNELS = 1;
export default function App() {
const [messages, setMessages] = useState<MessageRecord[]>([]);
const [wsStatus, setWsStatus] = useState<string>("connecting");
const wsRef = useRef<WebSocket | null>(null);
const { uiState, setUIState, patchUIState } = useUIState();
const voice = useVoiceControl();
const media = useMediaControl();
const messages = useMessages();
const [activeSpeakers, setActiveSpeakers] = useState<ActiveSpeaker[]>([]);
const [levels, setLevels] = useState<number[]>(Array.from({ length: 32 }, () => 0.04));
const [isListening, setIsListening] = useState(false);
const audioContextRef = useRef<AudioContext | null>(null);
const userTimelinesRef = useRef(new Map<number, number>());
const activeTab = uiState.activeTab || "voice";
const selectedVoiceGuild = uiState.selectedVoiceGuild || uiState.selectedGuild || "";
const selectedVoiceChannel = uiState.selectedVoiceChannel || "";
const selectedTextGuild = uiState.selectedTextGuild || uiState.selectedGuild || "";
const selectedTextChannel = uiState.selectedTextChannel || "";
const handleIncomingPcm = useCallback((data: ArrayBuffer) => {
const headerView = new DataView(data, 0, 4);
const userIdHash = headerView.getInt32(0, true);
const audioData = data.slice(4);
const int16Array = new Int16Array(audioData);
let sum = 0;
for (const sample of int16Array) sum += Math.abs(sample / 32768);
const average = int16Array.length ? sum / int16Array.length : 0;
setLevels((prev) => prev.map((_, index) => Math.max(0.04, average * (0.5 + Math.sin(index * 0.6 + Date.now() / 140) * 0.35 + 0.65) * 5)));
const audioContext = audioContextRef.current;
if (!isListening || !audioContext) return;
const float32Array = new Float32Array(int16Array.length);
for (let i = 0; i < int16Array.length; i++) float32Array[i] = int16Array[i] / 32768;
const audioBuffer = audioContext.createBuffer(CHANNELS, float32Array.length / CHANNELS, SAMPLE_RATE);
audioBuffer.getChannelData(0).set(float32Array);
const source = audioContext.createBufferSource();
source.buffer = audioBuffer;
source.connect(audioContext.destination);
const currentTime = audioContext.currentTime;
let nextStart = userTimelinesRef.current.get(userIdHash) || 0;
if (nextStart < currentTime) nextStart = currentTime + 0.05;
source.start(nextStart);
userTimelinesRef.current.set(userIdHash, nextStart + audioBuffer.duration);
}, [isListening]);
const socket = useDashboardSocket({
onUIState: (state) => setUIState((prev) => ({ ...prev, ...state })),
onUserState: setActiveSpeakers,
onMessageCreated: (message) => messages.setMessages((prev) => mergeMessages(prev, [message])),
onMessageUpdated: (message) => messages.setMessages((prev) => prev.map((item) => (item.id === message.id ? { ...item, ...message } as MessageRecord : item))),
onMessageDeleted: (message) => messages.setMessages((prev) => prev.map((item) => (item.id === message.id ? { ...item, type: "deleted" } : item))),
onMessageAnalyzed: (message) => messages.setMessages((prev) => mergeMessages(prev, [message])),
onAttachmentUploaded: () => messages.fetchMessages(selectedTextChannel).catch(() => undefined),
onMediaState: media.setMediaState,
onPcm: handleIncomingPcm,
});
useEffect(() => {
let cancelled = false;
if (selectedVoiceGuild) voice.loadVoiceChannels(selectedVoiceGuild).catch(() => undefined);
}, [selectedVoiceGuild, voice.loadVoiceChannels]);
listMessages(new URLSearchParams({ limit: "30" }))
.then((result) => {
if (!cancelled) {
setMessages(mergeMessages([], result.data));
}
})
.catch((err) => {
if (!cancelled) {
console.error("Failed to load messages:", err);
}
});
useEffect(() => {
if (selectedTextGuild) voice.loadTextTargets(selectedTextGuild).catch(() => undefined);
}, [selectedTextGuild, voice.loadTextTargets]);
const ws = connectDashboardSocket((event: DashboardEvent) => {
switch (event.type) {
case "message_created":
setMessages((prev) => mergeMessages(prev, [event.data]));
break;
case "message_analyzed":
setMessages((prev) => mergeMessages(prev, [event.data]));
break;
case "message_updated":
setMessages((prev) =>
prev.map((m) => (m.id === event.data.id ? { ...m, ...event.data } : m)),
);
break;
case "message_deleted":
setMessages((prev) =>
prev.map((m) =>
m.id === event.data.id ? { ...m, type: "deleted" as const } : m,
),
);
break;
}
});
useEffect(() => {
messages.fetchMessages(selectedTextChannel).catch(() => undefined);
}, [selectedTextChannel, messages.fetchMessages]);
wsRef.current = ws;
ws.addEventListener("open", () => setWsStatus("connected"));
ws.addEventListener("close", () => setWsStatus("disconnected"));
ws.addEventListener("error", () => setWsStatus("error"));
return () => {
cancelled = true;
ws.close();
wsRef.current = null;
};
}, []);
const handleReanalyze = async (id: string) => {
// Optimistic update
setMessages((prev) =>
prev.map((m) =>
m.id === id
? { ...m, ai_status: "pending" as const, ai_error: null, ai_analysis: null }
: m,
),
);
try {
await reanalyzeMessage(id);
} catch (err) {
console.error("Reanalyze failed:", err);
// Revert optimistic update on failure
setMessages((prev) =>
prev.map((m) =>
m.id === id ? { ...m, ai_status: "error" as const, ai_error: "Reanalyze failed" } : m,
),
);
const toggleListening = useCallback(async () => {
if (isListening) {
await audioContextRef.current?.suspend();
userTimelinesRef.current.clear();
setIsListening(false);
await patchUIState({ isListening: false });
return;
}
};
const AudioContextCtor = window.AudioContext || window.webkitAudioContext;
audioContextRef.current ??= new AudioContextCtor({ sampleRate: SAMPLE_RATE });
await audioContextRef.current.resume();
setIsListening(true);
await patchUIState({ isListening: true });
}, [isListening, patchUIState]);
const tabs = useMemo(() => ["voice", "media", "messages", "review"] as DashboardTab[], []);
return (
<div className="app">
<div className="sidebar">
<div className="sidebar-header">Moderation</div>
<div className="sidebar-placeholder">Channels placeholder</div>
<DashboardLayout
activeTab={activeTab}
wsStatus={socket.status}
voiceStatus={voice.voiceStatus}
onTabChange={(tab) => patchUIState({ activeTab: tab })}
>
<div className="md:hidden">
<Tabs value={activeTab} onValueChange={(value) => patchUIState({ activeTab: value as DashboardTab })}>
<div className="mb-4 grid grid-cols-4 gap-2 rounded-2xl bg-muted p-1">
{tabs.map((tab) => (
<button key={tab} className={`rounded-xl px-2 py-2 text-xs font-medium ${activeTab === tab ? "bg-background text-foreground" : "text-muted-foreground"}`} onClick={() => patchUIState({ activeTab: tab })}>
{tab}
</button>
))}
</div>
</Tabs>
</div>
<div className="main">
<div className="header">
<h1>Discord Moderation Dashboard</h1>
<span className="ws-status" data-status={wsStatus}>
{wsStatus}
</span>
</div>
<div className="content">
<MessageFeed messages={messages} onReanalyze={handleReanalyze} />
</div>
<ReviewPanel messages={messages} onReanalyze={handleReanalyze} />
</div>
</div>
<Tabs value={activeTab} onValueChange={(value) => patchUIState({ activeTab: value as DashboardTab })}>
<TabsContent value="voice">
<VoicePanel
guilds={voice.guilds}
channels={voice.voiceChannels}
selectedGuild={selectedVoiceGuild}
selectedChannel={selectedVoiceChannel}
status={voice.voiceStatus}
loading={voice.loading}
activeSpeakers={activeSpeakers}
levels={levels}
isListening={isListening}
onGuildChange={(guildId) => patchUIState({ selectedVoiceGuild: guildId, selectedVoiceChannel: "" })}
onChannelChange={(channelId) => patchUIState({ selectedVoiceChannel: channelId })}
onJoin={() => voice.joinVoice(selectedVoiceGuild, selectedVoiceChannel)}
onDisconnect={() => voice.leaveVoice()}
onListenToggle={toggleListening}
/>
</TabsContent>
<TabsContent value="media">
<MediaPanel
state={media.mediaState}
loading={media.loading}
onQueueMusic={(source) => media.enqueue(source, "music")}
onStartScreen={(source) => media.enqueue(source, "screen")}
onSkip={media.skip}
onStop={media.stop}
/>
</TabsContent>
<TabsContent value="messages">
<MessagesPanel
guilds={voice.guilds}
channels={voice.textChannels}
selectedGuild={selectedTextGuild}
selectedChannel={selectedTextChannel}
messages={messages.messages}
onGuildChange={(guildId) => patchUIState({ selectedTextGuild: guildId, selectedTextChannel: "" })}
onChannelChange={(channelId) => patchUIState({ selectedTextChannel: channelId })}
onReanalyze={messages.reanalyze}
/>
</TabsContent>
<TabsContent value="review">
<ReviewPanel messages={messages.messages} onReanalyze={messages.reanalyze} />
</TabsContent>
</Tabs>
</DashboardLayout>
);
}