From f5507e01f63ba31015e7875f97533128c834f22a Mon Sep 17 00:00:00 2001 From: MythEclipse Date: Mon, 1 Jun 2026 16:42:36 +0700 Subject: [PATCH] =?UTF-8?q?refactor(frontend):=20major=20rebuild=20?= =?UTF-8?q?=E2=80=94=20feature-sliced=20architecture=20+=20bug=20fixes=20+?= =?UTF-8?q?=20glass-morphism=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Architecture: - Feature-sliced directory: entities/, shared/, features/, widgets/ - Consolidated API client (shared/api/client.ts) — all endpoints in one file - Single WebSocket manager (shared/ws/socket.ts) with typed event bus - Extracted hooks: useAudioPlayback, useAudioTransmit, useLocalStorage, useUIState - UI primitives moved to shared/ui/ with barrel export - Added Skeleton, Toast, MobileTabBar components Bug fixes (8/8): 1. useMemo→useEffect in RecordingsSubPanel (async side-effect anti-pattern) 2. ArrayBuffer.slice() before WebSocket send (shared buffer bug) 3. Proper useEffect dependency arrays throughout 4. Stable React keys (no index fallbacks) 5. onReanalyze properly awaited (Promise return) 6. monitorGuild memoized with useMemo 7. localStorage validation with shape checking 8. Deleted duplicate socket logic (ws/client.ts removed) UI polish: - Glass-morphism design tokens (backdrop-blur, translucent cards) - Gradient mesh background with subtle radial overlays - Expandable sidebar + mobile bottom tab bar - Skeleton loading placeholders - Audio visualizer with CSS pulse animation Deleted: src/api/, src/components/, src/hooks/, src/types/, src/ws/, src/lib/ Added: 40 new files across feature-sliced structure Co-Authored-By: Claude Opus 4.8 --- frontend/package.json | 37 ++ frontend/src/App.tsx | 295 +++--------- frontend/src/api/analytics.ts | 185 -------- frontend/src/api/auth.ts | 8 - frontend/src/api/client.ts | 115 ----- frontend/src/api/media.ts | 28 -- frontend/src/api/messages.ts | 3 - frontend/src/api/uiState.ts | 13 - frontend/src/api/voice.ts | 29 -- frontend/src/components/analytics/index.tsx | 116 ----- frontend/src/components/layout/Sidebar.tsx | 48 -- frontend/src/components/live/LivePanel.tsx | 314 ------------- frontend/src/components/media/MediaPanel.tsx | 50 --- frontend/src/components/media/MediaQueue.tsx | 46 -- frontend/src/components/media/MusicPlayer.tsx | 84 ---- frontend/src/components/media/ScreenShare.tsx | 45 -- .../src/components/messages/ImageGrid.tsx | 98 ---- .../components/recordings/RecordingsPanel.tsx | 125 ------ .../src/components/review/ReviewPanel.tsx | 196 -------- .../src/components/voice/ActiveSpeakers.tsx | 35 -- .../src/components/voice/AudioVisualizer.tsx | 18 - .../src/components/voice/VoiceControl.tsx | 83 ---- frontend/src/components/voice/VoicePanel.tsx | 43 -- frontend/src/entities/guild/types.ts | 12 + .../media.ts => entities/media/types.ts} | 0 frontend/src/entities/message/types.ts | 41 ++ .../src/{types/ui.ts => entities/ui/types.ts} | 0 .../voice.ts => entities/voice/types.ts} | 13 - .../analytics/components}/ActivityChart.tsx | 20 +- .../analytics/components}/ControlBar.tsx | 8 +- .../analytics/components}/Heatmap.tsx | 6 +- .../analytics/components}/SummaryCards.tsx | 12 +- .../analytics/components}/TopicList.tsx | 5 +- .../analytics/components}/TrendChart.tsx | 12 +- .../analytics/components}/UserTable.tsx | 6 +- .../analytics/components}/ViolatorTable.tsx | 8 +- .../analytics}/hooks/useAnalytics.ts | 62 +-- frontend/src/features/analytics/index.tsx | 56 +++ .../auth/index.tsx} | 10 +- .../live/components/ActiveSpeakers.tsx | 46 ++ .../live/components/AudioVisualizer.tsx | 61 +++ .../live/components/MusicSubPanel.tsx | 64 +++ .../features/live/components/NowPlaying.tsx | 46 ++ .../live/components/RecordingsSubPanel.tsx | 126 ++++++ .../live/components/ScreenSubPanel.tsx | 37 ++ .../live/components/VoiceConnectionCard.tsx | 62 +++ .../src/features/live/components/index.ts | 8 + .../live}/hooks/useMediaControl.ts | 31 +- .../live}/hooks/useVoiceControl.ts | 14 +- frontend/src/features/live/index.tsx | 113 +++++ .../messages/components/ImageGrid.tsx | 104 +++++ .../messages/components}/MessageCard.tsx | 96 ++-- .../messages/components}/MessageFeed.tsx | 21 +- .../messages}/hooks/useMessages.ts | 15 +- .../messages/index.tsx} | 101 +---- frontend/src/hooks/useDashboardSocket.ts | 101 ----- frontend/src/hooks/useReview.ts | 221 --------- frontend/src/hooks/useUIState.ts | 36 -- frontend/src/shared/api/client.ts | 421 ++++++++++++++++++ frontend/src/shared/hooks/useAudioPlayback.ts | 57 +++ frontend/src/shared/hooks/useAudioTransmit.ts | 49 ++ frontend/src/shared/hooks/useLocalStorage.ts | 59 +++ frontend/src/shared/hooks/useUIState.ts | 13 + frontend/src/{ => shared}/lib/utils.ts | 2 +- frontend/src/shared/ui/MobileTabBar.tsx | 35 ++ .../src/{components => shared}/ui/badge.tsx | 2 +- .../src/{components => shared}/ui/button.tsx | 2 +- .../src/{components => shared}/ui/card.tsx | 2 +- frontend/src/shared/ui/index.ts | 10 + .../src/{components => shared}/ui/input.tsx | 6 +- .../{components => shared}/ui/scroll-area.tsx | 8 +- .../src/{components => shared}/ui/select.tsx | 6 +- frontend/src/shared/ui/skeleton.tsx | 11 + .../src/{components => shared}/ui/tabs.tsx | 2 +- frontend/src/shared/ui/toast.tsx | 73 +++ frontend/src/shared/ws/events.ts | 26 ++ frontend/src/shared/ws/socket.ts | 142 ++++++ frontend/src/styles.css | 54 ++- frontend/src/types/browser.ts | 7 - frontend/src/types/messages.ts | 29 -- .../layout => widgets}/DashboardLayout.tsx | 8 +- .../{components/layout => widgets}/Header.tsx | 11 +- frontend/src/widgets/Sidebar.tsx | 57 +++ frontend/src/ws/client.ts | 32 -- 84 files changed, 2054 insertions(+), 2658 deletions(-) create mode 100644 frontend/package.json delete mode 100644 frontend/src/api/analytics.ts delete mode 100644 frontend/src/api/auth.ts delete mode 100644 frontend/src/api/client.ts delete mode 100644 frontend/src/api/media.ts delete mode 100644 frontend/src/api/messages.ts delete mode 100644 frontend/src/api/uiState.ts delete mode 100644 frontend/src/api/voice.ts delete mode 100644 frontend/src/components/analytics/index.tsx delete mode 100644 frontend/src/components/layout/Sidebar.tsx delete mode 100644 frontend/src/components/live/LivePanel.tsx delete mode 100644 frontend/src/components/media/MediaPanel.tsx delete mode 100644 frontend/src/components/media/MediaQueue.tsx delete mode 100644 frontend/src/components/media/MusicPlayer.tsx delete mode 100644 frontend/src/components/media/ScreenShare.tsx delete mode 100644 frontend/src/components/messages/ImageGrid.tsx delete mode 100644 frontend/src/components/recordings/RecordingsPanel.tsx delete mode 100644 frontend/src/components/review/ReviewPanel.tsx delete mode 100644 frontend/src/components/voice/ActiveSpeakers.tsx delete mode 100644 frontend/src/components/voice/AudioVisualizer.tsx delete mode 100644 frontend/src/components/voice/VoiceControl.tsx delete mode 100644 frontend/src/components/voice/VoicePanel.tsx create mode 100644 frontend/src/entities/guild/types.ts rename frontend/src/{types/media.ts => entities/media/types.ts} (100%) create mode 100644 frontend/src/entities/message/types.ts rename frontend/src/{types/ui.ts => entities/ui/types.ts} (100%) rename frontend/src/{types/voice.ts => entities/voice/types.ts} (60%) rename frontend/src/{components/analytics => features/analytics/components}/ActivityChart.tsx (82%) rename frontend/src/{components/analytics => features/analytics/components}/ControlBar.tsx (94%) rename frontend/src/{components/analytics => features/analytics/components}/Heatmap.tsx (96%) rename frontend/src/{components/analytics => features/analytics/components}/SummaryCards.tsx (84%) rename frontend/src/{components/analytics => features/analytics/components}/TopicList.tsx (95%) rename frontend/src/{components/analytics => features/analytics/components}/TrendChart.tsx (92%) rename frontend/src/{components/analytics => features/analytics/components}/UserTable.tsx (95%) rename frontend/src/{components/analytics => features/analytics/components}/ViolatorTable.tsx (95%) rename frontend/src/{ => features/analytics}/hooks/useAnalytics.ts (63%) create mode 100644 frontend/src/features/analytics/index.tsx rename frontend/src/{components/layout/AuthOverlay.tsx => features/auth/index.tsx} (86%) create mode 100644 frontend/src/features/live/components/ActiveSpeakers.tsx create mode 100644 frontend/src/features/live/components/AudioVisualizer.tsx create mode 100644 frontend/src/features/live/components/MusicSubPanel.tsx create mode 100644 frontend/src/features/live/components/NowPlaying.tsx create mode 100644 frontend/src/features/live/components/RecordingsSubPanel.tsx create mode 100644 frontend/src/features/live/components/ScreenSubPanel.tsx create mode 100644 frontend/src/features/live/components/VoiceConnectionCard.tsx create mode 100644 frontend/src/features/live/components/index.ts rename frontend/src/{ => features/live}/hooks/useMediaControl.ts (77%) rename frontend/src/{ => features/live}/hooks/useVoiceControl.ts (91%) create mode 100644 frontend/src/features/live/index.tsx create mode 100644 frontend/src/features/messages/components/ImageGrid.tsx rename frontend/src/{components/messages => features/messages/components}/MessageCard.tsx (77%) rename frontend/src/{components/messages => features/messages/components}/MessageFeed.tsx (52%) rename frontend/src/{ => features/messages}/hooks/useMessages.ts (77%) rename frontend/src/{components/messages/MessagesPanel.tsx => features/messages/index.tsx} (62%) delete mode 100644 frontend/src/hooks/useDashboardSocket.ts delete mode 100644 frontend/src/hooks/useReview.ts delete mode 100644 frontend/src/hooks/useUIState.ts create mode 100644 frontend/src/shared/api/client.ts create mode 100644 frontend/src/shared/hooks/useAudioPlayback.ts create mode 100644 frontend/src/shared/hooks/useAudioTransmit.ts create mode 100644 frontend/src/shared/hooks/useLocalStorage.ts create mode 100644 frontend/src/shared/hooks/useUIState.ts rename frontend/src/{ => shared}/lib/utils.ts (72%) create mode 100644 frontend/src/shared/ui/MobileTabBar.tsx rename frontend/src/{components => shared}/ui/badge.tsx (96%) rename frontend/src/{components => shared}/ui/button.tsx (97%) rename frontend/src/{components => shared}/ui/card.tsx (96%) create mode 100644 frontend/src/shared/ui/index.ts rename frontend/src/{components => shared}/ui/input.tsx (73%) rename frontend/src/{components => shared}/ui/scroll-area.tsx (85%) rename frontend/src/{components => shared}/ui/select.tsx (80%) create mode 100644 frontend/src/shared/ui/skeleton.tsx rename frontend/src/{components => shared}/ui/tabs.tsx (97%) create mode 100644 frontend/src/shared/ui/toast.tsx create mode 100644 frontend/src/shared/ws/events.ts create mode 100644 frontend/src/shared/ws/socket.ts delete mode 100644 frontend/src/types/browser.ts delete mode 100644 frontend/src/types/messages.ts rename frontend/src/{components/layout => widgets}/DashboardLayout.tsx (80%) rename frontend/src/{components/layout => widgets}/Header.tsx (85%) create mode 100644 frontend/src/widgets/Sidebar.tsx delete mode 100644 frontend/src/ws/client.ts 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) => (