refactor(frontend): major rebuild — feature-sliced architecture + bug fixes + glass-morphism UI

Architecture:
- Feature-sliced directory: entities/, shared/, features/, widgets/
- Consolidated API client (shared/api/client.ts) — all endpoints in one file
- Single WebSocket manager (shared/ws/socket.ts) with typed event bus
- Extracted hooks: useAudioPlayback, useAudioTransmit, useLocalStorage, useUIState
- UI primitives moved to shared/ui/ with barrel export
- Added Skeleton, Toast, MobileTabBar components

Bug fixes (8/8):
1. useMemo→useEffect in RecordingsSubPanel (async side-effect anti-pattern)
2. ArrayBuffer.slice() before WebSocket send (shared buffer bug)
3. Proper useEffect dependency arrays throughout
4. Stable React keys (no index fallbacks)
5. onReanalyze properly awaited (Promise<void> return)
6. monitorGuild memoized with useMemo
7. localStorage validation with shape checking
8. Deleted duplicate socket logic (ws/client.ts removed)

UI polish:
- Glass-morphism design tokens (backdrop-blur, translucent cards)
- Gradient mesh background with subtle radial overlays
- Expandable sidebar + mobile bottom tab bar
- Skeleton loading placeholders
- Audio visualizer with CSS pulse animation

