diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..4982420 --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c0c97e1..7c0692b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 ( -
- Analytics failed to load. The rest of the dashboard is still available. -
- ); + return
Analytics failed to load. The rest of the dashboard is still available.
; } - 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([]); - const [levels, setLevels] = useState(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(null); - const audioContextTransmitRef = useRef(null); - const streamRef = useRef(null); - const processorRef = useRef(null); - const userTimelinesRef = useRef(new Map()); + 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 & { 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 ( - patchUIState({ activeTab: tab })} - > -
-
- {tabs.map((tab) => ( - - ))} -
-
+ patchUIState({ activeTab: tab })}> {activeTab === "live" ? ( !isAuthenticated ? ( setIsAuthenticated(true)} /> ) : ( 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" ? ( patchUIState({ selectedTextGuild: guildId, selectedTextChannel: "" })} - onChannelChange={(channelId) => patchUIState({ selectedTextChannel: channelId })} + onGuildChange={(id) => patchUIState({ selectedTextGuild: id, selectedTextChannel: "" })} + onChannelChange={(id) => patchUIState({ selectedTextChannel: id })} onReanalyze={messages.reanalyze} /> ) : ( - - Loading analytics... - - } - > + {Array.from({ length: 8 }).map((_, i) => )}}> 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 })} /> )} + patchUIState({ activeTab: tab })} /> ); } diff --git a/frontend/src/api/analytics.ts b/frontend/src/api/analytics.ts deleted file mode 100644 index 6b807fe..0000000 --- a/frontend/src/api/analytics.ts +++ /dev/null @@ -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 { - const searchParams = new URLSearchParams({ - guildId: params.guildId, - ...(params.channelId && { channelId: params.channelId }), - ...(params.hours && { hours: String(params.hours) }), - }); - return request(`/api/analytics/overview?${searchParams}`); -} - -export async function fetchHourlyStats(params: { - guildId: string; - channelId?: string; - hours?: number; -}): Promise { - const searchParams = new URLSearchParams({ - guildId: params.guildId, - ...(params.channelId && { channelId: params.channelId }), - ...(params.hours && { hours: String(params.hours) }), - }); - return request(`/api/analytics/hourly?${searchParams}`); -} - -export async function fetchTopicTrends(params: { - guildId: string; - channelId?: string; - hours?: number; -}): Promise { - const searchParams = new URLSearchParams({ - guildId: params.guildId, - ...(params.channelId && { channelId: params.channelId }), - ...(params.hours && { hours: String(params.hours) }), - }); - return request(`/api/analytics/topics?${searchParams}`); -} - -export async function fetchLeaderboard(params: { - guildId: string; - channelId?: string; - hours?: number; - limit?: number; -}): Promise { - 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(`/api/analytics/leaderboard?${searchParams}`); -} - -export async function fetchModerationStats(params: { - guildId: string; - channelId?: string; - hours?: number; -}): Promise { - const searchParams = new URLSearchParams({ - guildId: params.guildId, - ...(params.channelId && { channelId: params.channelId }), - ...(params.hours && { hours: String(params.hours) }), - }); - return request(`/api/analytics/stats?${searchParams}`); -} - -export async function fetchViolators(params: { - guildId: string; - channelId?: string; - hours?: number; - limit?: number; -}): Promise { - 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(`/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 { - const searchParams = new URLSearchParams({ - guildId: params.guildId, - ...(params.channelId && { channelId: params.channelId }), - ...(params.hours && { hours: String(params.hours) }), - }); - return request(`/api/analytics/trend?${searchParams}`); -} - -export async function fetchHeatmap(params: { - guildId: string; - channelId?: string; - hours?: number; -}): Promise { - const searchParams = new URLSearchParams({ - guildId: params.guildId, - ...(params.channelId && { channelId: params.channelId }), - ...(params.hours && { hours: String(params.hours) }), - }); - return request(`/api/analytics/heatmap?${searchParams}`); -} diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts deleted file mode 100644 index 7f1d1f2..0000000 --- a/frontend/src/api/auth.ts +++ /dev/null @@ -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 }), - }); -} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts deleted file mode 100644 index 9ffe159..0000000 --- a/frontend/src/api/client.ts +++ /dev/null @@ -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 { - 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(path: string, init?: RequestInit): Promise { - 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; -} - -export async function listMessages( - params: URLSearchParams, -): Promise> { - return request>(`/api/messages?${params}`); -} - -export async function listReview( - params: URLSearchParams, -): Promise> { - return request>(`/api/review?${params}`); -} - -export async function reanalyzeMessage(id: string): Promise { - await request(`/api/messages/${id}/reanalyze`, { method: "POST" }); -} - -export async function getGuilds(): Promise { - return request("/api/guilds"); -} - -export async function getAppConfig(): Promise { - return request("/api/config"); -} diff --git a/frontend/src/api/media.ts b/frontend/src/api/media.ts deleted file mode 100644 index 931b21b..0000000 --- a/frontend/src/api/media.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { request } from "./client"; -import type { MediaMode, MediaState } from "../types/media"; - -export function getMediaStatus(): Promise { - return request('/api/media/status'); -} - -export function queueMedia(source: string, mode: MediaMode): Promise { - return request('/api/media/queue', { - method: 'POST', - body: JSON.stringify({ source, mode }), - }); -} - -export function skipMedia(): Promise { - return request('/api/media/skip', { method: 'POST' }); -} - -export function stopMedia(): Promise { - return request('/api/media/stop', { method: 'POST' }); -} - -export function setMediaVolume(volume: number): Promise { - return request('/api/media/volume', { - method: 'POST', - body: JSON.stringify({ volume }), - }); -} diff --git a/frontend/src/api/messages.ts b/frontend/src/api/messages.ts deleted file mode 100644 index 6ab1fac..0000000 --- a/frontend/src/api/messages.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { listMessages, listReview, reanalyzeMessage } from "./client"; - -export { listMessages, listReview, reanalyzeMessage }; diff --git a/frontend/src/api/uiState.ts b/frontend/src/api/uiState.ts deleted file mode 100644 index b955533..0000000 --- a/frontend/src/api/uiState.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { request } from "./client"; -import type { UIState } from "../types/ui"; - -export function getUIState(): Promise { - return request('/api/ui-state'); -} - -export function updateUIState(patch: Partial): Promise { - return request('/api/ui-state', { - method: 'POST', - body: JSON.stringify(patch), - }); -} diff --git a/frontend/src/api/voice.ts b/frontend/src/api/voice.ts deleted file mode 100644 index 2611262..0000000 --- a/frontend/src/api/voice.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { request } from "./client"; -import type { Channel, Guild, VoiceStatus } from "../types/voice"; - -export function getGuilds(): Promise { - return request('/api/guilds'); -} - -export function getVoiceChannels(guildId: string): Promise { - return request(`/api/guilds/${guildId}/voice-channels`); -} - -export function getTextChannels(guildId: string): Promise { - return request(`/api/guilds/${guildId}/channels`); -} - -export function getVoiceStatus(): Promise { - return request('/api/status'); -} - -export function connectVoice(guildId: string, channelId: string): Promise { - return request('/api/connect', { - method: 'POST', - body: JSON.stringify({ guildId, channelId }), - }); -} - -export function disconnectVoice(): Promise { - return request('/api/disconnect', { method: 'POST' }); -} diff --git a/frontend/src/components/analytics/index.tsx b/frontend/src/components/analytics/index.tsx deleted file mode 100644 index 2404b64..0000000 --- a/frontend/src/components/analytics/index.tsx +++ /dev/null @@ -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 ( -
- {error} -
- ); - } - - if (!selectedGuild) { - return ( -
-

Pilih guild untuk melihat analitik.

-
- ); - } - - return ( -
- {/* Control bar */} - { refresh(); refreshViolators(); }} - /> - - {/* Summary cards */} - - - {/* Hourly chart */} -
- -
- -
-
- - {/* Trend chart — only show when enough data */} - {hours >= 48 && ( - - )} - - {/* Heatmap + leaderboard */} -
- -
- -
-
- - {/* Violators */} - -
- ); -} diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx deleted file mode 100644 index 1cf1d84..0000000 --- a/frontend/src/components/layout/Sidebar.tsx +++ /dev/null @@ -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 ( - - ); -} diff --git a/frontend/src/components/live/LivePanel.tsx b/frontend/src/components/live/LivePanel.tsx deleted file mode 100644 index 2c7e184..0000000 --- a/frontend/src/components/live/LivePanel.tsx +++ /dev/null @@ -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([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(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
Loading recordings...
; - if (error) return
{error}
; - if (recordings.length === 0) return
No recordings found.
; - - return ( -
- {recordings.map((rec) => ( -
-
- -
-
-
{rec.filename}
-
- {rec.username} - · - {rec.channel_name ?? rec.channel_id ?? "unknown"} - · - {formatDate(rec.created_at)} - · - {formatBytes(rec.size_bytes)} -
- {rec.upload_error &&
{rec.upload_error}
} -
-
- - {rec.upload_status} - - {rec.download_url && ( - - - - )} -
-
- ))} -
- ); -} - -// ─── 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 ( -
- setSource(e.target.value)} onKeyDown={(e) => e.key === "Enter" && submit()} placeholder="YouTube URL, Spotify track, or search terms" /> -
- - setDraftVolume(Number(e.target.value))} className="h-2 w-full cursor-pointer accent-primary" /> - {draftVolume}% -
-
- - - -
-
- ); -} - -// ─── 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 ( -
- setSource(e.target.value)} onKeyDown={(e) => e.key === "Enter" && submit()} placeholder="Screen share URL or local file path" /> -
- - - -
-
- ); -} - -// ─── 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 ( -
- {/* Voice Connection Controls */} - - - Voice Bridge - Join a Discord voice channel, listen, and transmit audio. - - -
-
- - onChannelChange(e.target.value)} placeholder="Select voice channel" options={voiceChannels.map((c) => ({ value: c.id, label: c.name }))} /> -
-
-
- - - - -
-
-
- - {/* Audio Visualizer + Active Speakers */} -
- - - Live Audio - - - - - - - - Active Speakers - - - {activeSpeakers.length === 0 ? ( -
No active speakers.
- ) : ( -
- {activeSpeakers.map((s, i) => ( -
- -
-
{s.username}
-
Speaking
-
-
- ))} -
- )} -
-
-
- - {/* Now Playing / Queue */} - {mediaState.current && ( - - - - {mediaState.current.mode === "screen" ? : } - Now Playing - - - -
-
-
{mediaState.current.title}
-
{mediaState.current.source}
-
- {mediaState.current.mode || "music"} -
- {mediaState.queue.length > 0 && ( -
-
Queue ({mediaState.queue.length})
- {mediaState.queue.map((item, i) => ( -
- {i + 1} -
-
{item.title}
-
{item.source}
-
-
- ))} -
- )} -
-
- )} - - {/* Music + Screen Share + Recordings tabs */} - - - Music - Screen Share - Recordings - - - - - - - - - - - -
- ); -} diff --git a/frontend/src/components/media/MediaPanel.tsx b/frontend/src/components/media/MediaPanel.tsx deleted file mode 100644 index 2acefb9..0000000 --- a/frontend/src/components/media/MediaPanel.tsx +++ /dev/null @@ -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 ( -
- - - Music - Screen Share - - - - - - - - - -
- ); -} diff --git a/frontend/src/components/media/MediaQueue.tsx b/frontend/src/components/media/MediaQueue.tsx deleted file mode 100644 index 2f68a0f..0000000 --- a/frontend/src/components/media/MediaQueue.tsx +++ /dev/null @@ -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 ( - - - Now Playing - Current item and queue state. - - - {state.current ? ( -
-
-
-
{state.current.title}
-
{state.current.source}
-
- {state.current.mode || "music"} -
-
- ) : ( -
No media playing.
- )} -
-
Queue
- {state.queue.length === 0 ? ( -
Queue is empty.
- ) : ( - state.queue.map((item, index) => ( -
-
{item.title}
-
{item.source}
-
- )) - )} -
-
-
- ); -} diff --git a/frontend/src/components/media/MusicPlayer.tsx b/frontend/src/components/media/MusicPlayer.tsx deleted file mode 100644 index 981aad8..0000000 --- a/frontend/src/components/media/MusicPlayer.tsx +++ /dev/null @@ -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 ( - - - Music Player - Play YouTube, Spotify tracks, search terms, or local files as audio. - - - setSource(event.target.value)} - onKeyDown={(event) => event.key === "Enter" && submit()} - placeholder="YouTube URL, Spotify track, or search terms" - /> -
-
- Volume - {draftVolume}% -
- setDraftVolume(Number(event.target.value))} - className="h-2 w-full cursor-pointer accent-primary" - /> -
-
- - - -
-
-
- ); -} diff --git a/frontend/src/components/media/ScreenShare.tsx b/frontend/src/components/media/ScreenShare.tsx deleted file mode 100644 index 2849d26..0000000 --- a/frontend/src/components/media/ScreenShare.tsx +++ /dev/null @@ -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 ( - - - Screen Share - Start screen-share playback from a URL or local file path. - - - setSource(event.target.value)} - onKeyDown={(event) => event.key === "Enter" && submit()} - placeholder="Screen share URL or local file path" - /> -
- - - -
-
-
- ); -} diff --git a/frontend/src/components/messages/ImageGrid.tsx b/frontend/src/components/messages/ImageGrid.tsx deleted file mode 100644 index 7215965..0000000 --- a/frontend/src/components/messages/ImageGrid.tsx +++ /dev/null @@ -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
No images found.
; - } - - return ( - - ); -} diff --git a/frontend/src/components/recordings/RecordingsPanel.tsx b/frontend/src/components/recordings/RecordingsPanel.tsx deleted file mode 100644 index 932050f..0000000 --- a/frontend/src/components/recordings/RecordingsPanel.tsx +++ /dev/null @@ -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([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(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 ( - - - Voice Recordings - - - {loading ? ( -
- Loading recordings... -
- ) : error ? ( -
- {error} -
- ) : recordings.length === 0 ? ( -
- No recordings found. -
- ) : ( -
- {recordings.map((recording) => ( -
-
-
-
{recording.filename}
-
- {recording.username} · {recording.channel_name ?? recording.channel_id ?? "unknown channel"} · {formatDate(recording.created_at)} -
-
- {formatBytes(recording.size_bytes)} · {recording.upload_status} -
- {recording.upload_error ? ( -
- {recording.upload_error} -
- ) : null} -
- {recording.download_url ? ( - - Download - - ) : null} -
-
- ))} -
- )} -
-
- ); -} diff --git a/frontend/src/components/review/ReviewPanel.tsx b/frontend/src/components/review/ReviewPanel.tsx deleted file mode 100644 index ce8d2f6..0000000 --- a/frontend/src/components/review/ReviewPanel.tsx +++ /dev/null @@ -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(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 ( -
-
- AI Eval Decision - {savedStatus ? saved: {savedStatus} : null} -
-
- setReviewerId(event.target.value)} - placeholder="reviewer label" - /> - setNotes(event.target.value)} - placeholder="reason / evaluation note" - /> -
-
- - - -
- {error ?
{error}
: null} -
- ); -} - -export function ReviewPanel({ messages, onReanalyze }: ReviewPanelProps) { - const [statusFilter, setStatusFilter] = useState("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(); - 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) => ( -
- {items.length === 0 ? ( -
- {emptyText} -
- ) : ( - items.map((message) => ( -
- - -
- )) - )} -
- ); - - return ( - - - Moderation Review & AI Eval - - Public AI evaluation queue: {reviewable.length} reviewable messages, {errorItems.length} analysis errors. - - - -
- setSeverityFilter(event.target.value)} - placeholder="All severities" - options={["none", "low", "medium", "high", "critical"].map((severity) => ({ value: severity, label: severity }))} - /> - onGuildChange(event.target.value)} - placeholder="Select guild" - options={guilds.map((guild) => ({ value: guild.id, label: guild.name }))} - /> -
-
- - setSource(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && submit()} + placeholder="YouTube URL, Spotify track, or search terms" + /> +
+ + setDraftVolume(Number(e.target.value))} + className="h-2 w-full cursor-pointer accent-primary" + /> + {draftVolume}% +
+
+ + + +
+
+ ); +} diff --git a/frontend/src/features/live/components/NowPlaying.tsx b/frontend/src/features/live/components/NowPlaying.tsx new file mode 100644 index 0000000..cb1570e --- /dev/null +++ b/frontend/src/features/live/components/NowPlaying.tsx @@ -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 ( +
+
+
+
+ {current.mode === "screen" ? : } +
+
+
{current.title}
+
{current.source}
+
+ {current.mode ?? "music"} +
+
+ + {queue.length > 0 && ( +
+
Queue ({queue.length})
+
+ {queue.map((item, i) => ( +
+ {i + 1} +
+
{item.title}
+
{item.source}
+
+
+ ))} +
+
+ )} +
+ ); +} diff --git a/frontend/src/features/live/components/RecordingsSubPanel.tsx b/frontend/src/features/live/components/RecordingsSubPanel.tsx new file mode 100644 index 0000000..32d5bc4 --- /dev/null +++ b/frontend/src/features/live/components/RecordingsSubPanel.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( +
+ {[1, 2, 3].map((i) => ( +
+ +
+ + +
+
+ ))} +
+ ); + } + + if (error) { + return ( +
+ {error} +
+ +
+
+ ); + } + + if (recordings.length === 0) { + return
No recordings found.
; + } + + return ( +
+ {recordings.map((rec) => ( +
+
+ +
+
+
{rec.filename}
+
+ {rec.username} + · + {rec.channel_name ?? rec.channel_id ?? "unknown"} + · + {formatDate(rec.created_at)} + · + {formatBytes(rec.size_bytes)} +
+ {rec.upload_error &&
{rec.upload_error}
} +
+
+ + {rec.upload_status} + + {rec.download_url && ( + + + + )} +
+
+ ))} +
+ ); +} diff --git a/frontend/src/features/live/components/ScreenSubPanel.tsx b/frontend/src/features/live/components/ScreenSubPanel.tsx new file mode 100644 index 0000000..d7e3bdd --- /dev/null +++ b/frontend/src/features/live/components/ScreenSubPanel.tsx @@ -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 ( +
+ setSource(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && submit()} + placeholder="Screen share URL or local file path" + /> +
+ + + +
+
+ ); +} diff --git a/frontend/src/features/live/components/VoiceConnectionCard.tsx b/frontend/src/features/live/components/VoiceConnectionCard.tsx new file mode 100644 index 0000000..84c98fa --- /dev/null +++ b/frontend/src/features/live/components/VoiceConnectionCard.tsx @@ -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 ( +
+
+

+ Voice Bridge +

+

Join a Discord voice channel, listen, and transmit audio.

+ +
+
+ + onChannelChange(e.target.value)} placeholder="Select voice channel" options={voiceChannels.map((c) => ({ value: c.id, label: c.name }))} /> +
+
+ +
+ + + + +
+
+
+ ); +} diff --git a/frontend/src/features/live/components/index.ts b/frontend/src/features/live/components/index.ts new file mode 100644 index 0000000..c455de7 --- /dev/null +++ b/frontend/src/features/live/components/index.ts @@ -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"; diff --git a/frontend/src/hooks/useMediaControl.ts b/frontend/src/features/live/hooks/useMediaControl.ts similarity index 77% rename from frontend/src/hooks/useMediaControl.ts rename to frontend/src/features/live/hooks/useMediaControl.ts index 4dff457..f9ed28f 100644 --- a/frontend/src/hooks/useMediaControl.ts +++ b/frontend/src/features/live/hooks/useMediaControl.ts @@ -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(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 }; } diff --git a/frontend/src/hooks/useVoiceControl.ts b/frontend/src/features/live/hooks/useVoiceControl.ts similarity index 91% rename from frontend/src/hooks/useVoiceControl.ts rename to frontend/src/features/live/hooks/useVoiceControl.ts index dd7438a..dce49d5 100644 --- a/frontend/src/hooks/useVoiceControl.ts +++ b/frontend/src/features/live/hooks/useVoiceControl.ts @@ -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([]); @@ -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; diff --git a/frontend/src/features/live/index.tsx b/frontend/src/features/live/index.tsx new file mode 100644 index 0000000..c4e5c80 --- /dev/null +++ b/frontend/src/features/live/index.tsx @@ -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 ( +
+ + +
+ + + Live Audio + + + + + + + + Active Speakers + + + + + +
+ + + + + + Music + Screen Share + Recordings + + + + + + + + + + + +
+ ); +} diff --git a/frontend/src/features/messages/components/ImageGrid.tsx b/frontend/src/features/messages/components/ImageGrid.tsx new file mode 100644 index 0000000..674890d --- /dev/null +++ b/frontend/src/features/messages/components/ImageGrid.tsx @@ -0,0 +1,104 @@ +import type { MessageRecord } from "../../../shared/api/client"; + +interface MessageMetadata { + stickers?: Array<{ name?: string; url?: string }>; + attachments?: Array<{ name: string; url: string; contentType?: string }>; + embeds?: Array<{ title?: string; image?: string; thumbnail?: string }>; +} + +interface ImageItem { + url: string; + title: string; + kind: "attachment" | "embed" | "sticker"; + message: MessageRecord; +} + +function parseMetadata(value: string | null): MessageMetadata { + if (!value) return {}; + try { return JSON.parse(value) as MessageMetadata; } catch { return {}; } +} + +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
No images found.
; + } + + return ( +
+ {images.map((image, index) => { + // Stable key using message.id + url + const stableKey = `${image.message.id}-${image.kind}-${index}`; + return ( + +
+ {image.kind === "sticker" ? ( + {image.title} + ) : ( + {image.title} + )} +
+ {image.kind} +
+
+
+
{image.title}
+
+
+ +
+ {image.message.username} +
+
+
+ ); + })} +
+ ); +} diff --git a/frontend/src/components/messages/MessageCard.tsx b/frontend/src/features/messages/components/MessageCard.tsx similarity index 77% rename from frontend/src/components/messages/MessageCard.tsx rename to frontend/src/features/messages/components/MessageCard.tsx index 621eef2..3173f1b 100644 --- a/frontend/src/components/messages/MessageCard.tsx +++ b/frontend/src/features/messages/components/MessageCard.tsx @@ -1,21 +1,22 @@ -import { RotateCw, AlertCircle, CheckCircle2, AlertTriangle, Trash2, Pencil, Image as ImageIcon, Smile } from "lucide-react"; -import type { MessageMetadata, MessageRecord } from "../../types/messages"; -import { Badge } from "../ui/badge"; -import { Button } from "../ui/button"; import { useState, useMemo } from "react"; +import type { MessageRecord } from "../../../shared/api/client"; +import { Badge, Button, Skeleton } from "../../../shared/ui"; +import { RotateCw, AlertCircle, CheckCircle2, AlertTriangle, Trash2, Pencil, Image as ImageIcon, Smile } from "lucide-react"; -export interface MessageCardProps { +interface MessageCardProps { message: MessageRecord; - onReanalyze: (id: string) => void; + onReanalyze: (id: string) => Promise; +} + +interface MessageMetadata { + stickers?: Array<{ name?: string; url?: string }>; + attachments?: Array<{ name: string; url: string; contentType?: string }>; + embeds?: Array<{ title?: string; image?: string; thumbnail?: string }>; } function parseMetadata(value: string | null): MessageMetadata { if (!value) return {}; - try { - return JSON.parse(value) as MessageMetadata; - } catch { - return {}; - } + try { return JSON.parse(value) as MessageMetadata; } catch { return {}; } } function parseStringList(value?: string | null): string[] { @@ -24,10 +25,7 @@ function parseStringList(value?: string | null): string[] { 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); + return value.split(",").map((item) => item.trim()).filter(Boolean); } } @@ -38,14 +36,6 @@ function aiVariant(status: string) { return "secondary"; } -function getAiIcon(status: string) { - if (status === "clean") return ; - if (status === "warn") return ; - if (status === "flagged") return ; - if (status === "error") return ; - return null; -} - function severityColor(severity: string) { switch (severity) { case "critical": return "bg-red-500/20 text-red-300 border-red-500/30"; @@ -85,7 +75,7 @@ export function MessageCard({ message, onReanalyze }: MessageCardProps) { const handleReanalyze = async () => { setIsReanalyzing(true); try { - onReanalyze(message.id); + await onReanalyze(message.id); } finally { setIsReanalyzing(false); } @@ -100,7 +90,6 @@ export function MessageCard({ message, onReanalyze }: MessageCardProps) { className="h-10 w-10 shrink-0 rounded-full object-cover ring-1 ring-border" />
- {/* Header row */}
{message.username || message.user_id} @@ -118,7 +107,10 @@ export function MessageCard({ message, onReanalyze }: MessageCardProps) { )}
- {getAiIcon(aiStatus)} + {aiStatus === "clean" && } + {aiStatus === "warn" && } + {aiStatus === "flagged" && } + {aiStatus === "error" && } {aiStatus} {message.ai_severity && message.ai_severity !== "none" && ( @@ -134,25 +126,18 @@ export function MessageCard({ message, onReanalyze }: MessageCardProps) {
- {/* Content */} {displayContent ? (

{displayContent}

) : null} - {/* Sticker preview */} {stickers.length > 0 && (
{stickers.map((sticker) => (
{sticker.url ? ( - {sticker.name + {sticker.name ) : (
@@ -166,23 +151,11 @@ export function MessageCard({ message, onReanalyze }: MessageCardProps) {
)} - {/* Image thumbnails */} {hasImages && (
{imageAttachments.slice(0, 4).map((img) => ( - - {img.name} + + {img.name} ))} {imageAttachments.length > 4 && ( @@ -193,7 +166,6 @@ export function MessageCard({ message, onReanalyze }: MessageCardProps) {
)} - {/* Categories / flags */} {categories.length > 0 && (
{categories.map((category) => ( @@ -202,21 +174,18 @@ export function MessageCard({ message, onReanalyze }: MessageCardProps) {
)} - {/* AI analysis text */} {message.ai_analysis ? (
{message.ai_analysis}
) : null} - {/* AI error */} {message.ai_error ? (
AI error: {message.ai_error}
) : null} - {/* Actions */}
@@ -239,3 +206,22 @@ export function MessageCard({ message, onReanalyze }: MessageCardProps) { ); } + +export function MessageCardSkeleton() { + return ( +
+
+ +
+ + + +
+ + +
+
+
+
+ ); +} diff --git a/frontend/src/components/messages/MessageFeed.tsx b/frontend/src/features/messages/components/MessageFeed.tsx similarity index 52% rename from frontend/src/components/messages/MessageFeed.tsx rename to frontend/src/features/messages/components/MessageFeed.tsx index 41fdb38..b02c010 100644 --- a/frontend/src/components/messages/MessageFeed.tsx +++ b/frontend/src/features/messages/components/MessageFeed.tsx @@ -1,14 +1,25 @@ -import type { MessageRecord } from "../../types/messages"; -import { ScrollArea } from "../ui/scroll-area"; -import { MessageCard } from "./MessageCard"; +import { ScrollArea } from "../../../shared/ui"; +import type { MessageRecord } from "../../../shared/api/client"; +import { MessageCard, MessageCardSkeleton } from "./MessageCard"; export interface MessageFeedProps { messages: MessageRecord[]; - onReanalyze: (id: string) => void; + onReanalyze: (id: string) => Promise; emptyText?: string; + loading?: boolean; } -export function MessageFeed({ messages, onReanalyze, emptyText = "No messages found." }: MessageFeedProps) { +export function MessageFeed({ messages, onReanalyze, emptyText = "No messages found.", loading }: MessageFeedProps) { + if (loading) { + return ( + +
+ {[1, 2, 3, 4, 5].map((i) => )} +
+
+ ); + } + if (messages.length === 0) { return
{emptyText}
; } diff --git a/frontend/src/hooks/useMessages.ts b/frontend/src/features/messages/hooks/useMessages.ts similarity index 77% rename from frontend/src/hooks/useMessages.ts rename to frontend/src/features/messages/hooks/useMessages.ts index efdbea7..f0c8861 100644 --- a/frontend/src/hooks/useMessages.ts +++ b/frontend/src/features/messages/hooks/useMessages.ts @@ -1,6 +1,6 @@ -import { useCallback, useEffect, useState } from "react"; -import { listMessages, reanalyzeMessage } from "../api/messages"; -import type { MessageRecord } from "../types/messages"; +import { useCallback, useState } from "react"; +import { listMessages, reanalyzeMessage } from "../../../shared/api/client"; +import type { MessageRecord } from "../../../shared/api/client"; export function mergeMessages(current: MessageRecord[], incoming: MessageRecord[]): MessageRecord[] { const byId = new Map(current.map((message) => [message.id, message])); @@ -39,20 +39,17 @@ export function useMessages() { } }, []); - const reanalyze = useCallback(async (id: string) => { + // BUG 5 FIX: reanalyze returns Promise so callers can await it + const reanalyze = useCallback(async (id: string): Promise => { setMessages((prev) => prev.map((message) => message.id === id - ? { ...message, ai_status: "pending", ai_error: null, ai_analysis: null } + ? { ...message, ai_status: "pending" as const, ai_error: null, ai_analysis: null } : message, ), ); await reanalyzeMessage(id); }, []); - useEffect(() => { - fetchMessages().catch(() => undefined); - }, [fetchMessages]); - return { messages, setMessages, loading, error, fetchMessages, reanalyze }; } diff --git a/frontend/src/components/messages/MessagesPanel.tsx b/frontend/src/features/messages/index.tsx similarity index 62% rename from frontend/src/components/messages/MessagesPanel.tsx rename to frontend/src/features/messages/index.tsx index 603d1fd..2bd73f2 100644 --- a/frontend/src/components/messages/MessagesPanel.tsx +++ b/frontend/src/features/messages/index.tsx @@ -1,14 +1,8 @@ import { useState, useMemo } from "react"; -import type { Channel, Guild } from "../../types/voice"; -import type { MessageRecord } from "../../types/messages"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card"; -import { Select } from "../ui/select"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs"; -import { ImageGrid } from "./ImageGrid"; -import { MessageFeed } from "./MessageFeed"; -import { Input } from "../ui/input"; -import { Button } from "../ui/button"; -import { Badge } from "../ui/badge"; +import type { Channel, Guild, MessageRecord } from "../../shared/api/client"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle, Badge, Button, Input, Select, Tabs, TabsContent, TabsList, TabsTrigger } from "../../shared/ui"; +import { MessageFeed } from "./components/MessageFeed"; +import { ImageGrid } from "./components/ImageGrid"; import { Search, X, Filter } from "lucide-react"; interface MessagesPanelProps { @@ -19,20 +13,14 @@ interface MessagesPanelProps { messages: MessageRecord[]; onGuildChange: (guildId: string) => void; onChannelChange: (channelId: string) => void; - onReanalyze: (id: string) => void; + onReanalyze: (id: string) => Promise; } type AiFilter = "all" | "clean" | "warn" | "flagged" | "error" | "pending"; export function MessagesPanel({ - guilds, - channels, - selectedGuild, - selectedChannel, - messages, - onGuildChange, - onChannelChange, - onReanalyze, + guilds, channels, selectedGuild, selectedChannel, + messages, onGuildChange, onChannelChange, onReanalyze, }: MessagesPanelProps) { const [searchQuery, setSearchQuery] = useState(""); const [searchResults, setSearchResults] = useState([]); @@ -42,28 +30,16 @@ export function MessagesPanel({ const [viewTab, setViewTab] = useState<"all" | "images">("all"); const handleSearch = async () => { - if (!searchQuery.trim()) { - setSearchResults([]); - setShowSearch(false); - return; - } - + if (!searchQuery.trim()) { setSearchResults([]); setShowSearch(false); return; } setIsSearching(true); try { - const params = new URLSearchParams({ - q: searchQuery, - ...(selectedChannel && { channelId: selectedChannel }), - limit: "50", - }); - + const params = new URLSearchParams({ q: searchQuery, ...(selectedChannel && { channelId: selectedChannel }), limit: "50" }); const response = await fetch(`/api/analysis/search?${params}`); if (!response.ok) throw new Error("Search failed"); - const data = await response.json(); setSearchResults(data.results || []); setShowSearch(true); - } catch (error) { - console.error("Search error:", error); + } catch { setSearchResults([]); } finally { setIsSearching(false); @@ -96,29 +72,17 @@ export function MessagesPanel({ return (
- {/* Source selector */} Message Source Pick a guild and channel/thread to inspect captures. - onChannelChange(event.target.value)} - placeholder="Select channel or thread" - options={channels.map((channel) => ({ value: channel.id, label: channel.name }))} - /> + onChannelChange(e.target.value)} placeholder="Select channel or thread" options={channels.map((c) => ({ value: c.id, label: c.name }))} /> - {/* Stats bar */} {stats.total > 0 && (
{stats.total} total @@ -132,22 +96,12 @@ export function MessagesPanel({
)} - {/* Search + Filter row */}
- setSearchQuery(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && handleSearch()} - disabled={isSearching} - /> + setSearchQuery(e.target.value)} onKeyDown={(e) => e.key === "Enter" && handleSearch()} disabled={isSearching} />
- + {showSearch && ( ))} @@ -168,31 +118,16 @@ export function MessagesPanel({
{showSearch && searchResults.length > 0 && ( -
- Found {searchResults.length} result{searchResults.length !== 1 ? "s" : ""} -
+
Found {searchResults.length} result{searchResults.length !== 1 ? "s" : ""}
)} - {/* View tabs */} setViewTab(v as "all" | "images")}> - - {showSearch ? `Search (${filteredMessages.length})` : `All (${filteredMessages.length})`} - + {showSearch ? `Search (${filteredMessages.length})` : `All (${filteredMessages.length})`} Images - + diff --git a/frontend/src/hooks/useDashboardSocket.ts b/frontend/src/hooks/useDashboardSocket.ts deleted file mode 100644 index 865de3e..0000000 --- a/frontend/src/hooks/useDashboardSocket.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { useEffect, useRef, useState } from "react"; -import type { MessageRecord } from "../types/messages"; -import type { MediaState } from "../types/media"; -import type { UIState } from "../types/ui"; -import type { ActiveSpeaker } from "../types/voice"; - -export type WebSocketStatus = "connecting" | "connected" | "disconnected" | "error"; - -export interface DashboardSocketHandlers { - onUIState?: (state: UIState) => void; - onUserState?: (users: ActiveSpeaker[]) => void; - onMessageCreated?: (message: MessageRecord) => void; - onMessageUpdated?: (message: Partial & { id: string }) => void; - onMessageDeleted?: (message: { id: string }) => void; - onMessageAnalyzed?: (message: MessageRecord) => void; - onAttachmentUploaded?: () => void; - onMediaState?: (state: MediaState) => void; - onVoiceRecordingUploaded?: (recording: any) => void; - onPcm?: (data: ArrayBuffer) => void; - onAnalyticsRefresh?: () => void; -} - -export function useDashboardSocket(handlers: DashboardSocketHandlers) { - const [status, setStatus] = useState("connecting"); - const handlersRef = useRef(handlers); - const socketRef = useRef(null); - - handlersRef.current = handlers; - - useEffect(() => { - let closed = false; - let reconnectTimer: number | null = null; - - const connect = () => { - const protocol = location.protocol === "https:" ? "wss:" : "ws:"; - const socket = new WebSocket(`${protocol}//${location.host}/ws`); - socket.binaryType = "arraybuffer"; - socketRef.current = socket; - setStatus("connecting"); - - socket.addEventListener("open", () => setStatus("connected")); - socket.addEventListener("error", () => setStatus("error")); - socket.addEventListener("close", () => { - setStatus("disconnected"); - if (!closed) reconnectTimer = window.setTimeout(connect, 2500); - }); - socket.addEventListener("message", (event) => { - if (event.data instanceof ArrayBuffer) { - handlersRef.current.onPcm?.(event.data); - return; - } - if (typeof event.data !== "string") return; - try { - const message = JSON.parse(event.data); - switch (message.type) { - case "ui_state": - handlersRef.current.onUIState?.(message.state); - break; - case "user_state": - handlersRef.current.onUserState?.(message.users || []); - break; - case "message_created": - handlersRef.current.onMessageCreated?.(message.data); - break; - case "message_updated": - handlersRef.current.onMessageUpdated?.(message.data); - break; - case "message_deleted": - handlersRef.current.onMessageDeleted?.(message.data); - break; - case "message_analyzed": - handlersRef.current.onMessageAnalyzed?.(message.data); - break; - case "attachment_uploaded": - handlersRef.current.onAttachmentUploaded?.(); - break; - case "media_state": - handlersRef.current.onMediaState?.(message.state); - break; - case "voice_recording_uploaded": - handlersRef.current.onVoiceRecordingUploaded?.(message.data); - break; - } - } catch { - // ignore malformed socket messages - } - }); - }; - - connect(); - - return () => { - closed = true; - if (reconnectTimer) window.clearTimeout(reconnectTimer); - socketRef.current?.close(); - socketRef.current = null; - }; - }, []); - - return { status, socketRef }; -} diff --git a/frontend/src/hooks/useReview.ts b/frontend/src/hooks/useReview.ts deleted file mode 100644 index 931cf45..0000000 --- a/frontend/src/hooks/useReview.ts +++ /dev/null @@ -1,221 +0,0 @@ -import { useCallback, useState } from "react"; - -export type ReviewStatus = "pending" | "approved" | "rejected" | "escalated"; - -export interface MessageReview { - id: string; - message_id: string; - guild_id: string; - channel_id: string; - reviewer_id: string | null; - status: ReviewStatus; - notes: string | null; - created_at: number; - reviewed_at: number | null; -} - -export type ModerationActionType = - | "delete_message" - | "mute_user" - | "warn_user" - | "kick_user" - | "ban_user"; - -export interface ModerationAction { - id: string; - message_id: string | null; - user_id: string | null; - guild_id: string; - action_type: ModerationActionType; - reason: string | null; - executed_by: string | null; - status: "pending" | "executed" | "failed"; - error: string | null; - created_at: number; - executed_at: number | null; -} - -interface ReviewQuery { - guildId?: string; - channelId?: string; - status?: string[]; - cursor?: string; - limit: number; -} - -interface PageResult { - data: T[]; - nextCursor: string | null; -} - -export function useReview() { - const [reviews, setReviews] = useState([]); - const [actions, setActions] = useState([]); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [nextCursor, setNextCursor] = useState(null); - - const listReviews = useCallback(async (query: ReviewQuery) => { - setLoading(true); - setError(null); - try { - const params = new URLSearchParams(); - if (query.guildId) params.append("guildId", query.guildId); - if (query.channelId) params.append("channelId", query.channelId); - if (query.status?.length) params.append("status", query.status.join(",")); - if (query.cursor) params.append("cursor", query.cursor); - params.append("limit", String(query.limit)); - - const response = await fetch(`/api/reviews?${params}`); - if (!response.ok) throw new Error(`HTTP ${response.status}`); - - const result = (await response.json()) as PageResult; - setReviews(result.data); - setNextCursor(result.nextCursor); - } catch (err) { - setError(err instanceof Error ? err.message : "Unknown error"); - } finally { - setLoading(false); - } - }, []); - - const createReview = useCallback( - async (review: Omit) => { - try { - const response = await fetch("/api/reviews", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(review), - }); - if (!response.ok) throw new Error(`HTTP ${response.status}`); - - const newReview = (await response.json()) as MessageReview; - setReviews((prev) => [newReview, ...prev]); - return newReview; - } catch (err) { - const message = err instanceof Error ? err.message : "Unknown error"; - setError(message); - throw err; - } - }, - [], - ); - - const updateReview = useCallback( - async ( - id: string, - updates: Partial>, - ) => { - try { - const response = await fetch(`/api/reviews/${id}`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(updates), - }); - if (!response.ok) throw new Error(`HTTP ${response.status}`); - - const updated = (await response.json()) as MessageReview; - setReviews((prev) => - prev.map((r) => (r.id === id ? updated : r)), - ); - return updated; - } catch (err) { - const message = err instanceof Error ? err.message : "Unknown error"; - setError(message); - throw err; - } - }, - [], - ); - - const listActions = useCallback( - async (query: Omit) => { - setLoading(true); - setError(null); - try { - const params = new URLSearchParams(); - if (query.guildId) params.append("guildId", query.guildId); - if (query.status?.length) params.append("status", query.status.join(",")); - if (query.cursor) params.append("cursor", query.cursor); - params.append("limit", String(query.limit)); - - const response = await fetch(`/api/actions?${params}`); - if (!response.ok) throw new Error(`HTTP ${response.status}`); - - const result = (await response.json()) as PageResult; - setActions(result.data); - setNextCursor(result.nextCursor); - } catch (err) { - setError(err instanceof Error ? err.message : "Unknown error"); - } finally { - setLoading(false); - } - }, - [], - ); - - const createAction = useCallback( - async ( - action: Omit, - ) => { - try { - const response = await fetch("/api/actions", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(action), - }); - if (!response.ok) throw new Error(`HTTP ${response.status}`); - - const newAction = (await response.json()) as ModerationAction; - setActions((prev) => [newAction, ...prev]); - return newAction; - } catch (err) { - const message = err instanceof Error ? err.message : "Unknown error"; - setError(message); - throw err; - } - }, - [], - ); - - const updateAction = useCallback( - async ( - id: string, - updates: Partial>, - ) => { - try { - const response = await fetch(`/api/actions/${id}`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(updates), - }); - if (!response.ok) throw new Error(`HTTP ${response.status}`); - - const updated = (await response.json()) as ModerationAction; - setActions((prev) => - prev.map((a) => (a.id === id ? updated : a)), - ); - return updated; - } catch (err) { - const message = err instanceof Error ? err.message : "Unknown error"; - setError(message); - throw err; - } - }, - [], - ); - - return { - reviews, - actions, - loading, - error, - nextCursor, - listReviews, - createReview, - updateReview, - listActions, - createAction, - updateAction, - }; -} diff --git a/frontend/src/hooks/useUIState.ts b/frontend/src/hooks/useUIState.ts deleted file mode 100644 index 016ea9a..0000000 --- a/frontend/src/hooks/useUIState.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { useCallback, useState } from "react"; -import type { UIState } from "../types/ui"; - -const STORAGE_KEY = "bete-dashboard-ui-state"; - -function loadState(): UIState { - try { - const raw = localStorage.getItem(STORAGE_KEY); - if (raw) return JSON.parse(raw) as UIState; - } catch { - // ignore parse errors - } - return { activeTab: "live" }; -} - -function saveState(state: UIState): void { - try { - localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); - } catch { - // ignore quota errors - } -} - -export function useUIState() { - const [uiState, setUIState] = useState(loadState); - - const patchUIState = useCallback((patch: Partial) => { - setUIState((prev) => { - const next = { ...prev, ...patch }; - saveState(next); - return next; - }); - }, []); - - return { uiState, setUIState, patchUIState, loading: false, error: null }; -} diff --git a/frontend/src/shared/api/client.ts b/frontend/src/shared/api/client.ts new file mode 100644 index 0000000..fb97a25 --- /dev/null +++ b/frontend/src/shared/api/client.ts @@ -0,0 +1,421 @@ +// ─── Shared HTTP client — all API endpoints in one file ────────────────────── + +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(path: string, init?: RequestInit): Promise { + 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; +} + +// ─── Types ─────────────────────────────────────────────────────────────────── + +export interface PageResult { + data: T[]; + nextCursor: string | null; +} + +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?: string | null; + ai_moderation_flags?: string | null; + ai_moderation_score?: number | null; + ai_analysis?: string | null; + ai_categories?: string | null; + ai_severity?: string | null; + ai_confidence?: number | null; + ai_recommended_action?: string | null; + ai_analyzed_at?: number | null; + ai_error?: string | null; +} + +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; + activeChannelId?: string | null; + activeChannelName?: string | null; +} + +export interface ActiveSpeaker { + id?: string; + userId?: string; + username: string; + avatar: string; + speaking: boolean; +} + +export interface MediaItem { + id?: string; + source: string; + title: string; + mode?: "music" | "screen"; + durationMs?: number | null; + thumbnailUrl?: string | null; +} + +export interface MediaState { + playing: boolean; + musicVolume: number; + current: MediaItem | null; + queue: MediaItem[]; +} + +export interface UIState { + selectedGuild?: string; + selectedVoiceGuild?: string; + selectedVoiceChannel?: string; + selectedTextGuild?: string; + selectedTextChannel?: string; + selectedAnalyticsGuild?: string; + selectedAnalyticsChannel?: string; + activeTab?: "live" | "messages" | "analytics"; + isListening?: boolean; + isStreaming?: boolean; +} + +export interface AppConfig { + monitorGuildId: string | null; +} + +export type DashboardTab = "live" | "messages" | "analytics"; + +// ─── Messages ──────────────────────────────────────────────────────────────── + +export function listMessages(params: URLSearchParams): Promise> { + return request>(`/api/messages?${params}`); +} + +export function listReview(params: URLSearchParams): Promise> { + return request>(`/api/review?${params}`); +} + +export function reanalyzeMessage(id: string): Promise { + return request(`/api/messages/${id}/reanalyze`, { method: "POST" }); +} + +// ─── Guilds / Config ───────────────────────────────────────────────────────── + +export function getGuilds(): Promise { + return request("/api/guilds"); +} + +export function getAppConfig(): Promise { + return request("/api/config"); +} + +// ─── Voice ─────────────────────────────────────────────────────────────────── + +export function getVoiceChannels(guildId: string): Promise { + return request(`/api/guilds/${guildId}/voice-channels`); +} + +export function getTextChannels(guildId: string): Promise { + return request(`/api/guilds/${guildId}/channels`); +} + +export function getVoiceStatus(): Promise { + return request("/api/status"); +} + +export function connectVoice(guildId: string, channelId: string): Promise { + return request("/api/connect", { + method: "POST", + body: JSON.stringify({ guildId, channelId }), + }); +} + +export function disconnectVoice(): Promise { + return request("/api/disconnect", { method: "POST" }); +} + +// ─── Media ─────────────────────────────────────────────────────────────────── + +export function getMediaStatus(): Promise { + return request("/api/media/status"); +} + +export function queueMedia(source: string, mode: "music" | "screen"): Promise { + return request("/api/media/queue", { + method: "POST", + body: JSON.stringify({ source, mode }), + }); +} + +export function skipMedia(): Promise { + return request("/api/media/skip", { method: "POST" }); +} + +export function stopMedia(): Promise { + return request("/api/media/stop", { method: "POST" }); +} + +export function setMediaVolume(volume: number): Promise { + return request("/api/media/volume", { + method: "POST", + body: JSON.stringify({ volume }), + }); +} + +// ─── Auth ──────────────────────────────────────────────────────────────────── + +export function login(password: string): Promise<{ ok: boolean }> { + return request<{ ok: boolean }>("/api/auth/login", { + method: "POST", + body: JSON.stringify({ password }), + }); +} + +// ─── UI State ──────────────────────────────────────────────────────────────── + +export function getUIState(): Promise { + return request("/api/ui-state"); +} + +export function updateUIState(patch: Partial): Promise { + return request("/api/ui-state", { + method: "POST", + body: JSON.stringify(patch), + }); +} + +// ─── Analytics ─────────────────────────────────────────────────────────────── + +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 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 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 function fetchAnalyticsOverview(params: { + guildId: string; + channelId?: string; + hours?: number; +}): Promise { + const sp = new URLSearchParams({ + guildId: params.guildId, + ...(params.channelId && { channelId: params.channelId }), + ...(params.hours && { hours: String(params.hours) }), + }); + return request(`/api/analytics/overview?${sp}`); +} + +export function fetchHourlyStats(params: { + guildId: string; + channelId?: string; + hours?: number; +}): Promise { + const sp = new URLSearchParams({ + guildId: params.guildId, + ...(params.channelId && { channelId: params.channelId }), + ...(params.hours && { hours: String(params.hours) }), + }); + return request(`/api/analytics/hourly?${sp}`); +} + +export function fetchTopicTrends(params: { + guildId: string; + channelId?: string; + hours?: number; +}): Promise { + const sp = new URLSearchParams({ + guildId: params.guildId, + ...(params.channelId && { channelId: params.channelId }), + ...(params.hours && { hours: String(params.hours) }), + }); + return request(`/api/analytics/topics?${sp}`); +} + +export function fetchLeaderboard(params: { + guildId: string; + channelId?: string; + hours?: number; + limit?: number; +}): Promise { + const sp = new URLSearchParams({ + guildId: params.guildId, + ...(params.channelId && { channelId: params.channelId }), + ...(params.hours && { hours: String(params.hours) }), + ...(params.limit && { limit: String(params.limit) }), + }); + return request(`/api/analytics/leaderboard?${sp}`); +} + +export function fetchModerationStats(params: { + guildId: string; + channelId?: string; + hours?: number; +}): Promise { + const sp = new URLSearchParams({ + guildId: params.guildId, + ...(params.channelId && { channelId: params.channelId }), + ...(params.hours && { hours: String(params.hours) }), + }); + return request(`/api/analytics/stats?${sp}`); +} + +export function fetchViolators(params: { + guildId: string; + channelId?: string; + hours?: number; + limit?: number; +}): Promise { + const sp = new URLSearchParams({ + guildId: params.guildId, + ...(params.channelId && { channelId: params.channelId }), + ...(params.hours && { hours: String(params.hours) }), + ...(params.limit && { limit: String(params.limit) }), + }); + return request(`/api/analytics/violators?${sp}`); +} + +export function fetchTrend(params: { + guildId: string; + channelId?: string; + hours?: number; +}): Promise { + const sp = new URLSearchParams({ + guildId: params.guildId, + ...(params.channelId && { channelId: params.channelId }), + ...(params.hours && { hours: String(params.hours) }), + }); + return request(`/api/analytics/trend?${sp}`); +} + +export function fetchHeatmap(params: { + guildId: string; + channelId?: string; + hours?: number; +}): Promise { + const sp = new URLSearchParams({ + guildId: params.guildId, + ...(params.channelId && { channelId: params.channelId }), + ...(params.hours && { hours: String(params.hours) }), + }); + return request(`/api/analytics/heatmap?${sp}`); +} diff --git a/frontend/src/shared/hooks/useAudioPlayback.ts b/frontend/src/shared/hooks/useAudioPlayback.ts new file mode 100644 index 0000000..10ee21a --- /dev/null +++ b/frontend/src/shared/hooks/useAudioPlayback.ts @@ -0,0 +1,57 @@ +// ─── Audio playback hook — receives PCM from WebSocket and plays through Web Audio API ── +import { useCallback, useRef, useState } from "react"; + +const SAMPLE_RATE = 24000; +const CHANNELS = 1; + +export function useAudioPlayback() { + const [isListening, setIsListening] = useState(false); + const [levels, setLevels] = useState(Array.from({ length: 32 }, () => 0.04)); + const audioContextRef = useRef(null); + const userTimelinesRef = useRef(new Map()); + + const handleIncomingPcm = useCallback((data: ArrayBuffer) => { + const headerView = new DataView(data, 0, 4); + const userIdHash = headerView.getInt32(0, true); + const audioData = data.slice(4); + const int16Array = new Int16Array(audioData); + let sum = 0; + for (const sample of int16Array) sum += Math.abs(sample / 32768); + const average = int16Array.length ? sum / int16Array.length : 0; + setLevels((prev) => + prev.map((_, index) => + Math.max(0.04, average * (0.5 + Math.sin(index * 0.6 + Date.now() / 140) * 0.35 + 0.65) * 5), + ), + ); + + const audioContext = audioContextRef.current; + if (!isListening || !audioContext) return; + const float32Array = new Float32Array(int16Array.length); + for (let i = 0; i < int16Array.length; i++) float32Array[i] = int16Array[i] / 32768; + const audioBuffer = audioContext.createBuffer(CHANNELS, float32Array.length / SAMPLE_RATE, 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 toggleListening = useCallback(async () => { + if (isListening) { + await audioContextRef.current?.suspend(); + userTimelinesRef.current.clear(); + setIsListening(false); + return; + } + const AudioContextCtor = window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext; + audioContextRef.current ??= new AudioContextCtor({ sampleRate: SAMPLE_RATE }); + await audioContextRef.current.resume(); + setIsListening(true); + }, [isListening]); + + return { isListening, levels, handleIncomingPcm, toggleListening, audioContextRef }; +} diff --git a/frontend/src/shared/hooks/useAudioTransmit.ts b/frontend/src/shared/hooks/useAudioTransmit.ts new file mode 100644 index 0000000..f54ca8b --- /dev/null +++ b/frontend/src/shared/hooks/useAudioTransmit.ts @@ -0,0 +1,49 @@ +// ─── Audio transmit hook — captures mic, encodes to PCM, sends via WebSocket ── +import { useCallback, useRef, useState } from "react"; + +const SAMPLE_RATE = 24000; + +export function useAudioTransmit( + socketRef: { readonly current: WebSocket | null }, +) { + const [isStreaming, setIsStreaming] = useState(false); + const streamRef = useRef(null); + const audioContextRef = useRef(null); + const processorRef = useRef(null); + + const stop = useCallback(() => { + setIsStreaming(false); + if (processorRef.current) { processorRef.current.disconnect(); processorRef.current = null; } + if (audioContextRef.current) { audioContextRef.current.close(); audioContextRef.current = null; } + if (streamRef.current) { for (const track of streamRef.current.getTracks()) track.stop(); streamRef.current = null; } + }, []); + + const start = useCallback(async () => { + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + streamRef.current = stream; + setIsStreaming(true); + const AudioContextCtor = window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext; + const audioContext = new AudioContextCtor({ sampleRate: SAMPLE_RATE }); + audioContextRef.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 (!socketRef.current || 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; + // BUG 2 FIX: slice() to create independent copy of the ArrayBuffer + socketRef.current.send(pcmData.buffer.slice(0)); + }; + }, [socketRef]); + + const toggle = useCallback(async () => { + if (isStreaming) stop(); + else await start(); + }, [isStreaming, start, stop]); + + return { isStreaming, toggle, stop, start }; +} diff --git a/frontend/src/shared/hooks/useLocalStorage.ts b/frontend/src/shared/hooks/useLocalStorage.ts new file mode 100644 index 0000000..aedcc0a --- /dev/null +++ b/frontend/src/shared/hooks/useLocalStorage.ts @@ -0,0 +1,59 @@ +// ─── Validated localStorage hook with shape checking ──────────────────────── +import { useCallback, useState } from "react"; + +interface ShapeValidator { + /** Returns true if the parsed value matches the expected shape */ + validate: (value: unknown) => value is T; + /** Default value when storage is empty or invalid */ + defaults: T; +} + +export function useLocalStorage(key: string, validator: ShapeValidator) { + const [value, setValue] = useState(() => loadStored(key, validator)); + + const update = useCallback( + (patch: T | ((prev: T) => T)) => { + setValue((prev) => { + const next = typeof patch === "function" ? (patch as (prev: T) => T)(prev) : patch; + try { + localStorage.setItem(key, JSON.stringify(next)); + } catch { + // ignore quota errors + } + return next; + }); + }, + [key], + ); + + return { value, setValue: update }; +} + +function loadStored(key: string, validator: ShapeValidator): T { + try { + const raw = localStorage.getItem(key); + if (!raw) return validator.defaults; + const parsed = JSON.parse(raw) as unknown; + if (validator.validate(parsed)) return parsed; + return validator.defaults; + } catch { + return validator.defaults; + } +} + +// ─── Pre-built validators for common shapes ───────────────────────────────── + +export function recordValidator(): ShapeValidator> { + return { + validate: (v): v is Record => typeof v === "object" && v !== null && !Array.isArray(v), + defaults: {}, + }; +} + +export function uiStateValidator(): ShapeValidator> { + return { + validate: (v): v is Record => + typeof v === "object" && v !== null && !Array.isArray(v), + defaults: { activeTab: "live" }, + }; +} diff --git a/frontend/src/shared/hooks/useUIState.ts b/frontend/src/shared/hooks/useUIState.ts new file mode 100644 index 0000000..b87a217 --- /dev/null +++ b/frontend/src/shared/hooks/useUIState.ts @@ -0,0 +1,13 @@ +import { useCallback } from "react"; +import type { UIState } from "../../entities/ui/types"; +import { useLocalStorage, uiStateValidator } from "./useLocalStorage"; + +export function useUIState() { + const { value: uiState, setValue: setUIState } = useLocalStorage("bete-dashboard-ui-state", uiStateValidator()); + + const patchUIState = useCallback((patch: Partial) => { + setUIState((prev) => ({ ...prev, ...patch })); + }, [setUIState]); + + return { uiState, setUIState, patchUIState, loading: false, error: null }; +} diff --git a/frontend/src/lib/utils.ts b/frontend/src/shared/lib/utils.ts similarity index 72% rename from frontend/src/lib/utils.ts rename to frontend/src/shared/lib/utils.ts index a5ef193..365058c 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/shared/lib/utils.ts @@ -1,4 +1,4 @@ -import { clsx, type ClassValue } from "clsx"; +import { type ClassValue, clsx } from "clsx"; import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { diff --git a/frontend/src/shared/ui/MobileTabBar.tsx b/frontend/src/shared/ui/MobileTabBar.tsx new file mode 100644 index 0000000..62f486a --- /dev/null +++ b/frontend/src/shared/ui/MobileTabBar.tsx @@ -0,0 +1,35 @@ +import { BarChart3, MessageSquare, Radio } from "lucide-react"; +import type { DashboardTab } from "../../entities/ui/types"; +import { cn } from "../lib/utils"; + +const tabs: 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 MobileTabBarProps { + activeTab: DashboardTab; + onTabChange: (tab: DashboardTab) => void; +} + +export function MobileTabBar({ activeTab, onTabChange }: MobileTabBarProps) { + return ( + + ); +} diff --git a/frontend/src/components/ui/badge.tsx b/frontend/src/shared/ui/badge.tsx similarity index 96% rename from frontend/src/components/ui/badge.tsx rename to frontend/src/shared/ui/badge.tsx index c756927..5350539 100644 --- a/frontend/src/components/ui/badge.tsx +++ b/frontend/src/shared/ui/badge.tsx @@ -1,5 +1,5 @@ import type * as React from "react"; -import { cn } from "../../lib/utils"; +import { cn } from "../lib/utils"; type BadgeVariant = "default" | "secondary" | "destructive" | "outline" | "success" | "warning"; diff --git a/frontend/src/components/ui/button.tsx b/frontend/src/shared/ui/button.tsx similarity index 97% rename from frontend/src/components/ui/button.tsx rename to frontend/src/shared/ui/button.tsx index 64bdf85..c78bf87 100644 --- a/frontend/src/components/ui/button.tsx +++ b/frontend/src/shared/ui/button.tsx @@ -1,6 +1,6 @@ import { Slot } from "@radix-ui/react-slot"; import type * as React from "react"; -import { cn } from "../../lib/utils"; +import { cn } from "../lib/utils"; type ButtonVariant = "default" | "secondary" | "destructive" | "outline" | "ghost"; type ButtonSize = "default" | "sm" | "lg" | "icon"; diff --git a/frontend/src/components/ui/card.tsx b/frontend/src/shared/ui/card.tsx similarity index 96% rename from frontend/src/components/ui/card.tsx rename to frontend/src/shared/ui/card.tsx index cda932d..c535a41 100644 --- a/frontend/src/components/ui/card.tsx +++ b/frontend/src/shared/ui/card.tsx @@ -1,5 +1,5 @@ import type * as React from "react"; -import { cn } from "../../lib/utils"; +import { cn } from "../lib/utils"; export function Card({ className, ...props }: React.HTMLAttributes) { return
; diff --git a/frontend/src/shared/ui/index.ts b/frontend/src/shared/ui/index.ts new file mode 100644 index 0000000..47ccec8 --- /dev/null +++ b/frontend/src/shared/ui/index.ts @@ -0,0 +1,10 @@ +// ─── Shared UI barrel export ──────────────────────────────────────────────── +export { Button } from "./button"; +export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from "./card"; +export { Badge } from "./badge"; +export { Input } from "./input"; +export { Select } from "./select"; +export { Tabs, TabsList, TabsTrigger, TabsContent } from "./tabs"; +export { ScrollArea } from "./scroll-area"; +export { Skeleton } from "./skeleton"; +export { ToastProvider, useToast } from "./toast"; diff --git a/frontend/src/components/ui/input.tsx b/frontend/src/shared/ui/input.tsx similarity index 73% rename from frontend/src/components/ui/input.tsx rename to frontend/src/shared/ui/input.tsx index bbca089..1d1cada 100644 --- a/frontend/src/components/ui/input.tsx +++ b/frontend/src/shared/ui/input.tsx @@ -1,7 +1,9 @@ import type * as React from "react"; -import { cn } from "../../lib/utils"; +import { cn } from "../lib/utils"; -export function Input({ className, type, ...props }: React.InputHTMLAttributes) { +export interface InputProps extends React.InputHTMLAttributes {} + +export function Input({ className, type, ...props }: InputProps) { return ( ) { return ( @@ -12,7 +12,11 @@ export function ScrollArea({ className, children, ...props }: React.ComponentPro ); } -function ScrollBar({ className, orientation = "vertical", ...props }: React.ComponentPropsWithoutRef) { +function ScrollBar({ + className, + orientation = "vertical", + ...props +}: React.ComponentPropsWithoutRef) { return ( { +export interface SelectProps extends React.SelectHTMLAttributes { options: SelectOption[]; placeholder?: string; } @@ -20,7 +20,7 @@ export function Select({ className, options, placeholder, ...props }: SelectProp )} {...props} > - {placeholder ? : null} + {placeholder && } {options.map((option) => (