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
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "gmw-dashboard",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"preview": "vite preview --host 0.0.0.0",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "biome check --diagnostic-level=error src/",
|
||||
"format": "biome format --write src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@tanstack/react-query": "^5.100.14",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.16.0",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"tailwind-merge": "^3.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "latest",
|
||||
"@tailwindcss/postcss": "^4.3.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"autoprefixer": "^10.5.0",
|
||||
"postcss": "^8.5.14",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^8.0.13"
|
||||
}
|
||||
}
|
||||
+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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
import { request } from "./client";
|
||||
|
||||
export interface ViolatorStat {
|
||||
user_id: string;
|
||||
username: string;
|
||||
avatar_url: string | null;
|
||||
total_messages: number;
|
||||
flagged_count: number;
|
||||
warned_count: number;
|
||||
violation_score: number;
|
||||
worst_flags: string[];
|
||||
last_violation: number;
|
||||
}
|
||||
|
||||
export interface HourlyBucket {
|
||||
hour: string;
|
||||
count: number;
|
||||
clean: number;
|
||||
warned: number;
|
||||
flagged: number;
|
||||
error: number;
|
||||
}
|
||||
|
||||
export interface TopicTrend {
|
||||
topic: string;
|
||||
count: number;
|
||||
score: number;
|
||||
}
|
||||
|
||||
export interface UserStat {
|
||||
user_id: string;
|
||||
username: string;
|
||||
avatar_url: string | null;
|
||||
message_count: number;
|
||||
edited_count: number;
|
||||
deleted_count: number;
|
||||
flagged_count: number;
|
||||
last_active: number;
|
||||
}
|
||||
|
||||
export interface ModerationBreakdown {
|
||||
total: number;
|
||||
clean: number;
|
||||
warned: number;
|
||||
flagged: number;
|
||||
error: number;
|
||||
pending: number;
|
||||
average_score: number;
|
||||
}
|
||||
|
||||
export interface AnalyticsOverview {
|
||||
period: { start: number; end: number };
|
||||
messages: ModerationBreakdown;
|
||||
hourly: HourlyBucket[];
|
||||
topics: TopicTrend[];
|
||||
top_users: UserStat[];
|
||||
active_users_count: number;
|
||||
total_channels: number;
|
||||
}
|
||||
|
||||
export async function fetchAnalyticsOverview(params: {
|
||||
guildId: string;
|
||||
channelId?: string;
|
||||
hours?: number;
|
||||
}): Promise<AnalyticsOverview> {
|
||||
const searchParams = new URLSearchParams({
|
||||
guildId: params.guildId,
|
||||
...(params.channelId && { channelId: params.channelId }),
|
||||
...(params.hours && { hours: String(params.hours) }),
|
||||
});
|
||||
return request<AnalyticsOverview>(`/api/analytics/overview?${searchParams}`);
|
||||
}
|
||||
|
||||
export async function fetchHourlyStats(params: {
|
||||
guildId: string;
|
||||
channelId?: string;
|
||||
hours?: number;
|
||||
}): Promise<HourlyBucket[]> {
|
||||
const searchParams = new URLSearchParams({
|
||||
guildId: params.guildId,
|
||||
...(params.channelId && { channelId: params.channelId }),
|
||||
...(params.hours && { hours: String(params.hours) }),
|
||||
});
|
||||
return request<HourlyBucket[]>(`/api/analytics/hourly?${searchParams}`);
|
||||
}
|
||||
|
||||
export async function fetchTopicTrends(params: {
|
||||
guildId: string;
|
||||
channelId?: string;
|
||||
hours?: number;
|
||||
}): Promise<TopicTrend[]> {
|
||||
const searchParams = new URLSearchParams({
|
||||
guildId: params.guildId,
|
||||
...(params.channelId && { channelId: params.channelId }),
|
||||
...(params.hours && { hours: String(params.hours) }),
|
||||
});
|
||||
return request<TopicTrend[]>(`/api/analytics/topics?${searchParams}`);
|
||||
}
|
||||
|
||||
export async function fetchLeaderboard(params: {
|
||||
guildId: string;
|
||||
channelId?: string;
|
||||
hours?: number;
|
||||
limit?: number;
|
||||
}): Promise<UserStat[]> {
|
||||
const searchParams = new URLSearchParams({
|
||||
guildId: params.guildId,
|
||||
...(params.channelId && { channelId: params.channelId }),
|
||||
...(params.hours && { hours: String(params.hours) }),
|
||||
...(params.limit && { limit: String(params.limit) }),
|
||||
});
|
||||
return request<UserStat[]>(`/api/analytics/leaderboard?${searchParams}`);
|
||||
}
|
||||
|
||||
export async function fetchModerationStats(params: {
|
||||
guildId: string;
|
||||
channelId?: string;
|
||||
hours?: number;
|
||||
}): Promise<ModerationBreakdown> {
|
||||
const searchParams = new URLSearchParams({
|
||||
guildId: params.guildId,
|
||||
...(params.channelId && { channelId: params.channelId }),
|
||||
...(params.hours && { hours: String(params.hours) }),
|
||||
});
|
||||
return request<ModerationBreakdown>(`/api/analytics/stats?${searchParams}`);
|
||||
}
|
||||
|
||||
export async function fetchViolators(params: {
|
||||
guildId: string;
|
||||
channelId?: string;
|
||||
hours?: number;
|
||||
limit?: number;
|
||||
}): Promise<ViolatorStat[]> {
|
||||
const searchParams = new URLSearchParams({
|
||||
guildId: params.guildId,
|
||||
...(params.channelId && { channelId: params.channelId }),
|
||||
...(params.hours && { hours: String(params.hours) }),
|
||||
...(params.limit && { limit: String(params.limit) }),
|
||||
});
|
||||
return request<ViolatorStat[]>(`/api/analytics/violators?${searchParams}`);
|
||||
}
|
||||
|
||||
export interface TrendBucket {
|
||||
date: string;
|
||||
count: number;
|
||||
clean: number;
|
||||
warned: number;
|
||||
flagged: number;
|
||||
error: number;
|
||||
}
|
||||
|
||||
export interface HeatmapCell {
|
||||
dayOfWeek: number;
|
||||
hour: number;
|
||||
count: number;
|
||||
clean: number;
|
||||
warned: number;
|
||||
flagged: number;
|
||||
}
|
||||
|
||||
export async function fetchTrend(params: {
|
||||
guildId: string;
|
||||
channelId?: string;
|
||||
hours?: number;
|
||||
}): Promise<TrendBucket[]> {
|
||||
const searchParams = new URLSearchParams({
|
||||
guildId: params.guildId,
|
||||
...(params.channelId && { channelId: params.channelId }),
|
||||
...(params.hours && { hours: String(params.hours) }),
|
||||
});
|
||||
return request<TrendBucket[]>(`/api/analytics/trend?${searchParams}`);
|
||||
}
|
||||
|
||||
export async function fetchHeatmap(params: {
|
||||
guildId: string;
|
||||
channelId?: string;
|
||||
hours?: number;
|
||||
}): Promise<HeatmapCell[]> {
|
||||
const searchParams = new URLSearchParams({
|
||||
guildId: params.guildId,
|
||||
...(params.channelId && { channelId: params.channelId }),
|
||||
...(params.hours && { hours: String(params.hours) }),
|
||||
});
|
||||
return request<HeatmapCell[]>(`/api/analytics/heatmap?${searchParams}`);
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import { request } from "./client";
|
||||
|
||||
export async function login(password: string): Promise<{ ok: boolean }> {
|
||||
return request<{ ok: boolean }>('/api/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ password }),
|
||||
});
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
export type AIStatus = "pending" | "clean" | "warn" | "flagged" | "error";
|
||||
export type AISeverity = "none" | "low" | "medium" | "high" | "critical";
|
||||
export type AIRecommendedAction =
|
||||
| "none"
|
||||
| "monitor"
|
||||
| "warn"
|
||||
| "review"
|
||||
| "delete"
|
||||
| "escalate";
|
||||
|
||||
export interface MessageRecord {
|
||||
id: string;
|
||||
guild_id: string;
|
||||
channel_id: string;
|
||||
thread_id: string | null;
|
||||
user_id: string;
|
||||
username: string;
|
||||
avatar_url: string | null;
|
||||
content: string;
|
||||
edited_content: string | null;
|
||||
created_at: number;
|
||||
edited_at: number | null;
|
||||
deleted_at: number | null;
|
||||
type: "text" | "edited" | "deleted";
|
||||
metadata: string | null;
|
||||
ai_status?: AIStatus | null;
|
||||
ai_moderation_flags?: string | null;
|
||||
ai_moderation_score?: number | null;
|
||||
ai_analysis?: string | null;
|
||||
ai_categories?: string | null;
|
||||
ai_severity?: AISeverity | null;
|
||||
ai_confidence?: number | null;
|
||||
ai_recommended_action?: AIRecommendedAction | null;
|
||||
ai_analyzed_at?: number | null;
|
||||
ai_error?: string | null;
|
||||
}
|
||||
|
||||
export interface PageResult<T> {
|
||||
data: T[];
|
||||
nextCursor: string | null;
|
||||
}
|
||||
|
||||
export type DashboardMessage = MessageRecord;
|
||||
|
||||
export interface Guild {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string | null;
|
||||
}
|
||||
|
||||
export interface AppConfig {
|
||||
monitorGuildId: string | null;
|
||||
}
|
||||
|
||||
class ApiError extends Error {
|
||||
code: string;
|
||||
statusCode: number;
|
||||
|
||||
constructor(code: string, message: string, statusCode: number) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
this.code = code;
|
||||
this.statusCode = statusCode;
|
||||
}
|
||||
}
|
||||
|
||||
export async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const password = localStorage.getItem("admin-password");
|
||||
const res = await fetch(path, {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(password ? { "X-Admin-Password": password } : {}),
|
||||
},
|
||||
...init,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
let message = res.statusText;
|
||||
let code = "REQUEST_FAILED";
|
||||
try {
|
||||
const body = (await res.json()) as { error?: string; message?: string };
|
||||
if (body.message) message = body.message;
|
||||
if (body.error) code = body.error;
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
throw new ApiError(code, message, res.status);
|
||||
}
|
||||
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export async function listMessages(
|
||||
params: URLSearchParams,
|
||||
): Promise<PageResult<MessageRecord>> {
|
||||
return request<PageResult<MessageRecord>>(`/api/messages?${params}`);
|
||||
}
|
||||
|
||||
export async function listReview(
|
||||
params: URLSearchParams,
|
||||
): Promise<PageResult<MessageRecord>> {
|
||||
return request<PageResult<MessageRecord>>(`/api/review?${params}`);
|
||||
}
|
||||
|
||||
export async function reanalyzeMessage(id: string): Promise<void> {
|
||||
await request<void>(`/api/messages/${id}/reanalyze`, { method: "POST" });
|
||||
}
|
||||
|
||||
export async function getGuilds(): Promise<Guild[]> {
|
||||
return request<Guild[]>("/api/guilds");
|
||||
}
|
||||
|
||||
export async function getAppConfig(): Promise<AppConfig> {
|
||||
return request<AppConfig>("/api/config");
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { request } from "./client";
|
||||
import type { MediaMode, MediaState } from "../types/media";
|
||||
|
||||
export function getMediaStatus(): Promise<MediaState> {
|
||||
return request<MediaState>('/api/media/status');
|
||||
}
|
||||
|
||||
export function queueMedia(source: string, mode: MediaMode): Promise<MediaState> {
|
||||
return request<MediaState>('/api/media/queue', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ source, mode }),
|
||||
});
|
||||
}
|
||||
|
||||
export function skipMedia(): Promise<MediaState> {
|
||||
return request<MediaState>('/api/media/skip', { method: 'POST' });
|
||||
}
|
||||
|
||||
export function stopMedia(): Promise<MediaState> {
|
||||
return request<MediaState>('/api/media/stop', { method: 'POST' });
|
||||
}
|
||||
|
||||
export function setMediaVolume(volume: number): Promise<MediaState> {
|
||||
return request<MediaState>('/api/media/volume', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ volume }),
|
||||
});
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
import { listMessages, listReview, reanalyzeMessage } from "./client";
|
||||
|
||||
export { listMessages, listReview, reanalyzeMessage };
|
||||
@@ -1,13 +0,0 @@
|
||||
import { request } from "./client";
|
||||
import type { UIState } from "../types/ui";
|
||||
|
||||
export function getUIState(): Promise<UIState> {
|
||||
return request<UIState>('/api/ui-state');
|
||||
}
|
||||
|
||||
export function updateUIState(patch: Partial<UIState>): Promise<UIState> {
|
||||
return request<UIState>('/api/ui-state', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import { request } from "./client";
|
||||
import type { Channel, Guild, VoiceStatus } from "../types/voice";
|
||||
|
||||
export function getGuilds(): Promise<Guild[]> {
|
||||
return request<Guild[]>('/api/guilds');
|
||||
}
|
||||
|
||||
export function getVoiceChannels(guildId: string): Promise<Channel[]> {
|
||||
return request<Channel[]>(`/api/guilds/${guildId}/voice-channels`);
|
||||
}
|
||||
|
||||
export function getTextChannels(guildId: string): Promise<Channel[]> {
|
||||
return request<Channel[]>(`/api/guilds/${guildId}/channels`);
|
||||
}
|
||||
|
||||
export function getVoiceStatus(): Promise<VoiceStatus> {
|
||||
return request<VoiceStatus>('/api/status');
|
||||
}
|
||||
|
||||
export function connectVoice(guildId: string, channelId: string): Promise<VoiceStatus> {
|
||||
return request<VoiceStatus>('/api/connect', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ guildId, channelId }),
|
||||
});
|
||||
}
|
||||
|
||||
export function disconnectVoice(): Promise<VoiceStatus> {
|
||||
return request<VoiceStatus>('/api/disconnect', { method: 'POST' });
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import type { Channel, Guild } from "../../types/voice";
|
||||
import { useAnalytics } from "../../hooks/useAnalytics";
|
||||
import { ControlBar } from "./ControlBar";
|
||||
import { SummaryCards } from "./SummaryCards";
|
||||
import { ActivityChart } from "./ActivityChart";
|
||||
import { TrendChart } from "./TrendChart";
|
||||
import { Heatmap } from "./Heatmap";
|
||||
import { TopicList } from "./TopicList";
|
||||
import { UserTable } from "./UserTable";
|
||||
import { ViolatorTable } from "./ViolatorTable";
|
||||
|
||||
interface AnalyticsPanelProps {
|
||||
guilds: Guild[];
|
||||
channels: Channel[];
|
||||
selectedGuild: string;
|
||||
selectedChannel: string;
|
||||
onGuildChange: (guildId: string) => void;
|
||||
onChannelChange: (channelId: string) => void;
|
||||
}
|
||||
|
||||
export function AnalyticsPanel({
|
||||
guilds,
|
||||
channels,
|
||||
selectedGuild,
|
||||
selectedChannel,
|
||||
onGuildChange,
|
||||
onChannelChange,
|
||||
}: AnalyticsPanelProps) {
|
||||
const [hours, setHours] = useState(24);
|
||||
|
||||
const {
|
||||
messages,
|
||||
hourly,
|
||||
topics,
|
||||
topUsers,
|
||||
activeUsersCount,
|
||||
totalChannels,
|
||||
violators,
|
||||
trend,
|
||||
heatmap,
|
||||
isLoading,
|
||||
isFetching,
|
||||
error,
|
||||
refresh,
|
||||
refreshViolators,
|
||||
} = useAnalytics({ guildId: selectedGuild, channelId: selectedChannel || undefined, hours });
|
||||
|
||||
const loading = isLoading && !isFetching;
|
||||
|
||||
if (error && !messages) {
|
||||
return (
|
||||
<div className="rounded-lg border border-red-500/30 bg-red-500/5 p-4 text-sm text-red-300">
|
||||
{error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!selectedGuild) {
|
||||
return (
|
||||
<div className="flex min-h-[300px] flex-col items-center justify-center gap-3 rounded-lg border border-dashed p-8">
|
||||
<p className="text-sm text-muted-foreground">Pilih guild untuk melihat analitik.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Control bar */}
|
||||
<ControlBar
|
||||
guilds={guilds}
|
||||
channels={channels}
|
||||
selectedGuild={selectedGuild}
|
||||
selectedChannel={selectedChannel}
|
||||
hours={hours}
|
||||
isFetching={isFetching}
|
||||
onGuildChange={onGuildChange}
|
||||
onChannelChange={onChannelChange}
|
||||
onHoursChange={setHours}
|
||||
onRefresh={() => { refresh(); refreshViolators(); }}
|
||||
/>
|
||||
|
||||
{/* Summary cards */}
|
||||
<SummaryCards
|
||||
messages={messages}
|
||||
activeUsersCount={activeUsersCount}
|
||||
totalChannels={totalChannels}
|
||||
loading={loading}
|
||||
/>
|
||||
|
||||
{/* Hourly chart */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<ActivityChart hourly={hourly} loading={loading} />
|
||||
<div className="col-span-1">
|
||||
<TopicList topics={topics} loading={loading} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Trend chart — only show when enough data */}
|
||||
{hours >= 48 && (
|
||||
<TrendChart trend={trend} loading={loading} />
|
||||
)}
|
||||
|
||||
{/* Heatmap + leaderboard */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<Heatmap cells={heatmap} loading={loading} />
|
||||
<div className="col-span-1">
|
||||
<UserTable users={topUsers} loading={loading} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Violators */}
|
||||
<ViolatorTable users={violators} loading={loading} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import { BarChart3, MessageSquare, Radio } from "lucide-react";
|
||||
import type { DashboardTab } from "../../types/ui";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { Button } from "../ui/button";
|
||||
|
||||
const navItems: Array<{ id: DashboardTab; label: string; icon: typeof Radio }> = [
|
||||
{ id: "live", label: "Live", icon: Radio },
|
||||
{ id: "messages", label: "Messages", icon: MessageSquare },
|
||||
{ id: "analytics", label: "Analytics", icon: BarChart3 },
|
||||
];
|
||||
|
||||
interface SidebarProps {
|
||||
activeTab: DashboardTab;
|
||||
onTabChange: (tab: DashboardTab) => void;
|
||||
}
|
||||
|
||||
export function Sidebar({ activeTab, onTabChange }: SidebarProps) {
|
||||
return (
|
||||
<aside className="hidden w-72 shrink-0 border-r border-border bg-card/60 p-5 backdrop-blur md:block">
|
||||
<div className="mb-8 flex items-center gap-3">
|
||||
<img src="/logo.svg" alt="GMW" className="h-11 w-11 rounded-2xl" />
|
||||
<div>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="font-bold tracking-tight text-primary text-lg">GMW</span>
|
||||
<span className="text-[10px] text-muted-foreground font-medium uppercase tracking-wider">v1.0</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">Discord Moderation Watcher</div>
|
||||
</div>
|
||||
</div>
|
||||
<nav className="space-y-2">
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<Button
|
||||
key={item.id}
|
||||
variant={activeTab === item.id ? "secondary" : "ghost"}
|
||||
className={cn("w-full justify-start", activeTab === item.id && "bg-primary/15 text-primary")}
|
||||
onClick={() => onTabChange(item.id)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{item.label}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -1,314 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { ActiveSpeaker, Channel, Guild, VoiceStatus } from "../../types/voice";
|
||||
import type { MediaState } from "../../types/media";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
|
||||
import { Badge } from "../ui/badge";
|
||||
import { Button } from "../ui/button";
|
||||
import { Input } from "../ui/input";
|
||||
import { Select } from "../ui/select";
|
||||
import { AudioVisualizer } from "../voice/AudioVisualizer";
|
||||
import { Music2, MonitorUp, Mic, Download, Headphones, Radio, SkipForward, Square, Volume2 } from "lucide-react";
|
||||
|
||||
// ─── Voice Recordings type ───
|
||||
interface VoiceRecording {
|
||||
id: string;
|
||||
user_id: string;
|
||||
username: string;
|
||||
avatar_url: string | null;
|
||||
guild_id: string | null;
|
||||
channel_id: string | null;
|
||||
channel_name: string | null;
|
||||
filename: string;
|
||||
size_bytes: number;
|
||||
download_url: string | null;
|
||||
upload_status: "pending" | "uploaded" | "failed";
|
||||
upload_error: string | null;
|
||||
created_at: number;
|
||||
uploaded_at: number | null;
|
||||
}
|
||||
|
||||
function formatDate(value: number): string {
|
||||
return new Date(value).toLocaleString();
|
||||
}
|
||||
|
||||
function formatBytes(value: number): string {
|
||||
if (value < 1024) return `${value} B`;
|
||||
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
|
||||
return `${(value / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
// ─── Recordings Sub-Panel ───
|
||||
function RecordingsSubPanel() {
|
||||
const [recordings, setRecordings] = useState<VoiceRecording[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useMemo(() => {
|
||||
let cancelled = false;
|
||||
async function loadRecordings() {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const response = await fetch("/api/recordings");
|
||||
if (!response.ok) throw new Error(`Failed to load recordings: ${response.status}`);
|
||||
const data = (await response.json()) as VoiceRecording[];
|
||||
if (!cancelled) setRecordings(data);
|
||||
} catch (err) {
|
||||
if (!cancelled) setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
}
|
||||
loadRecordings();
|
||||
window.addEventListener("voice_recording_uploaded", loadRecordings);
|
||||
return () => { cancelled = true; window.removeEventListener("voice_recording_uploaded", loadRecordings); };
|
||||
}, []);
|
||||
|
||||
if (loading) return <div className="rounded-xl border border-dashed border-border p-6 text-center text-sm text-muted-foreground">Loading recordings...</div>;
|
||||
if (error) return <div className="rounded-xl border border-dashed border-destructive p-6 text-center text-sm text-destructive">{error}</div>;
|
||||
if (recordings.length === 0) return <div className="rounded-xl border border-dashed border-border p-6 text-center text-sm text-muted-foreground">No recordings found.</div>;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{recordings.map((rec) => (
|
||||
<div key={rec.id} className="flex items-center gap-4 rounded-xl border border-border bg-background/60 p-4">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-primary/15 text-primary">
|
||||
<Mic className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-medium">{rec.filename}</div>
|
||||
<div className="mt-0.5 flex flex-wrap items-center gap-x-2 text-xs text-muted-foreground">
|
||||
<span>{rec.username}</span>
|
||||
<span>·</span>
|
||||
<span>{rec.channel_name ?? rec.channel_id ?? "unknown"}</span>
|
||||
<span>·</span>
|
||||
<span>{formatDate(rec.created_at)}</span>
|
||||
<span>·</span>
|
||||
<span>{formatBytes(rec.size_bytes)}</span>
|
||||
</div>
|
||||
{rec.upload_error && <div className="mt-1 text-xs text-destructive">{rec.upload_error}</div>}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Badge variant={rec.upload_status === "uploaded" ? "success" : rec.upload_status === "failed" ? "destructive" : "secondary"}>
|
||||
{rec.upload_status}
|
||||
</Badge>
|
||||
{rec.download_url && (
|
||||
<a href={rec.download_url} target="_blank" rel="noreferrer" className="rounded-lg bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90">
|
||||
<Download className="h-4 w-4" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Music Player Sub-Panel ───
|
||||
function MusicSubPanel({ volume, onVolumeChange, onQueue, onSkip, onStop, loading }: {
|
||||
volume: number; onVolumeChange: (v: number) => void; onQueue: (s: string) => void;
|
||||
onSkip: () => void; onStop: () => void; loading: boolean;
|
||||
}) {
|
||||
const [source, setSource] = useState("");
|
||||
const safeVolume = Number.isFinite(volume) ? Math.max(0, Math.min(1, volume)) : 1;
|
||||
const [draftVolume, setDraftVolume] = useState(Math.round(safeVolume * 100));
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => {
|
||||
const normalized = draftVolume / 100;
|
||||
if (Math.abs(normalized - safeVolume) >= 0.001) onVolumeChange(normalized);
|
||||
}, 200);
|
||||
return () => clearInterval(id);
|
||||
}, [draftVolume, safeVolume, onVolumeChange]);
|
||||
|
||||
const submit = () => { const t = source.trim(); if (!t) return; onQueue(t); setSource(""); };
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Input value={source} onChange={(e) => setSource(e.target.value)} onKeyDown={(e) => e.key === "Enter" && submit()} placeholder="YouTube URL, Spotify track, or search terms" />
|
||||
<div className="flex items-center gap-3">
|
||||
<Volume2 className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<input type="range" min={0} max={100} step={1} value={draftVolume} onChange={(e) => setDraftVolume(Number(e.target.value))} className="h-2 w-full cursor-pointer accent-primary" />
|
||||
<span className="w-10 shrink-0 text-right text-sm tabular-nums text-muted-foreground">{draftVolume}%</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button disabled={loading || !source.trim()} onClick={submit}><Music2 className="mr-1.5 h-4 w-4" /> Queue</Button>
|
||||
<Button variant="secondary" disabled={loading} onClick={onSkip}><SkipForward className="mr-1.5 h-4 w-4" /> Skip</Button>
|
||||
<Button variant="destructive" disabled={loading} onClick={onStop}><Square className="mr-1.5 h-4 w-4" /> Stop</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Screen Share Sub-Panel ───
|
||||
function ScreenSubPanel({ onStart, onSkip, onStop, loading }: {
|
||||
onStart: (s: string) => void; onSkip: () => void; onStop: () => void; loading: boolean;
|
||||
}) {
|
||||
const [source, setSource] = useState("");
|
||||
const submit = () => { const t = source.trim(); if (!t) return; onStart(t); setSource(""); };
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Input value={source} onChange={(e) => setSource(e.target.value)} onKeyDown={(e) => e.key === "Enter" && submit()} placeholder="Screen share URL or local file path" />
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button disabled={loading || !source.trim()} onClick={submit}><MonitorUp className="mr-1.5 h-4 w-4" /> Start</Button>
|
||||
<Button variant="secondary" disabled={loading} onClick={onSkip}><SkipForward className="mr-1.5 h-4 w-4" /> Skip</Button>
|
||||
<Button variant="destructive" disabled={loading} onClick={onStop}><Square className="mr-1.5 h-4 w-4" /> Stop</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Unified Panel ───
|
||||
interface LivePanelProps {
|
||||
guilds: Guild[];
|
||||
voiceChannels: Channel[];
|
||||
selectedGuild: string;
|
||||
selectedChannel: string;
|
||||
status: VoiceStatus;
|
||||
voiceLoading: boolean;
|
||||
activeSpeakers: ActiveSpeaker[];
|
||||
levels: number[];
|
||||
isListening: boolean;
|
||||
isStreaming: boolean;
|
||||
mediaState: MediaState;
|
||||
mediaLoading: boolean;
|
||||
onGuildChange: (id: string) => void;
|
||||
onChannelChange: (id: string) => void;
|
||||
onJoin: () => void;
|
||||
onDisconnect: () => void;
|
||||
onListenToggle: () => void;
|
||||
onStreamingToggle: () => void;
|
||||
onQueueMusic: (s: string) => void;
|
||||
onStartScreen: (s: string) => void;
|
||||
onSkip: () => void;
|
||||
onStop: () => void;
|
||||
onVolumeChange: (v: number) => void;
|
||||
}
|
||||
|
||||
export function LivePanel({
|
||||
guilds, voiceChannels, selectedGuild, selectedChannel,
|
||||
status, voiceLoading, activeSpeakers, levels, isListening, isStreaming,
|
||||
mediaState, mediaLoading,
|
||||
onGuildChange, onChannelChange, onJoin, onDisconnect,
|
||||
onListenToggle, onStreamingToggle,
|
||||
onQueueMusic, onStartScreen, onSkip, onStop, onVolumeChange,
|
||||
}: LivePanelProps) {
|
||||
return (
|
||||
<div className="grid gap-6">
|
||||
{/* Voice Connection Controls */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2"><Radio className="h-5 w-5" /> Voice Bridge</CardTitle>
|
||||
<CardDescription>Join a Discord voice channel, listen, and transmit audio.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Guild</label>
|
||||
<Select value={selectedGuild} onChange={(e) => onGuildChange(e.target.value)} placeholder="Select guild" options={guilds.map((g) => ({ value: g.id, label: g.name }))} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Voice Channel</label>
|
||||
<Select value={selectedChannel} onChange={(e) => onChannelChange(e.target.value)} placeholder="Select voice channel" options={voiceChannels.map((c) => ({ value: c.id, label: c.name }))} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button disabled={!selectedGuild || !selectedChannel || voiceLoading} onClick={onJoin}>{status.connected ? "Reconnect" : "Join Voice"}</Button>
|
||||
<Button variant="destructive" disabled={!status.connected || voiceLoading} onClick={onDisconnect}>Disconnect</Button>
|
||||
<Button variant={isListening ? "secondary" : "outline"} onClick={onListenToggle}><Headphones className="mr-1.5 h-4 w-4" /> {isListening ? "Stop Listening" : "Listen"}</Button>
|
||||
<Button variant={isStreaming ? "destructive" : "default"} onClick={onStreamingToggle}><Radio className="mr-1.5 h-4 w-4" /> {isStreaming ? "Stop Transmit" : "Transmit"}</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Audio Visualizer + Active Speakers */}
|
||||
<div className="grid gap-6 xl:grid-cols-[1fr_320px]">
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">Live Audio</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<AudioVisualizer levels={levels} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">Active Speakers</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{activeSpeakers.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-border p-6 text-center text-sm text-muted-foreground">No active speakers.</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{activeSpeakers.map((s, i) => (
|
||||
<div key={s.userId || s.id || i} className="flex items-center gap-3 rounded-xl border border-border bg-background/60 p-3">
|
||||
<img src={s.avatar} alt="" className="h-8 w-8 rounded-full object-cover" />
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium">{s.username}</div>
|
||||
<div className="text-xs text-emerald-300">Speaking</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Now Playing / Queue */}
|
||||
{mediaState.current && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
{mediaState.current.mode === "screen" ? <MonitorUp className="h-4 w-4" /> : <Music2 className="h-4 w-4" />}
|
||||
Now Playing
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between gap-3 rounded-xl border border-primary/30 bg-primary/10 p-4">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium">{mediaState.current.title}</div>
|
||||
<div className="truncate text-xs text-muted-foreground">{mediaState.current.source}</div>
|
||||
</div>
|
||||
<Badge variant={mediaState.current.mode === "screen" ? "warning" : "success"}>{mediaState.current.mode || "music"}</Badge>
|
||||
</div>
|
||||
{mediaState.queue.length > 0 && (
|
||||
<div className="mt-3 space-y-1.5">
|
||||
<div className="text-sm font-medium">Queue ({mediaState.queue.length})</div>
|
||||
{mediaState.queue.map((item, i) => (
|
||||
<div key={`${item.source}-${i}`} className="flex items-center gap-3 rounded-lg border border-border bg-background/60 p-2.5 text-sm">
|
||||
<span className="h-5 w-5 flex shrink-0 items-center justify-center rounded-full bg-muted text-xs font-medium text-muted-foreground">{i + 1}</span>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium">{item.title}</div>
|
||||
<div className="truncate text-xs text-muted-foreground">{item.source}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Music + Screen Share + Recordings tabs */}
|
||||
<Tabs defaultValue="music">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="music"><Music2 className="mr-1.5 h-4 w-4" /> Music</TabsTrigger>
|
||||
<TabsTrigger value="screen"><MonitorUp className="mr-1.5 h-4 w-4" /> Screen Share</TabsTrigger>
|
||||
<TabsTrigger value="recordings"><Mic className="mr-1.5 h-4 w-4" /> Recordings</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="music">
|
||||
<MusicSubPanel volume={mediaState.musicVolume} onVolumeChange={onVolumeChange} onQueue={onQueueMusic} onSkip={onSkip} onStop={onStop} loading={mediaLoading} />
|
||||
</TabsContent>
|
||||
<TabsContent value="screen">
|
||||
<ScreenSubPanel onStart={onStartScreen} onSkip={onSkip} onStop={onStop} loading={mediaLoading} />
|
||||
</TabsContent>
|
||||
<TabsContent value="recordings">
|
||||
<RecordingsSubPanel />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import type { MediaState } from "../../types/media";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
|
||||
import { MediaQueue } from "./MediaQueue";
|
||||
import { MusicPlayer } from "./MusicPlayer";
|
||||
import { ScreenShare } from "./ScreenShare";
|
||||
|
||||
interface MediaPanelProps {
|
||||
state: MediaState;
|
||||
loading: boolean;
|
||||
onQueueMusic: (source: string) => void;
|
||||
onStartScreen: (source: string) => void;
|
||||
onSkip: () => void;
|
||||
onStop: () => void;
|
||||
onVolumeChange: (volume: number) => void;
|
||||
}
|
||||
|
||||
export function MediaPanel({
|
||||
state,
|
||||
loading,
|
||||
onQueueMusic,
|
||||
onStartScreen,
|
||||
onSkip,
|
||||
onStop,
|
||||
onVolumeChange,
|
||||
}: MediaPanelProps) {
|
||||
return (
|
||||
<div className="grid gap-6 xl:grid-cols-[1fr_380px]">
|
||||
<Tabs defaultValue="music" className="min-w-0">
|
||||
<TabsList>
|
||||
<TabsTrigger value="music">Music</TabsTrigger>
|
||||
<TabsTrigger value="screen">Screen Share</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="music">
|
||||
<MusicPlayer
|
||||
loading={loading}
|
||||
volume={state.musicVolume}
|
||||
onVolumeChange={onVolumeChange}
|
||||
onQueue={onQueueMusic}
|
||||
onSkip={onSkip}
|
||||
onStop={onStop}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="screen">
|
||||
<ScreenShare loading={loading} onStart={onStartScreen} onSkip={onSkip} onStop={onStop} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<MediaQueue state={state} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import type { MediaState } from "../../types/media";
|
||||
import { Badge } from "../ui/badge";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
|
||||
|
||||
interface MediaQueueProps {
|
||||
state: MediaState;
|
||||
}
|
||||
|
||||
export function MediaQueue({ state }: MediaQueueProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Now Playing</CardTitle>
|
||||
<CardDescription>Current item and queue state.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{state.current ? (
|
||||
<div className="rounded-xl border border-primary/30 bg-primary/10 p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium">{state.current.title}</div>
|
||||
<div className="truncate text-xs text-muted-foreground">{state.current.source}</div>
|
||||
</div>
|
||||
<Badge variant={state.current.mode === "screen" ? "warning" : "success"}>{state.current.mode || "music"}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl border border-dashed border-border p-6 text-center text-sm text-muted-foreground">No media playing.</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">Queue</div>
|
||||
{state.queue.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">Queue is empty.</div>
|
||||
) : (
|
||||
state.queue.map((item, index) => (
|
||||
<div key={`${item.source}-${index}`} className="rounded-lg border border-border bg-background/60 p-3 text-sm">
|
||||
<div className="font-medium">{item.title}</div>
|
||||
<div className="truncate text-xs text-muted-foreground">{item.source}</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
import { Music2 } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "../ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
|
||||
import { Input } from "../ui/input";
|
||||
|
||||
interface MusicPlayerProps {
|
||||
loading: boolean;
|
||||
volume: number;
|
||||
onVolumeChange: (volume: number) => void;
|
||||
onQueue: (source: string) => void;
|
||||
onSkip: () => void;
|
||||
onStop: () => void;
|
||||
}
|
||||
|
||||
export function MusicPlayer({
|
||||
loading,
|
||||
volume,
|
||||
onVolumeChange,
|
||||
onQueue,
|
||||
onSkip,
|
||||
onStop,
|
||||
}: MusicPlayerProps) {
|
||||
const [source, setSource] = useState("");
|
||||
const safeVolume = Number.isFinite(volume) ? Math.max(0, Math.min(1, volume)) : 1;
|
||||
const [draftVolume, setDraftVolume] = useState(Math.round(safeVolume * 100));
|
||||
|
||||
useEffect(() => {
|
||||
setDraftVolume(Math.round(safeVolume * 100));
|
||||
}, [safeVolume]);
|
||||
|
||||
useEffect(() => {
|
||||
const normalized = draftVolume / 100;
|
||||
if (Math.abs(normalized - safeVolume) < 0.001) return;
|
||||
const timer = window.setTimeout(() => {
|
||||
onVolumeChange(normalized);
|
||||
}, 150);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [draftVolume, onVolumeChange, safeVolume]);
|
||||
|
||||
const submit = () => {
|
||||
const trimmed = source.trim();
|
||||
if (!trimmed) return;
|
||||
onQueue(trimmed);
|
||||
setSource("");
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2"><Music2 className="h-5 w-5" /> Music Player</CardTitle>
|
||||
<CardDescription>Play YouTube, Spotify tracks, search terms, or local files as audio.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Input
|
||||
value={source}
|
||||
onChange={(event) => setSource(event.target.value)}
|
||||
onKeyDown={(event) => event.key === "Enter" && submit()}
|
||||
placeholder="YouTube URL, Spotify track, or search terms"
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="font-medium">Volume</span>
|
||||
<span className="text-muted-foreground">{draftVolume}%</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
value={draftVolume}
|
||||
onChange={(event) => setDraftVolume(Number(event.target.value))}
|
||||
className="h-2 w-full cursor-pointer accent-primary"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button disabled={loading || !source.trim()} onClick={submit}>Queue / Play</Button>
|
||||
<Button variant="secondary" disabled={loading} onClick={onSkip}>Skip</Button>
|
||||
<Button variant="destructive" disabled={loading} onClick={onStop}>Stop</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import { MonitorUp } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "../ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
|
||||
import { Input } from "../ui/input";
|
||||
|
||||
interface ScreenShareProps {
|
||||
loading: boolean;
|
||||
onStart: (source: string) => void;
|
||||
onSkip: () => void;
|
||||
onStop: () => void;
|
||||
}
|
||||
|
||||
export function ScreenShare({ loading, onStart, onSkip, onStop }: ScreenShareProps) {
|
||||
const [source, setSource] = useState("");
|
||||
|
||||
const submit = () => {
|
||||
const trimmed = source.trim();
|
||||
if (!trimmed) return;
|
||||
onStart(trimmed);
|
||||
setSource("");
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2"><MonitorUp className="h-5 w-5" /> Screen Share</CardTitle>
|
||||
<CardDescription>Start screen-share playback from a URL or local file path.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Input
|
||||
value={source}
|
||||
onChange={(event) => setSource(event.target.value)}
|
||||
onKeyDown={(event) => event.key === "Enter" && submit()}
|
||||
placeholder="Screen share URL or local file path"
|
||||
/>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button disabled={loading || !source.trim()} onClick={submit}>Start Screen Share</Button>
|
||||
<Button variant="secondary" disabled={loading} onClick={onSkip}>Skip</Button>
|
||||
<Button variant="destructive" disabled={loading} onClick={onStop}>Stop</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
import type { MessageMetadata, MessageRecord } from "../../types/messages";
|
||||
|
||||
function parseMetadata(value: string | null): MessageMetadata {
|
||||
if (!value) return {};
|
||||
try {
|
||||
return JSON.parse(value) as MessageMetadata;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
interface ImageItem {
|
||||
url: string;
|
||||
title: string;
|
||||
kind: "attachment" | "embed" | "sticker";
|
||||
message: MessageRecord;
|
||||
}
|
||||
|
||||
export function ImageGrid({ messages }: { messages: MessageRecord[] }) {
|
||||
const images: ImageItem[] = [];
|
||||
|
||||
for (const message of messages) {
|
||||
const metadata = parseMetadata(message.metadata);
|
||||
|
||||
// Stickers
|
||||
for (const sticker of metadata.stickers ?? []) {
|
||||
if (sticker.url) {
|
||||
images.push({ url: sticker.url, title: sticker.name || "sticker", kind: "sticker", message });
|
||||
}
|
||||
}
|
||||
|
||||
// Attachments
|
||||
for (const attachment of metadata.attachments ?? []) {
|
||||
if (attachment.url && (attachment.contentType?.startsWith("image/") || /\.(png|jpe?g|gif|webp)$/i.test(attachment.name))) {
|
||||
images.push({ url: attachment.url, title: attachment.name, kind: "attachment", message });
|
||||
}
|
||||
}
|
||||
|
||||
// Embed images
|
||||
for (const embed of metadata.embeds ?? []) {
|
||||
for (const imgUrl of [embed.image, embed.thumbnail].filter(Boolean)) {
|
||||
images.push({ url: imgUrl as string, title: embed.title || "embed image", kind: "embed", message });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (images.length === 0) {
|
||||
return <div className="rounded-2xl border border-dashed border-border p-10 text-center text-sm text-muted-foreground">No images found.</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
|
||||
{images.map((image, index) => (
|
||||
<a
|
||||
key={`${image.url}-${index}`}
|
||||
href={image.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="group overflow-hidden rounded-2xl border border-border bg-card shadow-sm transition-all hover:border-primary/30 hover:shadow-md"
|
||||
>
|
||||
<div className="relative aspect-video overflow-hidden">
|
||||
{image.kind === "sticker" ? (
|
||||
<img
|
||||
src={image.url}
|
||||
alt={image.title}
|
||||
className="h-full w-full object-contain bg-muted/30 p-2 transition-transform group-hover:scale-105"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
src={image.url}
|
||||
alt={image.title}
|
||||
className="h-full w-full object-cover transition-transform group-hover:scale-105"
|
||||
loading="lazy"
|
||||
/>
|
||||
)}
|
||||
<div className="absolute right-2 top-2 rounded-md bg-black/60 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wider text-white backdrop-blur">
|
||||
{image.kind}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3">
|
||||
<div className="truncate text-sm font-medium">{image.title}</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-4 w-4 overflow-hidden rounded-full">
|
||||
<img
|
||||
src={image.message.avatar_url ?? "https://cdn.discordapp.com/embed/avatars/0.png"}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<span className="truncate text-xs text-muted-foreground">{image.message.username}</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "../ui/card";
|
||||
|
||||
interface VoiceRecording {
|
||||
id: string;
|
||||
user_id: string;
|
||||
username: string;
|
||||
avatar_url: string | null;
|
||||
guild_id: string | null;
|
||||
channel_id: string | null;
|
||||
channel_name: string | null;
|
||||
filename: string;
|
||||
size_bytes: number;
|
||||
download_url: string | null;
|
||||
upload_status: "pending" | "uploaded" | "failed";
|
||||
upload_error: string | null;
|
||||
created_at: number;
|
||||
uploaded_at: number | null;
|
||||
}
|
||||
|
||||
function formatDate(value: number): string {
|
||||
return new Date(value).toLocaleString();
|
||||
}
|
||||
|
||||
function formatBytes(value: number): string {
|
||||
if (value < 1024) return `${value} B`;
|
||||
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
|
||||
return `${(value / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
export function RecordingsPanel() {
|
||||
const [recordings, setRecordings] = useState<VoiceRecording[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function loadRecordings() {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const response = await fetch("/api/recordings");
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load recordings: ${response.status}`);
|
||||
}
|
||||
const data = (await response.json()) as VoiceRecording[];
|
||||
if (!cancelled) setRecordings(data);
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
loadRecordings();
|
||||
window.addEventListener("voice_recording_uploaded", loadRecordings);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.removeEventListener("voice_recording_uploaded", loadRecordings);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Voice Recordings</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="rounded-xl border border-dashed border-border p-6 text-center text-sm text-muted-foreground">
|
||||
Loading recordings...
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="rounded-xl border border-dashed border-destructive p-6 text-center text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
) : recordings.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-border p-6 text-center text-sm text-muted-foreground">
|
||||
No recordings found.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{recordings.map((recording) => (
|
||||
<div
|
||||
key={recording.id}
|
||||
className="rounded-xl border border-border bg-background/60 p-4"
|
||||
>
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium">{recording.filename}</div>
|
||||
<div className="mt-1 text-sm text-muted-foreground">
|
||||
{recording.username} · {recording.channel_name ?? recording.channel_id ?? "unknown channel"} · {formatDate(recording.created_at)}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{formatBytes(recording.size_bytes)} · {recording.upload_status}
|
||||
</div>
|
||||
{recording.upload_error ? (
|
||||
<div className="mt-2 text-xs text-destructive">
|
||||
{recording.upload_error}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{recording.download_url ? (
|
||||
<a
|
||||
href={recording.download_url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="rounded-lg bg-primary px-3 py-2 text-center text-sm font-medium text-primary-foreground"
|
||||
>
|
||||
Download
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,196 +0,0 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import type { MessageRecord } from "../../types/messages";
|
||||
import { useReview, type ReviewStatus } from "../../hooks/useReview";
|
||||
import { MessageCard } from "../messages/MessageCard";
|
||||
import { Badge } from "../ui/badge";
|
||||
import { Button } from "../ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
|
||||
import { Input } from "../ui/input";
|
||||
import { Select } from "../ui/select";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
|
||||
|
||||
export interface ReviewPanelProps {
|
||||
messages: MessageRecord[];
|
||||
onReanalyze: (id: string) => void;
|
||||
}
|
||||
|
||||
type ReviewFilter = "all" | "warn" | "flagged" | "error";
|
||||
|
||||
const statusOptions = [
|
||||
{ value: "all", label: "All reviewable" },
|
||||
{ value: "warn", label: "Warn" },
|
||||
{ value: "flagged", label: "Flagged" },
|
||||
{ value: "error", label: "Errors" },
|
||||
];
|
||||
|
||||
function parseStringList(value?: string | null): string[] {
|
||||
if (!value) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(value) as unknown;
|
||||
return Array.isArray(parsed) ? parsed.filter((item): item is string => typeof item === "string") : [];
|
||||
} catch {
|
||||
return value
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
}
|
||||
|
||||
function ReviewDecisionControls({
|
||||
message,
|
||||
onReanalyze,
|
||||
}: {
|
||||
message: MessageRecord;
|
||||
onReanalyze: (id: string) => void;
|
||||
}) {
|
||||
const { createReview, loading, error } = useReview();
|
||||
const [notes, setNotes] = useState("");
|
||||
const [reviewerId, setReviewerId] = useState("public-eval");
|
||||
const [savedStatus, setSavedStatus] = useState<ReviewStatus | null>(null);
|
||||
|
||||
const submitDecision = async (status: ReviewStatus) => {
|
||||
const review = await createReview({
|
||||
message_id: message.id,
|
||||
guild_id: message.guild_id,
|
||||
channel_id: message.channel_id,
|
||||
reviewer_id: reviewerId.trim() || "public-eval",
|
||||
status,
|
||||
notes: notes.trim() || null,
|
||||
reviewed_at: Date.now(),
|
||||
});
|
||||
setSavedStatus(review.status);
|
||||
if (status === "rejected") {
|
||||
onReanalyze(message.id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-muted/30 p-3">
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">AI Eval Decision</span>
|
||||
{savedStatus ? <Badge variant="success">saved: {savedStatus}</Badge> : null}
|
||||
</div>
|
||||
<div className="grid gap-2 md:grid-cols-[160px_1fr]">
|
||||
<Input
|
||||
value={reviewerId}
|
||||
onChange={(event) => setReviewerId(event.target.value)}
|
||||
placeholder="reviewer label"
|
||||
/>
|
||||
<Input
|
||||
value={notes}
|
||||
onChange={(event) => setNotes(event.target.value)}
|
||||
placeholder="reason / evaluation note"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="outline" disabled={loading} onClick={() => submitDecision("approved")}>
|
||||
Approve AI
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" disabled={loading} onClick={() => submitDecision("rejected")}>
|
||||
False Positive + Reanalyze
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" disabled={loading} onClick={() => submitDecision("escalated")}>
|
||||
Escalate
|
||||
</Button>
|
||||
</div>
|
||||
{error ? <div className="mt-2 text-xs text-destructive">{error}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReviewPanel({ messages, onReanalyze }: ReviewPanelProps) {
|
||||
const [statusFilter, setStatusFilter] = useState<ReviewFilter>("all");
|
||||
const [severityFilter, setSeverityFilter] = useState("");
|
||||
const [categoryFilter, setCategoryFilter] = useState("");
|
||||
|
||||
const reviewable = useMemo(
|
||||
() => messages.filter((message) => message.ai_status === "warn" || message.ai_status === "flagged" || message.ai_status === "error"),
|
||||
[messages],
|
||||
);
|
||||
|
||||
const categories = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
for (const message of reviewable) {
|
||||
for (const category of parseStringList(message.ai_categories ?? message.ai_moderation_flags)) {
|
||||
set.add(category);
|
||||
}
|
||||
}
|
||||
return Array.from(set).sort();
|
||||
}, [reviewable]);
|
||||
|
||||
const filtered = reviewable.filter((message) => {
|
||||
if (statusFilter !== "all" && message.ai_status !== statusFilter) return false;
|
||||
if (severityFilter && message.ai_severity !== severityFilter) return false;
|
||||
if (categoryFilter) {
|
||||
const messageCategories = parseStringList(message.ai_categories ?? message.ai_moderation_flags);
|
||||
if (!messageCategories.includes(categoryFilter)) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const flaggedItems = filtered.filter(
|
||||
(message) => message.ai_status === "warn" || message.ai_status === "flagged",
|
||||
);
|
||||
const errorItems = filtered.filter((message) => message.ai_status === "error");
|
||||
|
||||
const renderList = (items: MessageRecord[], emptyText: string) => (
|
||||
<div className="space-y-3">
|
||||
{items.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-border p-6 text-center text-sm text-muted-foreground">
|
||||
{emptyText}
|
||||
</div>
|
||||
) : (
|
||||
items.map((message) => (
|
||||
<div key={message.id} className="space-y-2">
|
||||
<MessageCard message={message} onReanalyze={onReanalyze} />
|
||||
<ReviewDecisionControls message={message} onReanalyze={onReanalyze} />
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Moderation Review & AI Eval</CardTitle>
|
||||
<CardDescription>
|
||||
Public AI evaluation queue: {reviewable.length} reviewable messages, {errorItems.length} analysis errors.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="mb-4 grid gap-2 md:grid-cols-3">
|
||||
<Select
|
||||
value={statusFilter}
|
||||
onChange={(event) => setStatusFilter(event.target.value as ReviewFilter)}
|
||||
options={statusOptions}
|
||||
/>
|
||||
<Select
|
||||
value={severityFilter}
|
||||
onChange={(event) => setSeverityFilter(event.target.value)}
|
||||
placeholder="All severities"
|
||||
options={["none", "low", "medium", "high", "critical"].map((severity) => ({ value: severity, label: severity }))}
|
||||
/>
|
||||
<Select
|
||||
value={categoryFilter}
|
||||
onChange={(event) => setCategoryFilter(event.target.value)}
|
||||
placeholder="All categories"
|
||||
options={categories.map((category) => ({ value: category, label: category }))}
|
||||
/>
|
||||
</div>
|
||||
<Tabs defaultValue="flags">
|
||||
<TabsList>
|
||||
<TabsTrigger value="flags">Flags ({flaggedItems.length})</TabsTrigger>
|
||||
<TabsTrigger value="errors">Errors ({errorItems.length})</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="flags">
|
||||
{renderList(flaggedItems, "No warned or flagged messages match the filters.")}
|
||||
</TabsContent>
|
||||
<TabsContent value="errors">
|
||||
{renderList(errorItems, "No analysis errors match the filters.")}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import type { ActiveSpeaker } from "../../types/voice";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "../ui/card";
|
||||
|
||||
interface ActiveSpeakersProps {
|
||||
speakers: ActiveSpeaker[];
|
||||
}
|
||||
|
||||
export function ActiveSpeakers({ speakers }: ActiveSpeakersProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Active Speakers</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{speakers.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-border p-6 text-center text-sm text-muted-foreground">
|
||||
No active speakers.
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{speakers.map((speaker, index) => (
|
||||
<div key={speaker.userId || speaker.id || index} className="flex items-center gap-3 rounded-xl border border-border bg-background/60 p-3">
|
||||
<img src={speaker.avatar} alt="" className="h-10 w-10 rounded-full object-cover" />
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium">{speaker.username}</div>
|
||||
<div className="text-xs text-emerald-300">Speaking</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
interface AudioVisualizerProps {
|
||||
levels: number[];
|
||||
}
|
||||
|
||||
export function AudioVisualizer({ levels }: AudioVisualizerProps) {
|
||||
const bars = levels.length ? levels : Array.from({ length: 32 }, () => 0.04);
|
||||
return (
|
||||
<div className="flex h-40 items-end gap-1 rounded-2xl border border-border bg-background/60 p-4">
|
||||
{bars.map((level, index) => (
|
||||
<div
|
||||
key={`${index}-${level}`}
|
||||
className="flex-1 rounded-full bg-gradient-to-t from-primary/50 to-cyan-300 transition-all duration-150"
|
||||
style={{ height: `${Math.max(6, Math.min(100, level * 100))}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
import type { Channel, Guild, VoiceStatus } from "../../types/voice";
|
||||
import { Button } from "../ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
|
||||
import { Select } from "../ui/select";
|
||||
|
||||
interface VoiceControlProps {
|
||||
guilds: Guild[];
|
||||
channels: Channel[];
|
||||
selectedGuild: string;
|
||||
selectedChannel: string;
|
||||
status: VoiceStatus;
|
||||
loading: boolean;
|
||||
onGuildChange: (guildId: string) => void;
|
||||
onChannelChange: (channelId: string) => void;
|
||||
onJoin: () => void;
|
||||
onDisconnect: () => void;
|
||||
onListenToggle: () => void;
|
||||
onStreamingToggle: () => void;
|
||||
isListening: boolean;
|
||||
isStreaming: boolean;
|
||||
}
|
||||
|
||||
export function VoiceControl({
|
||||
guilds,
|
||||
channels,
|
||||
selectedGuild,
|
||||
selectedChannel,
|
||||
status,
|
||||
loading,
|
||||
onGuildChange,
|
||||
onChannelChange,
|
||||
onJoin,
|
||||
onDisconnect,
|
||||
onListenToggle,
|
||||
onStreamingToggle,
|
||||
isListening,
|
||||
isStreaming,
|
||||
}: VoiceControlProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Voice Bridge</CardTitle>
|
||||
<CardDescription>Join a Discord voice channel and monitor audio in real time.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Guild</label>
|
||||
<Select
|
||||
value={selectedGuild}
|
||||
onChange={(event) => onGuildChange(event.target.value)}
|
||||
placeholder="Select guild"
|
||||
options={guilds.map((guild) => ({ value: guild.id, label: guild.name }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Voice Channel</label>
|
||||
<Select
|
||||
value={selectedChannel}
|
||||
onChange={(event) => onChannelChange(event.target.value)}
|
||||
placeholder="Select voice channel"
|
||||
options={channels.map((channel) => ({ value: channel.id, label: channel.name }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button disabled={!selectedGuild || !selectedChannel || loading} onClick={onJoin}>
|
||||
{status.connected ? "Reconnect" : "Join Voice"}
|
||||
</Button>
|
||||
<Button variant="destructive" disabled={!status.connected || loading} onClick={onDisconnect}>
|
||||
Disconnect
|
||||
</Button>
|
||||
<Button variant={isListening ? "secondary" : "outline"} onClick={onListenToggle}>
|
||||
{isListening ? "Stop Listening" : "Listen Live"}
|
||||
</Button>
|
||||
<Button variant={isStreaming ? "destructive" : "default"} onClick={onStreamingToggle}>
|
||||
{isStreaming ? "Stop Transmitting" : "Start Transmitting"}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import type { ActiveSpeaker, Channel, Guild, VoiceStatus } from "../../types/voice";
|
||||
import { AudioVisualizer } from "./AudioVisualizer";
|
||||
import { ActiveSpeakers } from "./ActiveSpeakers";
|
||||
import { VoiceControl } from "./VoiceControl";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "../ui/card";
|
||||
|
||||
interface VoicePanelProps {
|
||||
guilds: Guild[];
|
||||
channels: Channel[];
|
||||
selectedGuild: string;
|
||||
selectedChannel: string;
|
||||
status: VoiceStatus;
|
||||
loading: boolean;
|
||||
activeSpeakers: ActiveSpeaker[];
|
||||
levels: number[];
|
||||
isListening: boolean;
|
||||
isStreaming: boolean;
|
||||
onGuildChange: (guildId: string) => void;
|
||||
onChannelChange: (channelId: string) => void;
|
||||
onJoin: () => void;
|
||||
onDisconnect: () => void;
|
||||
onListenToggle: () => void;
|
||||
onStreamingToggle: () => void;
|
||||
}
|
||||
|
||||
export function VoicePanel(props: VoicePanelProps) {
|
||||
return (
|
||||
<div className="grid gap-6">
|
||||
<VoiceControl {...props} />
|
||||
<div className="grid gap-6 xl:grid-cols-[1fr_360px]">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Live Audio Visualizer</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<AudioVisualizer levels={props.levels} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<ActiveSpeakers speakers={props.activeSpeakers} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export interface Guild {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string | null;
|
||||
}
|
||||
|
||||
export interface Channel {
|
||||
id: string;
|
||||
name: string;
|
||||
type?: string;
|
||||
parentId?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
export type AIStatus = "pending" | "clean" | "warn" | "flagged" | "error";
|
||||
export type AISeverity = "none" | "low" | "medium" | "high" | "critical";
|
||||
export type AIRecommendedAction = "none" | "monitor" | "warn" | "review" | "delete" | "escalate";
|
||||
|
||||
export interface MessageMetadata {
|
||||
stickers?: Array<{ name?: string; url?: string }>;
|
||||
attachments?: Array<{ name: string; url: string; contentType?: string }>;
|
||||
embeds?: Array<{ title?: string; image?: string; thumbnail?: string }>;
|
||||
}
|
||||
|
||||
export interface MessageRecord {
|
||||
id: string;
|
||||
guild_id: string;
|
||||
channel_id: string;
|
||||
thread_id: string | null;
|
||||
user_id: string;
|
||||
username: string;
|
||||
avatar_url: string | null;
|
||||
content: string;
|
||||
edited_content: string | null;
|
||||
created_at: number;
|
||||
edited_at: number | null;
|
||||
deleted_at: number | null;
|
||||
type: "text" | "edited" | "deleted";
|
||||
metadata: string | null;
|
||||
ai_status?: AIStatus | null;
|
||||
ai_moderation_flags?: string | null;
|
||||
ai_moderation_score?: number | null;
|
||||
ai_analysis?: string | null;
|
||||
ai_categories?: string | null;
|
||||
ai_severity?: AISeverity | null;
|
||||
ai_confidence?: number | null;
|
||||
ai_recommended_action?: AIRecommendedAction | null;
|
||||
ai_analyzed_at?: number | null;
|
||||
ai_error?: string | null;
|
||||
}
|
||||
|
||||
export interface PageResult<T> {
|
||||
data: T[];
|
||||
nextCursor: string | null;
|
||||
}
|
||||
@@ -1,16 +1,3 @@
|
||||
export interface Guild {
|
||||
id: string;
|
||||
name: string;
|
||||
icon?: string | null;
|
||||
}
|
||||
|
||||
export interface Channel {
|
||||
id: string;
|
||||
name: string;
|
||||
type?: string;
|
||||
parentId?: string | null;
|
||||
}
|
||||
|
||||
export interface VoiceStatus {
|
||||
connected: boolean;
|
||||
activeGuildId?: string | null;
|
||||
+8
-12
@@ -1,5 +1,5 @@
|
||||
import type { HourlyBucket } from "../../api/analytics";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
|
||||
import type { HourlyBucket } from "../../../shared/api/client";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../../../shared/ui";
|
||||
|
||||
interface ActivityChartProps {
|
||||
hourly: HourlyBucket[];
|
||||
@@ -29,7 +29,7 @@ export function ActivityChart({ hourly, loading }: ActivityChartProps) {
|
||||
});
|
||||
|
||||
return (
|
||||
<Card className="col-span-2">
|
||||
<Card className="col-span-1 lg:col-span-2 glass border-white/5">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-semibold">Aktivitas per Jam</CardTitle>
|
||||
<CardDescription className="text-xs">Distribusi pesan per jam berdasarkan status moderasi.</CardDescription>
|
||||
@@ -73,21 +73,17 @@ export function ActivityChart({ hourly, loading }: ActivityChartProps) {
|
||||
|
||||
function LoadingBox() {
|
||||
return (
|
||||
<Card className="col-span-2">
|
||||
<CardContent className="flex h-65 items-center justify-center text-sm text-muted-foreground">
|
||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
<span className="ml-2">Memuat data...</span>
|
||||
</CardContent>
|
||||
<Card className="col-span-1 flex h-65 items-center justify-center text-sm text-muted-foreground lg:col-span-2 glass border-white/5">
|
||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
<span className="ml-2">Memuat data...</span>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyBox({ text }: { text: string }) {
|
||||
return (
|
||||
<Card className="col-span-2">
|
||||
<CardContent className="flex h-65 items-center justify-center text-sm text-muted-foreground">
|
||||
{text}
|
||||
</CardContent>
|
||||
<Card className="col-span-1 flex h-65 items-center justify-center text-sm text-muted-foreground lg:col-span-2 glass border-white/5">
|
||||
{text}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
+3
-5
@@ -1,9 +1,7 @@
|
||||
import { Activity, BarChart3 } from "lucide-react";
|
||||
import type { Channel, Guild } from "../../types/voice";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
|
||||
import { Select } from "../ui/select";
|
||||
import { Button } from "../ui/button";
|
||||
import { cn } from "../../lib/utils";
|
||||
import type { Channel, Guild } from "../../../shared/api/client";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle, Select, Button } from "../../../shared/ui";
|
||||
import { cn } from "../../../shared/lib/utils";
|
||||
|
||||
const TIME_RANGES = [
|
||||
{ label: "1j", value: 1 },
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
import { useMemo } from "react";
|
||||
import type { HeatmapCell } from "../../api/analytics";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
|
||||
import { cn } from "../../lib/utils";
|
||||
import type { HeatmapCell } from "../../../shared/api/client";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../../../shared/ui";
|
||||
import { cn } from "../../../shared/lib/utils";
|
||||
|
||||
const DAYS = ["Sen", "Sel", "Rab", "Kam", "Jum", "Sab", "Min"];
|
||||
|
||||
+6
-6
@@ -1,6 +1,6 @@
|
||||
import type { ModerationBreakdown } from "../../api/analytics";
|
||||
import { Card, CardContent } from "../ui/card";
|
||||
import { cn } from "../../lib/utils";
|
||||
import type { ModerationBreakdown } from "../../../shared/api/client";
|
||||
import { Card, CardContent, Skeleton } from "../../../shared/ui";
|
||||
import { cn } from "../../../shared/lib/utils";
|
||||
|
||||
interface SummaryCardsProps {
|
||||
messages: ModerationBreakdown | null;
|
||||
@@ -27,16 +27,16 @@ export function SummaryCards({ messages, activeUsersCount, totalChannels, loadin
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-4 gap-2 lg:grid-cols-8">
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4 lg:grid-cols-8">
|
||||
{cards.map((card) => (
|
||||
<Card key={card.label} className="overflow-hidden">
|
||||
<Card key={card.label} className="overflow-hidden glass border-white/5">
|
||||
<CardContent className="p-3">
|
||||
<div className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{card.label}
|
||||
</div>
|
||||
<div className={cn("mt-1 font-mono text-lg font-bold tabular-nums", card.accent)}>
|
||||
{loading ? (
|
||||
<span className="animate-pulse">…</span>
|
||||
<Skeleton className="h-7 w-12 mt-1" />
|
||||
) : (
|
||||
card.value
|
||||
)}
|
||||
+2
-3
@@ -1,7 +1,6 @@
|
||||
import { Flame } from "lucide-react";
|
||||
import type { TopicTrend } from "../../api/analytics";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
|
||||
import { ScrollArea } from "../ui/scroll-area";
|
||||
import type { TopicTrend } from "../../../shared/api/client";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle, ScrollArea } from "../../../shared/ui";
|
||||
|
||||
interface TopicListProps {
|
||||
topics: TopicTrend[];
|
||||
+6
-6
@@ -1,5 +1,5 @@
|
||||
import type { TrendBucket } from "../../api/analytics";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
|
||||
import type { TrendBucket } from "../../../shared/api/client";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../../../shared/ui";
|
||||
|
||||
interface TrendChartProps {
|
||||
trend: TrendBucket[];
|
||||
@@ -64,10 +64,10 @@ export function TrendChart({ trend, loading }: TrendChartProps) {
|
||||
</g>
|
||||
|
||||
<TrendArea data={data} keyName="total" fill="url(#trendFill)" stroke="#3b82f6" />
|
||||
<TrendLine data={data} keyName="total" stroke="#3b82f6" strokeWidth={2.5} />
|
||||
<TrendLine data={data} keyName="clean" stroke="#10b981" strokeWidth={1.8} />
|
||||
<TrendLine data={data} keyName="warned" stroke="#f59e0b" strokeWidth={1.8} />
|
||||
<TrendLine data={data} keyName="flagged" stroke="#ef4444" strokeWidth={1.8} />
|
||||
<TrendLine data={data} keyName="total" color="#3b82f6" strokeWidth={2.5} />
|
||||
<TrendLine data={data} keyName="clean" color="#10b981" strokeWidth={1.8} />
|
||||
<TrendLine data={data} keyName="warned" color="#f59e0b" strokeWidth={1.8} />
|
||||
<TrendLine data={data} keyName="flagged" color="#ef4444" strokeWidth={1.8} />
|
||||
|
||||
{data.map((item, index) => {
|
||||
const x = data.length <= 1 ? 0 : (index / (data.length - 1)) * Math.max((data.length - 1) * 56, 56);
|
||||
+2
-4
@@ -1,8 +1,6 @@
|
||||
import { Users } from "lucide-react";
|
||||
import type { UserStat } from "../../api/analytics";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
|
||||
import { Badge } from "../ui/badge";
|
||||
import { ScrollArea } from "../ui/scroll-area";
|
||||
import type { UserStat } from "../../../shared/api/client";
|
||||
import { Badge, Card, CardContent, CardDescription, CardHeader, CardTitle, ScrollArea } from "../../../shared/ui";
|
||||
|
||||
interface UserTableProps {
|
||||
users: UserStat[];
|
||||
+3
-5
@@ -1,8 +1,6 @@
|
||||
import { Siren } from "lucide-react";
|
||||
import type { ViolatorStat } from "../../api/analytics";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
|
||||
import { Badge } from "../ui/badge";
|
||||
import { ScrollArea } from "../ui/scroll-area";
|
||||
import type { ViolatorStat } from "../../../shared/api/client";
|
||||
import { Badge, Card, CardContent, CardDescription, CardHeader, CardTitle, ScrollArea } from "../../../shared/ui";
|
||||
|
||||
interface ViolatorTableProps {
|
||||
users: ViolatorStat[];
|
||||
@@ -118,7 +116,7 @@ export function ViolatorTable({ users, loading }: ViolatorTableProps) {
|
||||
);
|
||||
}
|
||||
|
||||
import { cn } from "../../lib/utils";
|
||||
import { cn } from "../../../shared/lib/utils";
|
||||
|
||||
function LoadingBox() {
|
||||
return (
|
||||
+21
-41
@@ -1,18 +1,21 @@
|
||||
import { useQuery, useQueryClient, keepPreviousData } from "@tanstack/react-query";
|
||||
import { useQuery, keepPreviousData } from "@tanstack/react-query";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import {
|
||||
fetchAnalyticsOverview,
|
||||
fetchViolators,
|
||||
fetchTrend,
|
||||
fetchHeatmap,
|
||||
type AnalyticsOverview,
|
||||
type HourlyBucket,
|
||||
type TopicTrend,
|
||||
type UserStat,
|
||||
type ViolatorStat,
|
||||
type TrendBucket,
|
||||
type HeatmapCell,
|
||||
} from "../api/analytics";
|
||||
} from "../../../shared/api/client";
|
||||
import type { AnalyticsOverview, HourlyBucket, TopicTrend, UserStat, ViolatorStat, TrendBucket, HeatmapCell } from "../../../shared/api/client";
|
||||
|
||||
function analyticsKeys(guildId: string, channelId: string | undefined, hours: number) {
|
||||
return {
|
||||
overview: ["analytics", "overview", guildId, channelId ?? "", hours] as const,
|
||||
violators: ["analytics", "violators", guildId, channelId ?? "", hours] as const,
|
||||
trend: ["analytics", "trend", guildId, channelId ?? "", hours] as const,
|
||||
heatmap: ["analytics", "heatmap", guildId, channelId ?? "", hours] as const,
|
||||
};
|
||||
}
|
||||
|
||||
interface UseAnalyticsOptions {
|
||||
guildId: string;
|
||||
@@ -20,22 +23,9 @@ interface UseAnalyticsOptions {
|
||||
hours?: number;
|
||||
}
|
||||
|
||||
/** Shared key factory so WebSocket refresh invalidates all related queries at once. */
|
||||
function analyticsKeys(guildId: string, channelId: string | undefined, hours: number) {
|
||||
return {
|
||||
overview: ["analytics", "overview", guildId, channelId ?? "", hours] as const,
|
||||
violators: ["analytics", "violators", guildId, channelId ?? "", hours] as const,
|
||||
trend: ["analytics", "trend", guildId, channelId ?? "", hours] as const,
|
||||
heatmap: ["analytics", "heatmap", guildId, channelId ?? "", hours] as const,
|
||||
all: ["analytics"] as const,
|
||||
};
|
||||
}
|
||||
|
||||
export function useAnalytics({ guildId, channelId, hours = 24 }: UseAnalyticsOptions) {
|
||||
const queryClient = useQueryClient();
|
||||
const keys = analyticsKeys(guildId, channelId, hours);
|
||||
|
||||
// ── Overview query (stale-while-revalidate) ──────────────────────────
|
||||
const overviewQuery = useQuery({
|
||||
queryKey: keys.overview,
|
||||
queryFn: () => fetchAnalyticsOverview({ guildId, channelId, hours }),
|
||||
@@ -44,17 +34,14 @@ export function useAnalytics({ guildId, channelId, hours = 24 }: UseAnalyticsOpt
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
// ── Violators query ──────────────────────────────────────────────────
|
||||
const violatorsQuery = useQuery({
|
||||
queryKey: keys.violators,
|
||||
queryFn: () =>
|
||||
fetchViolators({ guildId, channelId, hours, limit: 20 }),
|
||||
queryFn: () => fetchViolators({ guildId, channelId, hours, limit: 20 }),
|
||||
enabled: !!guildId,
|
||||
staleTime: 30_000,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
// ── Trend query ──────────────────────────────────────────────────────
|
||||
const trendQuery = useQuery({
|
||||
queryKey: keys.trend,
|
||||
queryFn: () => fetchTrend({ guildId, channelId, hours }),
|
||||
@@ -63,7 +50,6 @@ export function useAnalytics({ guildId, channelId, hours = 24 }: UseAnalyticsOpt
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
// ── Heatmap query ────────────────────────────────────────────────────
|
||||
const heatmapQuery = useQuery({
|
||||
queryKey: keys.heatmap,
|
||||
queryFn: () => fetchHeatmap({ guildId, channelId, hours }),
|
||||
@@ -72,18 +58,17 @@ export function useAnalytics({ guildId, channelId, hours = 24 }: UseAnalyticsOpt
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
// ── Refresh: invalidate & refetch ────────────────────────────────────
|
||||
const refresh = useCallback(() => {
|
||||
if (!guildId) return;
|
||||
queryClient.invalidateQueries({ queryKey: keys.overview });
|
||||
queryClient.invalidateQueries({ queryKey: keys.violators });
|
||||
queryClient.invalidateQueries({ queryKey: keys.trend });
|
||||
queryClient.invalidateQueries({ queryKey: keys.heatmap });
|
||||
}, [queryClient, keys, guildId]);
|
||||
window.dispatchEvent(new CustomEvent("analytics_refresh"));
|
||||
}, [guildId]);
|
||||
|
||||
// Real-time refresh via WebSocket-triggered custom event
|
||||
useEffect(() => {
|
||||
const handler = () => refresh();
|
||||
const handler = () => {
|
||||
if (!guildId) return;
|
||||
// Use queryClient.invalidateQueries from the React Query internals
|
||||
window.dispatchEvent(new CustomEvent("analytics_force_refresh"));
|
||||
};
|
||||
window.addEventListener("analytics_refresh", handler);
|
||||
return () => window.removeEventListener("analytics_refresh", handler);
|
||||
}, [refresh]);
|
||||
@@ -99,25 +84,21 @@ export function useAnalytics({ guildId, channelId, hours = 24 }: UseAnalyticsOpt
|
||||
error: overviewQuery.error instanceof Error ? overviewQuery.error.message : null,
|
||||
refresh,
|
||||
|
||||
// Violators
|
||||
violators: violatorsQuery.data ?? [],
|
||||
violatorsLoading: violatorsQuery.isLoading && !violatorsQuery.data,
|
||||
violatorsFetching: violatorsQuery.isFetching && !violatorsQuery.isLoading,
|
||||
refreshViolators: () => {
|
||||
if (guildId) queryClient.invalidateQueries({ queryKey: keys.violators });
|
||||
if (guildId) window.dispatchEvent(new CustomEvent("analytics_refresh"));
|
||||
},
|
||||
|
||||
// Trend
|
||||
trend: trendQuery.data ?? [],
|
||||
trendLoading: trendQuery.isLoading && !trendQuery.data,
|
||||
trendFetching: trendQuery.isFetching && !trendQuery.isLoading,
|
||||
|
||||
// Heatmap
|
||||
heatmap: heatmapQuery.data ?? [],
|
||||
heatmapLoading: heatmapQuery.isLoading && !heatmapQuery.data,
|
||||
heatmapFetching: heatmapQuery.isFetching && !heatmapQuery.isLoading,
|
||||
|
||||
// Convenience accessors
|
||||
hourly: overview?.hourly ?? ([] as HourlyBucket[]),
|
||||
topics: overview?.topics ?? ([] as TopicTrend[]),
|
||||
topUsers: overview?.top_users ?? ([] as UserStat[]),
|
||||
@@ -128,5 +109,4 @@ export function useAnalytics({ guildId, channelId, hours = 24 }: UseAnalyticsOpt
|
||||
};
|
||||
}
|
||||
|
||||
// Re-export for convenience
|
||||
export type { AnalyticsOverview, HourlyBucket, TopicTrend, UserStat, ViolatorStat, TrendBucket, HeatmapCell };
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useState } from "react";
|
||||
import type { Channel, Guild } from "../../shared/api/client";
|
||||
import { useAnalytics } from "./hooks/useAnalytics";
|
||||
import { ControlBar } from "./components/ControlBar";
|
||||
import { SummaryCards } from "./components/SummaryCards";
|
||||
import { ActivityChart } from "./components/ActivityChart";
|
||||
import { TrendChart } from "./components/TrendChart";
|
||||
import { Heatmap } from "./components/Heatmap";
|
||||
import { TopicList } from "./components/TopicList";
|
||||
import { UserTable } from "./components/UserTable";
|
||||
import { ViolatorTable } from "./components/ViolatorTable";
|
||||
|
||||
interface AnalyticsPanelProps {
|
||||
guilds: Guild[];
|
||||
channels: Channel[];
|
||||
selectedGuild: string;
|
||||
selectedChannel: string;
|
||||
onGuildChange: (guildId: string) => void;
|
||||
onChannelChange: (channelId: string) => void;
|
||||
}
|
||||
|
||||
export function AnalyticsPanel({
|
||||
guilds, channels, selectedGuild, selectedChannel,
|
||||
onGuildChange, onChannelChange,
|
||||
}: AnalyticsPanelProps) {
|
||||
const [hours, setHours] = useState(24);
|
||||
const analytics = useAnalytics({ guildId: selectedGuild, channelId: selectedChannel || undefined, hours });
|
||||
|
||||
const { hourly, topics, topUsers, activeUsersCount, totalChannels, violators, trend, heatmap, isLoading, isFetching, error, refresh, refreshViolators, messages: analyticsMessages } = analytics;
|
||||
const loading = isLoading && !isFetching;
|
||||
|
||||
if (error && !analyticsMessages) {
|
||||
return <div className="rounded-lg border border-red-500/30 bg-red-500/5 p-4 text-sm text-red-300">{error}</div>;
|
||||
}
|
||||
|
||||
if (!selectedGuild) {
|
||||
return <div className="flex min-h-[300px] flex-col items-center justify-center gap-3 rounded-lg border border-dashed p-8"><p className="text-sm text-muted-foreground">Pilih guild untuk melihat analitik.</p></div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<ControlBar guilds={guilds} channels={channels} selectedGuild={selectedGuild} selectedChannel={selectedChannel} hours={hours} isFetching={isFetching} onGuildChange={onGuildChange} onChannelChange={onChannelChange} onHoursChange={setHours} onRefresh={() => { refresh(); refreshViolators(); }} />
|
||||
<SummaryCards messages={analyticsMessages} activeUsersCount={activeUsersCount} totalChannels={totalChannels} loading={loading} />
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<ActivityChart hourly={hourly} loading={loading} />
|
||||
<div className="col-span-1"><TopicList topics={topics} loading={loading} /></div>
|
||||
</div>
|
||||
{hours >= 48 && <TrendChart trend={trend} loading={loading} />}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<Heatmap cells={heatmap} loading={loading} />
|
||||
<div className="col-span-1"><UserTable users={topUsers} loading={loading} /></div>
|
||||
</div>
|
||||
<ViolatorTable users={violators} loading={loading} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+4
-6
@@ -1,8 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { login } from "../../api/auth";
|
||||
import { Button } from "../ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
|
||||
import { Input } from "../ui/input";
|
||||
import { login } from "../../shared/api/client";
|
||||
import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle, Input } from "../../shared/ui";
|
||||
import { Lock } from "lucide-react";
|
||||
|
||||
interface AuthOverlayProps {
|
||||
@@ -14,7 +12,7 @@ export function AuthOverlay({ onAuthenticated }: AuthOverlayProps) {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
const handleSubmit = async (e: { preventDefault: () => void }) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
@@ -22,7 +20,7 @@ export function AuthOverlay({ onAuthenticated }: AuthOverlayProps) {
|
||||
await login(password);
|
||||
localStorage.setItem("admin-password", password);
|
||||
onAuthenticated();
|
||||
} catch (err) {
|
||||
} catch {
|
||||
setError("Invalid password");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { ActiveSpeaker } from "../../../shared/api/client";
|
||||
import { Skeleton } from "../../../shared/ui";
|
||||
|
||||
interface ActiveSpeakersProps {
|
||||
speakers: ActiveSpeaker[];
|
||||
}
|
||||
|
||||
export function ActiveSpeakers({ speakers }: ActiveSpeakersProps) {
|
||||
if (speakers.length === 0) {
|
||||
return <div className="rounded-xl border border-dashed border-border p-6 text-center text-sm text-muted-foreground">No active speakers.</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{speakers.map((s) => {
|
||||
// BUG 4 FIX: stable key — no index fallback
|
||||
const key = s.userId ?? s.id ?? `speaker-${s.username}`;
|
||||
return (
|
||||
<div key={key} className="flex items-center gap-3 rounded-xl border border-border bg-background/60 p-3">
|
||||
<img src={s.avatar} alt="" className="h-8 w-8 rounded-full object-cover" />
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium">{s.username}</div>
|
||||
<div className="text-xs text-emerald-300">Speaking</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ActiveSpeakersSkeleton() {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="flex items-center gap-3 rounded-xl border border-border bg-background/60 p-3">
|
||||
<Skeleton className="h-8 w-8 rounded-full" />
|
||||
<div className="flex-1 space-y-1">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-3 w-16" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
interface AudioVisualizerProps {
|
||||
levels: number[];
|
||||
}
|
||||
|
||||
export function AudioVisualizer({ levels }: AudioVisualizerProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
const width = canvas.width;
|
||||
const height = canvas.height;
|
||||
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
|
||||
const barWidth = width / levels.length;
|
||||
const maxBarHeight = height * 0.85;
|
||||
|
||||
for (let i = 0; i < levels.length; i++) {
|
||||
const level = levels[i];
|
||||
const barHeight = Math.min(maxBarHeight, level * maxBarHeight);
|
||||
const x = i * barWidth;
|
||||
const y = height - barHeight;
|
||||
|
||||
// Gradient color based on level
|
||||
const hue = 199 - level * 199;
|
||||
const saturation = 89;
|
||||
const lightness = 48 + level * 20;
|
||||
ctx.fillStyle = `hsl(${hue}, ${saturation}%, ${lightness}%)`;
|
||||
|
||||
// Rounded bar
|
||||
const radius = barWidth * 0.3;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + radius, y);
|
||||
ctx.lineTo(x + barWidth - radius, y);
|
||||
ctx.quadraticCurveTo(x + barWidth, y, x + barWidth, y + radius);
|
||||
ctx.lineTo(x + barWidth, height);
|
||||
ctx.lineTo(x, height);
|
||||
ctx.lineTo(x, y + radius);
|
||||
ctx.quadraticCurveTo(x, y, x + radius, y);
|
||||
ctx.fill();
|
||||
}
|
||||
}, [levels]);
|
||||
|
||||
return (
|
||||
<div className="relative w-full">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={512}
|
||||
height={128}
|
||||
className="w-full rounded-xl bg-muted/30"
|
||||
style={{ height: "128px" }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button, Input } from "../../../shared/ui";
|
||||
import { Music2, SkipForward, Square, Volume2 } from "lucide-react";
|
||||
|
||||
interface MusicSubPanelProps {
|
||||
volume: number;
|
||||
onVolumeChange: (v: number) => void;
|
||||
onQueue: (source: string) => void;
|
||||
onSkip: () => void;
|
||||
onStop: () => void;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export function MusicSubPanel({ volume, onVolumeChange, onQueue, onSkip, onStop, loading }: MusicSubPanelProps) {
|
||||
const [source, setSource] = useState("");
|
||||
const safeVolume = Number.isFinite(volume) ? Math.max(0, Math.min(1, volume)) : 1;
|
||||
const [draftVolume, setDraftVolume] = useState(Math.round(safeVolume * 100));
|
||||
|
||||
// Debounced volume — poll every 200ms instead of instant send to avoid flood
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => {
|
||||
const normalized = draftVolume / 100;
|
||||
if (Math.abs(normalized - safeVolume) >= 0.001) onVolumeChange(normalized);
|
||||
}, 200);
|
||||
return () => clearInterval(id);
|
||||
}, [draftVolume, safeVolume, onVolumeChange]);
|
||||
|
||||
const submit = () => { const t = source.trim(); if (!t) return; onQueue(t); setSource(""); };
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
value={source}
|
||||
onChange={(e) => setSource(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && submit()}
|
||||
placeholder="YouTube URL, Spotify track, or search terms"
|
||||
/>
|
||||
<div className="flex items-center gap-3">
|
||||
<Volume2 className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
value={draftVolume}
|
||||
onChange={(e) => setDraftVolume(Number(e.target.value))}
|
||||
className="h-2 w-full cursor-pointer accent-primary"
|
||||
/>
|
||||
<span className="w-10 shrink-0 text-right text-sm tabular-nums text-muted-foreground">{draftVolume}%</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button disabled={loading || !source.trim()} onClick={submit}>
|
||||
<Music2 className="mr-1.5 h-4 w-4" /> Queue
|
||||
</Button>
|
||||
<Button variant="secondary" disabled={loading} onClick={onSkip}>
|
||||
<SkipForward className="mr-1.5 h-4 w-4" /> Skip
|
||||
</Button>
|
||||
<Button variant="destructive" disabled={loading} onClick={onStop}>
|
||||
<Square className="mr-1.5 h-4 w-4" /> Stop
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { MediaItem } from "../../../shared/api/client";
|
||||
import { Badge } from "../../../shared/ui";
|
||||
import { Music2, MonitorUp } from "lucide-react";
|
||||
|
||||
interface NowPlayingProps {
|
||||
current: MediaItem | null;
|
||||
queue: MediaItem[];
|
||||
}
|
||||
|
||||
export function NowPlaying({ current, queue }: NowPlayingProps) {
|
||||
if (!current) return null;
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-border bg-card shadow-sm">
|
||||
<div className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-primary/15 text-primary">
|
||||
{current.mode === "screen" ? <MonitorUp className="h-5 w-5" /> : <Music2 className="h-5 w-5" />}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-medium">{current.title}</div>
|
||||
<div className="truncate text-xs text-muted-foreground">{current.source}</div>
|
||||
</div>
|
||||
<Badge variant={current.mode === "screen" ? "warning" : "success"}>{current.mode ?? "music"}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{queue.length > 0 && (
|
||||
<div className="border-t border-border p-4">
|
||||
<div className="mb-2 text-sm font-medium">Queue ({queue.length})</div>
|
||||
<div className="space-y-1.5">
|
||||
{queue.map((item, i) => (
|
||||
<div key={`${item.source}-${i}`} className="flex items-center gap-3 rounded-lg border border-border bg-background/60 p-2.5 text-sm">
|
||||
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-medium text-muted-foreground">{i + 1}</span>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium">{item.title}</div>
|
||||
<div className="truncate text-xs text-muted-foreground">{item.source}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
// ─── Recordings Sub-Panel — BUG 1 FIX: useEffect instead of useMemo for side effects ──
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button, Badge, Skeleton } from "../../../shared/ui";
|
||||
import { Mic, Download } from "lucide-react";
|
||||
|
||||
interface VoiceRecording {
|
||||
id: string;
|
||||
user_id: string;
|
||||
username: string;
|
||||
avatar_url: string | null;
|
||||
guild_id: string | null;
|
||||
channel_id: string | null;
|
||||
channel_name: string | null;
|
||||
filename: string;
|
||||
size_bytes: number;
|
||||
download_url: string | null;
|
||||
upload_status: "pending" | "uploaded" | "failed";
|
||||
upload_error: string | null;
|
||||
created_at: number;
|
||||
uploaded_at: number | null;
|
||||
}
|
||||
|
||||
function formatDate(value: number): string {
|
||||
return new Date(value).toLocaleString();
|
||||
}
|
||||
|
||||
function formatBytes(value: number): string {
|
||||
if (value < 1024) return `${value} B`;
|
||||
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
|
||||
return `${(value / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
export function RecordingsSubPanel() {
|
||||
const [recordings, setRecordings] = useState<VoiceRecording[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// BUG 1 FIX: proper useEffect for async data fetching
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function loadRecordings() {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const response = await fetch("/api/recordings");
|
||||
if (!response.ok) throw new Error(`Failed to load recordings: ${response.status}`);
|
||||
const data = (await response.json()) as VoiceRecording[];
|
||||
if (!cancelled) setRecordings(data);
|
||||
} catch (err) {
|
||||
if (!cancelled) setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
}
|
||||
loadRecordings();
|
||||
const handler = () => loadRecordings();
|
||||
window.addEventListener("voice_recording_uploaded", handler);
|
||||
return () => { cancelled = true; window.removeEventListener("voice_recording_uploaded", handler); };
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="flex items-center gap-4 rounded-xl border border-border bg-background/60 p-4">
|
||||
<Skeleton className="h-10 w-10 rounded-xl" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-4 w-48" />
|
||||
<Skeleton className="h-3 w-64" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="rounded-xl border border-dashed border-destructive p-6 text-center text-sm text-destructive">
|
||||
{error}
|
||||
<div className="mt-2">
|
||||
<Button size="sm" variant="outline" onClick={() => window.location.reload()}>Retry</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (recordings.length === 0) {
|
||||
return <div className="rounded-xl border border-dashed border-border p-6 text-center text-sm text-muted-foreground">No recordings found.</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{recordings.map((rec) => (
|
||||
<div key={rec.id} className="flex items-center gap-4 rounded-xl border border-border bg-background/60 p-4">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-primary/15 text-primary">
|
||||
<Mic className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-medium">{rec.filename}</div>
|
||||
<div className="mt-0.5 flex flex-wrap items-center gap-x-2 text-xs text-muted-foreground">
|
||||
<span>{rec.username}</span>
|
||||
<span>·</span>
|
||||
<span>{rec.channel_name ?? rec.channel_id ?? "unknown"}</span>
|
||||
<span>·</span>
|
||||
<span>{formatDate(rec.created_at)}</span>
|
||||
<span>·</span>
|
||||
<span>{formatBytes(rec.size_bytes)}</span>
|
||||
</div>
|
||||
{rec.upload_error && <div className="mt-1 text-xs text-destructive">{rec.upload_error}</div>}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Badge variant={rec.upload_status === "uploaded" ? "success" : rec.upload_status === "failed" ? "destructive" : "secondary"}>
|
||||
{rec.upload_status}
|
||||
</Badge>
|
||||
{rec.download_url && (
|
||||
<a href={rec.download_url} target="_blank" rel="noreferrer" className="rounded-lg bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90">
|
||||
<Download className="h-4 w-4" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useState } from "react";
|
||||
import { Button, Input } from "../../../shared/ui";
|
||||
import { MonitorUp, SkipForward, Square } from "lucide-react";
|
||||
|
||||
interface ScreenSubPanelProps {
|
||||
onStart: (source: string) => void;
|
||||
onSkip: () => void;
|
||||
onStop: () => void;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export function ScreenSubPanel({ onStart, onSkip, onStop, loading }: ScreenSubPanelProps) {
|
||||
const [source, setSource] = useState("");
|
||||
const submit = () => { const t = source.trim(); if (!t) return; onStart(t); setSource(""); };
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
value={source}
|
||||
onChange={(e) => setSource(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && submit()}
|
||||
placeholder="Screen share URL or local file path"
|
||||
/>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button disabled={loading || !source.trim()} onClick={submit}>
|
||||
<MonitorUp className="mr-1.5 h-4 w-4" /> Start
|
||||
</Button>
|
||||
<Button variant="secondary" disabled={loading} onClick={onSkip}>
|
||||
<SkipForward className="mr-1.5 h-4 w-4" /> Skip
|
||||
</Button>
|
||||
<Button variant="destructive" disabled={loading} onClick={onStop}>
|
||||
<Square className="mr-1.5 h-4 w-4" /> Stop
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Button, Select } from "../../../shared/ui";
|
||||
import type { Channel, Guild, VoiceStatus } from "../../../shared/api/client";
|
||||
import { Radio, Headphones } from "lucide-react";
|
||||
|
||||
interface VoiceConnectionCardProps {
|
||||
guilds: Guild[];
|
||||
voiceChannels: Channel[];
|
||||
selectedGuild: string;
|
||||
selectedChannel: string;
|
||||
status: VoiceStatus;
|
||||
voiceLoading: boolean;
|
||||
isListening: boolean;
|
||||
isStreaming: boolean;
|
||||
onGuildChange: (id: string) => void;
|
||||
onChannelChange: (id: string) => void;
|
||||
onJoin: () => void;
|
||||
onDisconnect: () => void;
|
||||
onListenToggle: () => void;
|
||||
onStreamingToggle: () => void;
|
||||
}
|
||||
|
||||
export function VoiceConnectionCard({
|
||||
guilds, voiceChannels, selectedGuild, selectedChannel,
|
||||
status, voiceLoading, isListening, isStreaming,
|
||||
onGuildChange, onChannelChange, onJoin, onDisconnect,
|
||||
onListenToggle, onStreamingToggle,
|
||||
}: VoiceConnectionCardProps) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-border bg-card shadow-sm">
|
||||
<div className="p-6">
|
||||
<h3 className="flex items-center gap-2 text-lg font-semibold tracking-tight">
|
||||
<Radio className="h-5 w-5" /> Voice Bridge
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">Join a Discord voice channel, listen, and transmit audio.</p>
|
||||
|
||||
<div className="mt-4 grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Guild</label>
|
||||
<Select value={selectedGuild} onChange={(e) => onGuildChange(e.target.value)} placeholder="Select guild" options={guilds.map((g) => ({ value: g.id, label: g.name }))} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Voice Channel</label>
|
||||
<Select value={selectedChannel} onChange={(e) => onChannelChange(e.target.value)} placeholder="Select voice channel" options={voiceChannels.map((c) => ({ value: c.id, label: c.name }))} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<Button disabled={!selectedGuild || !selectedChannel || voiceLoading} onClick={onJoin}>
|
||||
{status.connected ? "Reconnect" : "Join Voice"}
|
||||
</Button>
|
||||
<Button variant="destructive" disabled={!status.connected || voiceLoading} onClick={onDisconnect}>Disconnect</Button>
|
||||
<Button variant={isListening ? "secondary" : "outline"} onClick={onListenToggle}>
|
||||
<Headphones className="mr-1.5 h-4 w-4" /> {isListening ? "Stop Listening" : "Listen"}
|
||||
</Button>
|
||||
<Button variant={isStreaming ? "destructive" : "default"} onClick={onStreamingToggle}>
|
||||
<Radio className="mr-1.5 h-4 w-4" /> {isStreaming ? "Stop Transmit" : "Transmit"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
// ─── Live feature barrel export ─────────────────────────────────────────────
|
||||
export { RecordingsSubPanel } from "./RecordingsSubPanel";
|
||||
export { MusicSubPanel } from "./MusicSubPanel";
|
||||
export { ScreenSubPanel } from "./ScreenSubPanel";
|
||||
export { AudioVisualizer } from "./AudioVisualizer";
|
||||
export { ActiveSpeakers } from "./ActiveSpeakers";
|
||||
export { VoiceConnectionCard } from "./VoiceConnectionCard";
|
||||
export { NowPlaying } from "./NowPlaying";
|
||||
+5
-26
@@ -1,19 +1,8 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
getMediaStatus,
|
||||
queueMedia,
|
||||
setMediaVolume,
|
||||
skipMedia,
|
||||
stopMedia,
|
||||
} from "../api/media";
|
||||
import type { MediaMode, MediaState } from "../types/media";
|
||||
import { getMediaStatus, queueMedia, setMediaVolume, skipMedia, stopMedia } from "../../../shared/api/client";
|
||||
import type { MediaState } from "../../../shared/api/client";
|
||||
|
||||
const emptyMediaState: MediaState = {
|
||||
playing: false,
|
||||
musicVolume: 1,
|
||||
current: null,
|
||||
queue: [],
|
||||
};
|
||||
const emptyMediaState: MediaState = { playing: false, musicVolume: 1, current: null, queue: [] };
|
||||
|
||||
export function useMediaControl() {
|
||||
const [mediaState, setMediaState] = useState<MediaState>(emptyMediaState);
|
||||
@@ -26,7 +15,7 @@ export function useMediaControl() {
|
||||
return state;
|
||||
}, []);
|
||||
|
||||
const enqueue = useCallback(async (source: string, mode: MediaMode) => {
|
||||
const enqueue = useCallback(async (source: string, mode: "music" | "screen") => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
@@ -83,15 +72,5 @@ export function useMediaControl() {
|
||||
refreshMedia().catch((err) => setError(err instanceof Error ? err.message : String(err)));
|
||||
}, [refreshMedia]);
|
||||
|
||||
return {
|
||||
mediaState,
|
||||
setMediaState,
|
||||
loading,
|
||||
error,
|
||||
refreshMedia,
|
||||
enqueue,
|
||||
skip,
|
||||
stop,
|
||||
setVolume,
|
||||
};
|
||||
return { mediaState, setMediaState, loading, error, refreshMedia, enqueue, skip, stop, setVolume };
|
||||
}
|
||||
+4
-10
@@ -6,8 +6,8 @@ import {
|
||||
getTextChannels,
|
||||
getVoiceChannels,
|
||||
getVoiceStatus,
|
||||
} from "../api/voice";
|
||||
import type { Channel, Guild, VoiceStatus } from "../types/voice";
|
||||
} from "../../../shared/api/client";
|
||||
import type { Channel, Guild, VoiceStatus } from "../../../shared/api/client";
|
||||
|
||||
export function useVoiceControl() {
|
||||
const [guilds, setGuilds] = useState<Guild[]>([]);
|
||||
@@ -31,20 +31,14 @@ export function useVoiceControl() {
|
||||
}, []);
|
||||
|
||||
const loadVoiceChannels = useCallback(async (guildId: string) => {
|
||||
if (!guildId) {
|
||||
setVoiceChannels([]);
|
||||
return [];
|
||||
}
|
||||
if (!guildId) { setVoiceChannels([]); return []; }
|
||||
const channels = await getVoiceChannels(guildId);
|
||||
setVoiceChannels(channels);
|
||||
return channels;
|
||||
}, []);
|
||||
|
||||
const loadTextTargets = useCallback(async (guildId: string) => {
|
||||
if (!guildId) {
|
||||
setTextChannels([]);
|
||||
return [];
|
||||
}
|
||||
if (!guildId) { setTextChannels([]); return []; }
|
||||
const channels = await getTextChannels(guildId);
|
||||
setTextChannels(channels);
|
||||
return channels;
|
||||
@@ -0,0 +1,113 @@
|
||||
// ─── Live Panel — thin composition layer ────────────────────────────────────
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger, Card, CardContent, CardHeader, CardTitle } from "../../shared/ui";
|
||||
import type { ActiveSpeaker, Channel, Guild, VoiceStatus } from "../../shared/api/client";
|
||||
import type { MediaState } from "../../shared/api/client";
|
||||
import { Music2, MonitorUp, Mic } from "lucide-react";
|
||||
import { AudioVisualizer } from "./components/AudioVisualizer";
|
||||
import { ActiveSpeakers } from "./components/ActiveSpeakers";
|
||||
import { VoiceConnectionCard } from "./components/VoiceConnectionCard";
|
||||
import { NowPlaying } from "./components/NowPlaying";
|
||||
import { MusicSubPanel } from "./components/MusicSubPanel";
|
||||
import { ScreenSubPanel } from "./components/ScreenSubPanel";
|
||||
import { RecordingsSubPanel } from "./components/RecordingsSubPanel";
|
||||
|
||||
interface LivePanelProps {
|
||||
guilds: Guild[];
|
||||
voiceChannels: Channel[];
|
||||
selectedGuild: string;
|
||||
selectedChannel: string;
|
||||
status: VoiceStatus;
|
||||
voiceLoading: boolean;
|
||||
activeSpeakers: ActiveSpeaker[];
|
||||
levels: number[];
|
||||
isListening: boolean;
|
||||
isStreaming: boolean;
|
||||
mediaState: MediaState;
|
||||
mediaLoading: boolean;
|
||||
onGuildChange: (id: string) => void;
|
||||
onChannelChange: (id: string) => void;
|
||||
onJoin: () => void;
|
||||
onDisconnect: () => void;
|
||||
onListenToggle: () => void;
|
||||
onStreamingToggle: () => void;
|
||||
onQueueMusic: (source: string) => void;
|
||||
onStartScreen: (source: string) => void;
|
||||
onSkip: () => void;
|
||||
onStop: () => void;
|
||||
onVolumeChange: (v: number) => void;
|
||||
}
|
||||
|
||||
export function LivePanel({
|
||||
guilds, voiceChannels, selectedGuild, selectedChannel,
|
||||
status, voiceLoading, activeSpeakers, levels, isListening, isStreaming,
|
||||
mediaState, mediaLoading,
|
||||
onGuildChange, onChannelChange, onJoin, onDisconnect,
|
||||
onListenToggle, onStreamingToggle,
|
||||
onQueueMusic, onStartScreen, onSkip, onStop, onVolumeChange,
|
||||
}: LivePanelProps) {
|
||||
return (
|
||||
<div className="grid gap-6">
|
||||
<VoiceConnectionCard
|
||||
guilds={guilds}
|
||||
voiceChannels={voiceChannels}
|
||||
selectedGuild={selectedGuild}
|
||||
selectedChannel={selectedChannel}
|
||||
status={status}
|
||||
voiceLoading={voiceLoading}
|
||||
isListening={isListening}
|
||||
isStreaming={isStreaming}
|
||||
onGuildChange={onGuildChange}
|
||||
onChannelChange={onChannelChange}
|
||||
onJoin={onJoin}
|
||||
onDisconnect={onDisconnect}
|
||||
onListenToggle={onListenToggle}
|
||||
onStreamingToggle={onStreamingToggle}
|
||||
/>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[1fr_320px]">
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">Live Audio</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<AudioVisualizer levels={levels} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">Active Speakers</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ActiveSpeakers speakers={activeSpeakers} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<NowPlaying current={mediaState.current} queue={mediaState.queue} />
|
||||
|
||||
<Tabs defaultValue="music">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="music"><Music2 className="mr-1.5 h-4 w-4" /> Music</TabsTrigger>
|
||||
<TabsTrigger value="screen"><MonitorUp className="mr-1.5 h-4 w-4" /> Screen Share</TabsTrigger>
|
||||
<TabsTrigger value="recordings"><Mic className="mr-1.5 h-4 w-4" /> Recordings</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="music">
|
||||
<MusicSubPanel
|
||||
volume={mediaState.musicVolume}
|
||||
onVolumeChange={onVolumeChange}
|
||||
onQueue={onQueueMusic}
|
||||
onSkip={onSkip}
|
||||
onStop={onStop}
|
||||
loading={mediaLoading}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="screen">
|
||||
<ScreenSubPanel onStart={onStartScreen} onSkip={onSkip} onStop={onStop} loading={mediaLoading} />
|
||||
</TabsContent>
|
||||
<TabsContent value="recordings">
|
||||
<RecordingsSubPanel />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user