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:
co-authored by
Claude Opus 4.8
parent
1aab0d1df1
commit
f5507e01f6
+68
-227
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user