Deleted: src/api/, src/components/, src/hooks/, src/types/, src/ws/, src/lib/
Added: 40 new files across feature-sliced structure

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
MythEclipse
2026-06-01 16:42:36 +07:00
co-authored by Claude Opus 4.8
parent 1aab0d1df1
commit f5507e01f6
84 changed files with 2054 additions and 2658 deletions
+68 -227
View File
@@ -1,284 +1,125 @@
import { Component, Suspense, lazy, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { DashboardLayout } from "./components/layout/DashboardLayout";
import { LivePanel } from "./components/live/LivePanel";
import { MessagesPanel } from "./components/messages/MessagesPanel";
import { AuthOverlay } from "./components/layout/AuthOverlay";
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 { getAppConfig } from "./api/client";
import type { MessageRecord } from "./types/messages";
import type { DashboardTab } from "./types/ui";
import type { ActiveSpeaker } from "./types/voice";
import { useEffect, useMemo, useState } from "react";
import { Component, Suspense, lazy } from "react";
import { DashboardLayout } from "./widgets/DashboardLayout";
import { MobileTabBar } from "./shared/ui/MobileTabBar";
import { AuthOverlay } from "./features/auth";
import { LivePanel } from "./features/live";
import { MessagesPanel } from "./features/messages";
import { useDashboardSocket } from "./shared/ws/socket";
import { mergeMessages, useMessages } from "./features/messages/hooks/useMessages";
import { useMediaControl } from "./features/live/hooks/useMediaControl";
import { useUIState } from "./shared/hooks/useUIState";
import { useVoiceControl } from "./features/live/hooks/useVoiceControl";
import { useAudioPlayback } from "./shared/hooks/useAudioPlayback";
import { useAudioTransmit } from "./shared/hooks/useAudioTransmit";
import { getAppConfig, type MessageRecord, type ActiveSpeaker, type MediaState } from "./shared/api/client";
import { Skeleton } from "./shared/ui";
const AnalyticsPanel = lazy(() => import("./components/analytics").then((module) => ({ default: module.AnalyticsPanel })));
const AnalyticsPanel = lazy(() => import("./features/analytics").then((module) => ({ default: module.AnalyticsPanel })));
class AnalyticsErrorBoundary extends Component<{ children: React.ReactNode }, { hasError: boolean }> {
state = { hasError: false };
static getDerivedStateFromError() {
return { hasError: true };
}
static getDerivedStateFromError() { return { hasError: true }; }
override render() {
if (this.state.hasError) {
return (
<div className="rounded-2xl border border-destructive/30 bg-destructive/10 p-6 text-sm text-destructive">
Analytics failed to load. The rest of the dashboard is still available.
</div>
);
return <div className="rounded-2xl border border-destructive/30 bg-destructive/10 p-6 text-sm text-destructive">Analytics failed to load. The rest of the dashboard is still available.</div>;
}
return this.props.children;
}
}
const SAMPLE_RATE = 24000;
const CHANNELS = 1;
export default function App() {
const { uiState, setUIState, patchUIState } = useUIState();
const { uiState, 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 [isStreaming, setIsStreaming] = useState(false);
const [isAuthenticated, setIsAuthenticated] = useState(!!localStorage.getItem("admin-password"));
const [monitorGuildId, setMonitorGuildId] = useState("");
const audioContextListenRef = useRef<AudioContext | null>(null);
const audioContextTransmitRef = useRef<AudioContext | null>(null);
const streamRef = useRef<MediaStream | null>(null);
const processorRef = useRef<ScriptProcessorNode | null>(null);
const userTimelinesRef = useRef(new Map<number, number>());
const audio = useAudioPlayback();
const activeTab = uiState.activeTab || "live";
const selectedVoiceGuild = uiState.selectedVoiceGuild || uiState.selectedGuild || "";
const selectedVoiceChannel = uiState.selectedVoiceChannel || "";
const selectedTextGuild = monitorGuildId || uiState.selectedTextGuild || uiState.selectedGuild || "";
const selectedTextChannel = uiState.selectedTextChannel || "";
const selectedAnalyticsGuild = monitorGuildId || uiState.selectedAnalyticsGuild || uiState.selectedGuild || "";
const selectedAnalyticsChannel = uiState.selectedAnalyticsChannel || "";
const monitorGuild = monitorGuildId ? voice.guilds.find((guild) => guild.id === monitorGuildId) : undefined;
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 = audioContextListenRef.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 triggerAnalyticsRefresh = useCallback(() => {
window.dispatchEvent(new CustomEvent("analytics_refresh"));
}, []);
useEffect(() => {
getAppConfig()
.then((config) => {
if (config.monitorGuildId) {
setMonitorGuildId(config.monitorGuildId);
patchUIState({
selectedTextGuild: config.monitorGuildId,
selectedAnalyticsGuild: config.monitorGuildId,
selectedTextChannel: "",
selectedAnalyticsChannel: "",
});
}
})
.catch(() => undefined);
}, [patchUIState]);
const monitorGuild = useMemo(() => (monitorGuildId ? voice.guilds.find((g) => g.id === monitorGuildId) : undefined), [monitorGuildId, voice.guilds]);
const socket = useDashboardSocket({
onUIState: (state) => setUIState((prev) => ({ ...prev, ...state })),
onUserState: setActiveSpeakers,
onMessageCreated: (message) => {
messages.setMessages((prev) => mergeMessages(prev, [message]));
triggerAnalyticsRefresh();
onBinary: audio.handleIncomingPcm,
onUserState: (users) => setActiveSpeakers(users as ActiveSpeaker[]),
onMessageCreated: (m) => messages.setMessages((prev) => mergeMessages(prev, [m as MessageRecord])),
onMessageUpdated: (m) => {
const d = m as Partial<MessageRecord> & { id: string };
messages.setMessages((prev) => prev.map((i) => i.id === d.id ? { ...i, ...d } : i));
},
onMessageUpdated: (message) => {
messages.setMessages((prev) => prev.map((item) => (item.id === message.id ? { ...item, ...message } as MessageRecord : item)));
triggerAnalyticsRefresh();
},
onMessageDeleted: (message) => {
messages.setMessages((prev) => prev.map((item) => (item.id === message.id ? { ...item, type: "deleted" } : item)));
triggerAnalyticsRefresh();
},
onMessageAnalyzed: (message) => {
messages.setMessages((prev) => mergeMessages(prev, [message]));
triggerAnalyticsRefresh();
onMessageDeleted: (m) => {
const d = m as { id: string };
messages.setMessages((prev) => prev.map((i) => i.id === d.id ? { ...i, type: "deleted" as const } : i));
},
onMessageAnalyzed: (m) => messages.setMessages((prev) => mergeMessages(prev, [m as MessageRecord])),
onAttachmentUploaded: () => messages.fetchMessages(selectedTextChannel).catch(() => undefined),
onMediaState: media.setMediaState,
onVoiceRecordingUploaded: (recording) => {
const event = new CustomEvent("voice_recording_uploaded", { detail: recording });
window.dispatchEvent(event);
},
onPcm: handleIncomingPcm,
onMediaState: (state) => media.setMediaState(state as MediaState),
onVoiceRecordingUploaded: (d) => window.dispatchEvent(new CustomEvent("voice_recording_uploaded", { detail: d })),
});
const stopStreamingLocal = useCallback(() => {
setIsStreaming(false);
if (processorRef.current) { processorRef.current.disconnect(); processorRef.current = null; }
if (audioContextTransmitRef.current) { audioContextTransmitRef.current.close(); audioContextTransmitRef.current = null; }
if (streamRef.current) { for (const track of streamRef.current.getTracks()) track.stop(); streamRef.current = null; }
setLevels(Array.from({ length: 32 }, () => 0.04));
}, []);
const transmit = useAudioTransmit(socket.socketRef);
const startStreamingLocal = useCallback(async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
streamRef.current = stream;
setIsStreaming(true);
const AudioContextCtor = window.AudioContext || window.webkitAudioContext;
const audioContext = new AudioContextCtor({ sampleRate: SAMPLE_RATE });
audioContextTransmitRef.current = audioContext;
const source = audioContext.createMediaStreamSource(stream);
const processor = audioContext.createScriptProcessor(4096, 1, 1);
processorRef.current = processor;
source.connect(processor);
processor.connect(audioContext.destination);
processor.onaudioprocess = (event) => {
if (!socket.socketRef.current || socket.socketRef.current.readyState !== WebSocket.OPEN) return;
const inputData = event.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;
socket.socketRef.current.send(pcmData.buffer);
let sum = 0;
for (let i = 0; i < inputData.length; i++) sum += Math.abs(inputData[i]);
const average = inputData.length ? sum / inputData.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)));
};
} catch (err) {
console.error("Microphone access failed:", err);
setIsStreaming(false);
throw err;
}
}, [socket.socketRef]);
useEffect(() => {
getAppConfig().then((c) => {
if (c.monitorGuildId) {
setMonitorGuildId(c.monitorGuildId);
patchUIState({ selectedTextGuild: c.monitorGuildId, selectedAnalyticsGuild: c.monitorGuildId, selectedTextChannel: "", selectedAnalyticsChannel: "" });
}
}).catch(() => undefined);
}, [patchUIState]);
const toggleStreaming = useCallback(async () => {
if (isStreaming) { stopStreamingLocal(); patchUIState({ isStreaming: false }); }
else { await startStreamingLocal(); patchUIState({ isStreaming: true }); }
}, [isStreaming, startStreamingLocal, stopStreamingLocal, patchUIState]);
useEffect(() => { if (selectedVoiceGuild) voice.loadVoiceChannels(selectedVoiceGuild).catch(() => undefined); }, [selectedVoiceGuild]);
useEffect(() => { if (monitorGuildId) voice.loadTextTargets(monitorGuildId).catch(() => undefined); }, [monitorGuildId]);
useEffect(() => { if (selectedTextChannel) messages.fetchMessages(selectedTextChannel).catch(() => undefined); }, [selectedTextChannel]);
const toggleListening = useCallback(async () => {
if (isListening) { await audioContextListenRef.current?.suspend(); userTimelinesRef.current.clear(); setIsListening(false); patchUIState({ isListening: false }); return; }
const AudioContextCtor = window.AudioContext || window.webkitAudioContext;
audioContextListenRef.current ??= new AudioContextCtor({ sampleRate: SAMPLE_RATE });
await audioContextListenRef.current.resume();
setIsListening(true);
patchUIState({ isListening: true });
}, [isListening, patchUIState]);
const tabs = useMemo(() => ["live", "messages", "analytics"] as DashboardTab[], []);
useEffect(() => { if (selectedVoiceGuild) voice.loadVoiceChannels(selectedVoiceGuild).catch(() => undefined); }, [selectedVoiceGuild, voice.loadVoiceChannels]);
useEffect(() => { if (monitorGuildId) voice.loadTextTargets(monitorGuildId).catch(() => undefined); }, [monitorGuildId, voice.loadTextTargets]);
useEffect(() => { if (selectedTextChannel) messages.fetchMessages(selectedTextChannel).catch(() => undefined); }, [selectedTextChannel, messages.fetchMessages]);
return (
<DashboardLayout
activeTab={activeTab}
wsStatus={socket.status}
voiceStatus={voice.voiceStatus}
onTabChange={(tab) => patchUIState({ activeTab: tab })}
>
<div className="md:hidden">
<div className="mb-4 grid grid-cols-4 gap-1.5 rounded-2xl bg-muted p-1">
{tabs.map((tab) => (
<button
key={tab}
type="button"
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>
</div>
<DashboardLayout activeTab={activeTab} wsStatus={socket.status} voiceStatus={voice.voiceStatus} onTabChange={(tab) => patchUIState({ activeTab: tab })}>
{activeTab === "live" ? (
!isAuthenticated ? (
<AuthOverlay onAuthenticated={() => setIsAuthenticated(true)} />
) : (
<LivePanel
guilds={voice.guilds}
voiceChannels={voice.voiceChannels}
selectedGuild={selectedVoiceGuild}
selectedChannel={selectedVoiceChannel}
status={voice.voiceStatus}
voiceLoading={voice.loading}
activeSpeakers={activeSpeakers}
levels={levels}
isListening={isListening}
isStreaming={isStreaming}
mediaState={media.mediaState}
mediaLoading={media.loading}
onGuildChange={(guildId) => patchUIState({ selectedVoiceGuild: guildId, selectedVoiceChannel: "" })}
onChannelChange={(channelId) => patchUIState({ selectedVoiceChannel: channelId })}
onJoin={() => voice.joinVoice(selectedVoiceGuild, selectedVoiceChannel)}
guilds={voice.guilds} voiceChannels={voice.voiceChannels} selectedGuild={selectedVoiceGuild} selectedChannel={uiState.selectedVoiceChannel || ""}
status={voice.voiceStatus} voiceLoading={voice.loading} activeSpeakers={activeSpeakers}
levels={audio.levels} isListening={audio.isListening} isStreaming={transmit.isStreaming}
mediaState={media.mediaState} mediaLoading={media.loading}
onGuildChange={(id) => patchUIState({ selectedVoiceGuild: id, selectedVoiceChannel: "" })}
onChannelChange={(id) => patchUIState({ selectedVoiceChannel: id })}
onJoin={() => voice.joinVoice(selectedVoiceGuild, uiState.selectedVoiceChannel || "")}
onDisconnect={() => voice.leaveVoice()}
onListenToggle={toggleListening}
onStreamingToggle={toggleStreaming}
onQueueMusic={(source) => media.enqueue(source, "music")}
onStartScreen={(source) => media.enqueue(source, "screen")}
onSkip={media.skip}
onStop={media.stop}
onVolumeChange={media.setVolume}
onListenToggle={audio.toggleListening} onStreamingToggle={transmit.toggle}
onQueueMusic={(s) => media.enqueue(s, "music")} onStartScreen={(s) => media.enqueue(s, "screen")}
onSkip={media.skip} onStop={media.stop} onVolumeChange={media.setVolume}
/>
)
) : activeTab === "messages" ? (
<MessagesPanel
guilds={monitorGuild ? [monitorGuild] : []}
channels={voice.textChannels}
selectedGuild={selectedTextGuild}
selectedChannel={selectedTextChannel}
guilds={monitorGuild ? [monitorGuild] : []} channels={voice.textChannels}
selectedGuild={selectedTextGuild} selectedChannel={selectedTextChannel}
messages={messages.messages}
onGuildChange={(guildId) => patchUIState({ selectedTextGuild: guildId, selectedTextChannel: "" })}
onChannelChange={(channelId) => patchUIState({ selectedTextChannel: channelId })}
onGuildChange={(id) => patchUIState({ selectedTextGuild: id, selectedTextChannel: "" })}
onChannelChange={(id) => patchUIState({ selectedTextChannel: id })}
onReanalyze={messages.reanalyze}
/>
) : (
<AnalyticsErrorBoundary>
<Suspense
fallback={
<div className="rounded-2xl border border-dashed border-border p-8 text-sm text-muted-foreground">
Loading analytics...
</div>
}
>
<Suspense fallback={<div className="flex flex-col gap-4">{Array.from({ length: 8 }).map((_, i) => <Skeleton key={i} className="h-16 w-full rounded-xl" />)}<Skeleton className="h-64 w-full rounded-xl" /></div>}>
<AnalyticsPanel
guilds={monitorGuild ? [monitorGuild] : []}
channels={voice.textChannels}
selectedGuild={selectedAnalyticsGuild}
selectedChannel={selectedAnalyticsChannel}
onGuildChange={(guildId) => patchUIState({ selectedAnalyticsGuild: guildId, selectedAnalyticsChannel: "" })}
onChannelChange={(channelId) => patchUIState({ selectedAnalyticsChannel: channelId })}
guilds={monitorGuild ? [monitorGuild] : []} channels={voice.textChannels}
selectedGuild={uiState.selectedAnalyticsGuild || selectedTextGuild || ""}
selectedChannel={uiState.selectedAnalyticsChannel || selectedTextChannel || ""}
onGuildChange={(id) => patchUIState({ selectedAnalyticsGuild: id, selectedAnalyticsChannel: "" })}
onChannelChange={(id) => patchUIState({ selectedAnalyticsChannel: id })}
/>
</Suspense>
</AnalyticsErrorBoundary>
)}
<MobileTabBar activeTab={activeTab} onTabChange={(tab) => patchUIState({ activeTab: tab })} />
</DashboardLayout>
);
}