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:
co-authored by
Claude Opus 4.7
parent
3c7d722973
commit
82025a19b2
@@ -0,0 +1,96 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { MessageRecord } from "../types/messages";
|
||||
import type { MediaState } from "../types/media";
|
||||
import type { UIState } from "../types/ui";
|
||||
import type { ActiveSpeaker } from "../types/voice";
|
||||
|
||||
export type WebSocketStatus = "connecting" | "connected" | "disconnected" | "error";
|
||||
|
||||
export interface DashboardSocketHandlers {
|
||||
onUIState?: (state: UIState) => void;
|
||||
onUserState?: (users: ActiveSpeaker[]) => void;
|
||||
onMessageCreated?: (message: MessageRecord) => void;
|
||||
onMessageUpdated?: (message: Partial<MessageRecord> & { id: string }) => void;
|
||||
onMessageDeleted?: (message: { id: string }) => void;
|
||||
onMessageAnalyzed?: (message: MessageRecord) => void;
|
||||
onAttachmentUploaded?: () => void;
|
||||
onMediaState?: (state: MediaState) => void;
|
||||
onPcm?: (data: ArrayBuffer) => void;
|
||||
}
|
||||
|
||||
export function useDashboardSocket(handlers: DashboardSocketHandlers) {
|
||||
const [status, setStatus] = useState<WebSocketStatus>("connecting");
|
||||
const handlersRef = useRef(handlers);
|
||||
const socketRef = useRef<WebSocket | null>(null);
|
||||
|
||||
handlersRef.current = handlers;
|
||||
|
||||
useEffect(() => {
|
||||
let closed = false;
|
||||
let reconnectTimer: number | null = null;
|
||||
|
||||
const connect = () => {
|
||||
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const socket = new WebSocket(`${protocol}//${location.host}/ws`);
|
||||
socket.binaryType = "arraybuffer";
|
||||
socketRef.current = socket;
|
||||
setStatus("connecting");
|
||||
|
||||
socket.addEventListener("open", () => setStatus("connected"));
|
||||
socket.addEventListener("error", () => setStatus("error"));
|
||||
socket.addEventListener("close", () => {
|
||||
setStatus("disconnected");
|
||||
if (!closed) reconnectTimer = window.setTimeout(connect, 2500);
|
||||
});
|
||||
socket.addEventListener("message", (event) => {
|
||||
if (event.data instanceof ArrayBuffer) {
|
||||
handlersRef.current.onPcm?.(event.data);
|
||||
return;
|
||||
}
|
||||
if (typeof event.data !== "string") return;
|
||||
try {
|
||||
const message = JSON.parse(event.data);
|
||||
switch (message.type) {
|
||||
case "ui_state":
|
||||
handlersRef.current.onUIState?.(message.state);
|
||||
break;
|
||||
case "user_state":
|
||||
handlersRef.current.onUserState?.(message.users || []);
|
||||
break;
|
||||
case "message_created":
|
||||
handlersRef.current.onMessageCreated?.(message.data);
|
||||
break;
|
||||
case "message_updated":
|
||||
handlersRef.current.onMessageUpdated?.(message.data);
|
||||
break;
|
||||
case "message_deleted":
|
||||
handlersRef.current.onMessageDeleted?.(message.data);
|
||||
break;
|
||||
case "message_analyzed":
|
||||
handlersRef.current.onMessageAnalyzed?.(message.data);
|
||||
break;
|
||||
case "attachment_uploaded":
|
||||
handlersRef.current.onAttachmentUploaded?.();
|
||||
break;
|
||||
case "media_state":
|
||||
handlersRef.current.onMediaState?.(message.state);
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed socket messages
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
closed = true;
|
||||
if (reconnectTimer) window.clearTimeout(reconnectTimer);
|
||||
socketRef.current?.close();
|
||||
socketRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { status, socketRef };
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { getMediaStatus, queueMedia, skipMedia, stopMedia } from "../api/media";
|
||||
import type { MediaMode, MediaState } from "../types/media";
|
||||
|
||||
const emptyMediaState: MediaState = { playing: false, current: null, queue: [] };
|
||||
|
||||
export function useMediaControl() {
|
||||
const [mediaState, setMediaState] = useState<MediaState>(emptyMediaState);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const refreshMedia = useCallback(async () => {
|
||||
const state = await getMediaStatus();
|
||||
setMediaState(state);
|
||||
return state;
|
||||
}, []);
|
||||
|
||||
const enqueue = useCallback(async (source: string, mode: MediaMode) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const state = await queueMedia(source, mode);
|
||||
setMediaState(state);
|
||||
return state;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setError(message);
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const skip = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const state = await skipMedia();
|
||||
setMediaState(state);
|
||||
return state;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const stop = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const state = await stopMedia();
|
||||
setMediaState(state);
|
||||
return state;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refreshMedia().catch((err) => setError(err instanceof Error ? err.message : String(err)));
|
||||
}, [refreshMedia]);
|
||||
|
||||
return { mediaState, setMediaState, loading, error, refreshMedia, enqueue, skip, stop };
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { listMessages, reanalyzeMessage } from "../api/messages";
|
||||
import type { MessageRecord } from "../types/messages";
|
||||
|
||||
export 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);
|
||||
}
|
||||
|
||||
export function useMessages() {
|
||||
const [messages, setMessages] = useState<MessageRecord[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchMessages = useCallback(async (channelId?: string) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const params = new URLSearchParams({ limit: "80" });
|
||||
if (channelId) params.set("channel", channelId);
|
||||
const result = await listMessages(params);
|
||||
setMessages(result.data);
|
||||
return result.data;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setError(message);
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const reanalyze = useCallback(async (id: string) => {
|
||||
setMessages((prev) =>
|
||||
prev.map((message) =>
|
||||
message.id === id
|
||||
? { ...message, ai_status: "pending", ai_error: null, ai_analysis: null }
|
||||
: message,
|
||||
),
|
||||
);
|
||||
await reanalyzeMessage(id);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchMessages().catch(() => undefined);
|
||||
}, [fetchMessages]);
|
||||
|
||||
return { messages, setMessages, loading, error, fetchMessages, reanalyze };
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { getUIState, updateUIState } from "../api/uiState";
|
||||
import type { UIState } from "../types/ui";
|
||||
|
||||
export function useUIState() {
|
||||
const [uiState, setUIState] = useState<UIState>({ activeTab: "voice" });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
getUIState()
|
||||
.then((state) => {
|
||||
if (!cancelled) setUIState({ activeTab: "voice", ...state });
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) setError(err instanceof Error ? err.message : String(err));
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const patchUIState = useCallback(async (patch: Partial<UIState>) => {
|
||||
setUIState((prev) => ({ ...prev, ...patch }));
|
||||
const next = await updateUIState(patch);
|
||||
setUIState((prev) => ({ ...prev, ...next }));
|
||||
return next;
|
||||
}, []);
|
||||
|
||||
return { uiState, setUIState, patchUIState, loading, error };
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
connectVoice,
|
||||
disconnectVoice,
|
||||
getGuilds,
|
||||
getTextChannels,
|
||||
getThreads,
|
||||
getVoiceChannels,
|
||||
getVoiceStatus,
|
||||
} from "../api/voice";
|
||||
import type { Channel, Guild, VoiceStatus } from "../types/voice";
|
||||
|
||||
export function useVoiceControl() {
|
||||
const [guilds, setGuilds] = useState<Guild[]>([]);
|
||||
const [voiceChannels, setVoiceChannels] = useState<Channel[]>([]);
|
||||
const [textChannels, setTextChannels] = useState<Channel[]>([]);
|
||||
const [voiceStatus, setVoiceStatus] = useState<VoiceStatus>({ connected: false });
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const refreshGuilds = useCallback(async () => {
|
||||
setError(null);
|
||||
const nextGuilds = await getGuilds();
|
||||
setGuilds(nextGuilds);
|
||||
return nextGuilds;
|
||||
}, []);
|
||||
|
||||
const refreshVoiceStatus = useCallback(async () => {
|
||||
const status = await getVoiceStatus();
|
||||
setVoiceStatus(status);
|
||||
return status;
|
||||
}, []);
|
||||
|
||||
const loadVoiceChannels = useCallback(async (guildId: string) => {
|
||||
if (!guildId) {
|
||||
setVoiceChannels([]);
|
||||
return [];
|
||||
}
|
||||
const channels = await getVoiceChannels(guildId);
|
||||
setVoiceChannels(channels);
|
||||
return channels;
|
||||
}, []);
|
||||
|
||||
const loadTextTargets = useCallback(async (guildId: string) => {
|
||||
if (!guildId) {
|
||||
setTextChannels([]);
|
||||
return [];
|
||||
}
|
||||
const [channels, threads] = await Promise.all([
|
||||
getTextChannels(guildId),
|
||||
getThreads(guildId).catch(() => []),
|
||||
]);
|
||||
const combined = [...channels, ...threads];
|
||||
setTextChannels(combined);
|
||||
return combined;
|
||||
}, []);
|
||||
|
||||
const joinVoice = useCallback(async (guildId: string, channelId: string) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const status = await connectVoice(guildId, channelId);
|
||||
setVoiceStatus(status);
|
||||
return status;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setError(message);
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const leaveVoice = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const status = await disconnectVoice();
|
||||
setVoiceStatus(status);
|
||||
return status;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setError(message);
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refreshGuilds().catch((err) => setError(err instanceof Error ? err.message : String(err)));
|
||||
refreshVoiceStatus().catch((err) => setError(err instanceof Error ? err.message : String(err)));
|
||||
}, [refreshGuilds, refreshVoiceStatus]);
|
||||
|
||||
return {
|
||||
guilds,
|
||||
voiceChannels,
|
||||
textChannels,
|
||||
voiceStatus,
|
||||
loading,
|
||||
error,
|
||||
refreshGuilds,
|
||||
refreshVoiceStatus,
|
||||
loadVoiceChannels,
|
||||
loadTextTargets,
|
||||
joinVoice,
|
||||
leaveVoice,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user