diff --git a/services/discord-gateway/src/modules/voice-recording/transmitter.ts b/services/discord-gateway/src/modules/voice-recording/transmitter.ts index e933964..07ccec2 100644 --- a/services/discord-gateway/src/modules/voice-recording/transmitter.ts +++ b/services/discord-gateway/src/modules/voice-recording/transmitter.ts @@ -28,9 +28,6 @@ export class VoiceTransmitter { /** Set true before sending SIGTERM so exit handler knows it's intentional */ private _expectedExit = false; - /** True while backpressure drain is in progress */ - private draining = false; - /** * Start listening for PCM audio data from Redis and stream to Discord */ diff --git a/services/frontend/src/app/(dashboard)/analysis/page.tsx b/services/frontend/src/app/(dashboard)/analysis/page.tsx index a28d231..e54f007 100644 --- a/services/frontend/src/app/(dashboard)/analysis/page.tsx +++ b/services/frontend/src/app/(dashboard)/analysis/page.tsx @@ -2,55 +2,44 @@ import { Loader2, RefreshCw, Search, Sparkles } from "lucide-react"; import { useCallback, useState } from "react"; - +import { EmptyState, LoadingSkeleton } from "@/components/shared"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Progress } from "@/components/ui/progress"; -import { Skeleton } from "@/components/ui/skeleton"; -import { messagesApi } from "@/lib/api"; +import { useSearch } from "@/hooks"; import { safeParseJsonArray } from "@/lib/format"; import type { MessageRecord } from "@/lib/types"; import { cn } from "@/lib/utils"; export default function AnalysisPage() { + const { results, searching, search } = useSearch(); const [query, setQuery] = useState(""); - const [results, setResults] = useState(null); - const [searching, setSearching] = useState(false); const [searched, setSearched] = useState(false); - const handleSearch = useCallback(async () => { + const handleSearch = useCallback(() => { if (!query.trim()) return; - setSearching(true); setSearched(true); - try { - const result = await messagesApi.search(query, 50); - setResults(result.results); - } catch { - setResults([]); - } finally { - setSearching(false); - } - }, [query]); + search(query); + }, [query, search]); const handleReanalyze = useCallback(async (id: string) => { + const { messagesApi } = await import("@/lib/api"); try { await messagesApi.reanalyze(id); } catch { - // ignore + /* ignore */ } }, []); return (
- {/* Search */}
setQuery(e.target.value)} @@ -64,112 +53,27 @@ export default function AnalysisPage() {
- {/* Results */} {searching ? ( -
- {Array.from({ length: 5 }, (_, i) => ( - - ))} -
+ ) : results !== null ? ( <>

- Found {results.length} result - {results.length !== 1 ? "s" : ""} + Found {results.length} result{results.length !== 1 ? "s" : ""}

{results.length === 0 ? ( -
- -

- No messages found matching your query. -

-
+ ) : (
{results.map((msg) => ( - - -
- - - - {msg.username.charAt(0).toUpperCase()} - - - -
-
- - {msg.username} - - - {new Date(msg.created_at).toLocaleString()} - - {msg.ai_status && ( - - {msg.ai_status} - - )} -
- -

{msg.content}

- - {msg.ai_moderation_flags && - msg.ai_moderation_flags !== "[]" && ( -
- {safeParseJsonArray(msg.ai_moderation_flags).map( - (flag) => ( - - {flag} - - ), - )} -
- )} - - {msg.ai_analysis && ( -

- - {msg.ai_analysis} -

- )} - - {msg.ai_confidence != null && ( -
- - - {(msg.ai_confidence * 100).toFixed(0)}% - -
- )} - - -
-
-
-
+ ))}
)} @@ -188,3 +92,88 @@ export default function AnalysisPage() {
); } + +function SearchResultCard({ + message: msg, + onReanalyze, +}: { + message: MessageRecord; + onReanalyze: (id: string) => void; +}) { + return ( + + +
+ + + + {msg.username.charAt(0).toUpperCase()} + + + +
+
+ {msg.username} + + {new Date(msg.created_at).toLocaleString()} + + {msg.ai_status && ( + + {msg.ai_status} + + )} +
+ +

{msg.content}

+ + {msg.ai_moderation_flags && msg.ai_moderation_flags !== "[]" && ( +
+ {safeParseJsonArray(msg.ai_moderation_flags).map((flag) => ( + + {flag} + + ))} +
+ )} + + {msg.ai_analysis && ( +

+ + {msg.ai_analysis} +

+ )} + + {msg.ai_confidence != null && ( +
+ + + {(msg.ai_confidence * 100).toFixed(0)}% + +
+ )} + + +
+
+
+
+ ); +} diff --git a/services/frontend/src/app/(dashboard)/dashboard/page.tsx b/services/frontend/src/app/(dashboard)/dashboard/page.tsx index ea3b82b..27b89d1 100644 --- a/services/frontend/src/app/(dashboard)/dashboard/page.tsx +++ b/services/frontend/src/app/(dashboard)/dashboard/page.tsx @@ -7,33 +7,30 @@ import { ChevronRight, Clock, Hash, - RefreshCw, Search, Shield, Sparkles, Users, } from "lucide-react"; import Image from "next/image"; -import { useCallback, useEffect, useState } from "react"; +import { useEffect, useState } from "react"; +import { + DetailStat, + EmptyState, + ErrorState, + LoadingSkeleton, + StatCard, +} from "@/components/shared"; import { GuildSelector } from "@/components/shared/guild-selector"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Progress } from "@/components/ui/progress"; -import { Skeleton } from "@/components/ui/skeleton"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { dashboardApi } from "@/lib/api"; +import { useChannels, useStats, useUsers } from "@/hooks"; import { formatNumber } from "@/lib/format"; -import type { - DashboardChannel, - DashboardChannelDetail, - DashboardStats, - DashboardUser, - DashboardUserDetail, -} from "@/lib/types"; -import { cn } from "@/lib/utils"; -import { useWebSocket } from "@/lib/ws/context"; +import type { DashboardChannelDetail, DashboardUserDetail } from "@/lib/types"; type View = "stats" | "users" | "channels" | "user-detail" | "channel-detail"; @@ -46,60 +43,6 @@ export default function DashboardPage() { const [activeChannel, setActiveChannel] = useState(null); - // WS connection for real-time awareness - useWebSocket(); - - const renderView = () => { - switch (view) { - case "stats": - return ; - case "users": - return ( - { - try { - const detail = await dashboardApi.getUserDetail(userId); - setActiveUser(detail); - setView("user-detail"); - } catch { - // ignore - } - }} - /> - ); - case "channels": - return ( - { - try { - const detail = await dashboardApi.getChannelDetail(channelId); - setActiveChannel(detail); - setView("channel-detail"); - } catch { - // ignore - } - }} - /> - ); - case "user-detail": - return activeUser ? ( - setView("users")} /> - ) : ( - {}} /> - ); - case "channel-detail": - return activeChannel ? ( - setView("channels")} - /> - ) : ( - {}} /> - ); - } - }; - return (
@@ -130,260 +73,221 @@ export default function DashboardPage() { - {renderView()} + {view === "stats" && } + {view === "users" && ( + { + try { + const { dashboardApi } = await import("@/lib/api"); + const detail = await dashboardApi.getUserDetail(userId); + setActiveUser(detail); + setView("user-detail"); + } catch { + /* ignore */ + } + }} + /> + )} + {view === "channels" && ( + { + try { + const { dashboardApi } = await import("@/lib/api"); + const detail = await dashboardApi.getChannelDetail(chId); + setActiveChannel(detail); + setView("channel-detail"); + } catch { + /* ignore */ + } + }} + /> + )} + {view === "user-detail" && activeUser && ( + setView("users")} /> + )} + {view === "channel-detail" && activeChannel && ( + setView("channels")} + /> + )}
); } -// ── Stats View ────────────────────────────────── +// ── Stats Section ─────────────────────────────── -function StatsView() { - const [stats, setStats] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); +function StatsSection() { + const { stats, loading, error, refetch } = useStats(); - const fetchStats = useCallback(async () => { - setLoading(true); - setError(null); - try { - const data = await dashboardApi.getStats(); - setStats(data); - } catch (err) { - setError(err instanceof Error ? err.message : "Failed to load stats"); - } finally { - setLoading(false); - } - }, []); + if (error) return ; - useEffect(() => { - fetchStats(); - }, [fetchStats]); - - if (error) { + if (loading || !stats) { return ( -
- -

{error}

- +
+
); } return (
- {loading ? ( -
- {Array.from({ length: 8 }, (_, i) => ( - - ))} -
- ) : stats ? ( - <> -
- - - - - - - - -
+
+ + + + + + + + +
-
- - - - - Top Channels - - - - {stats.top_channels.length === 0 ? ( -

- No channel data yet. -

- ) : ( -
- {stats.top_channels.map((ch, _i) => { - const maxCount = stats.top_channels[0].message_count; - const pct = - maxCount > 0 ? (ch.message_count / maxCount) * 100 : 0; - return ( -
-
- - #{ch.channel_name ?? ch.channel_id.slice(0, 8)} - - - {formatNumber(ch.message_count)} - -
- -
- ); - })} -
- )} -
-
+
+ + + + + Top Channels + + + + {stats.top_channels.length === 0 ? ( +

+ No channel data yet. +

+ ) : ( +
+ {stats.top_channels.map((ch) => { + const max = stats.top_channels[0].message_count; + const pct = max > 0 ? (ch.message_count / max) * 100 : 0; + return ( +
+
+ + #{ch.channel_name ?? ch.channel_id.slice(0, 8)} + + + {formatNumber(ch.message_count)} + +
+ +
+ ); + })} +
+ )} +
+
- - - - - Moderation Queue - - - -
-
-
- {stats.moderation_overview.pending} -
-
Pending
-
-
-
- {stats.moderation_overview.processing} -
-
- Processing -
-
-
-
- {stats.moderation_overview.error} -
-
Errors
-
-
-
-
-
- - ) : null} + + + + + Moderation Queue + + + +
+ + + +
+
+
+
); } -function StatCard({ +function QueueStat({ label, value, variant, - icon: Icon, }: { label: string; value: number; - variant?: "default" | "danger" | "success"; - icon: React.ComponentType<{ className?: string }>; + variant?: "default" | "warning" | "danger"; }) { return ( - - -
-
-

{label}

-

- {formatNumber(value)} -

-
-
- -
-
-
-
+
+
+ {value} +
+
{label}
+
); } -// ── Users View ────────────────────────────────── +// ── Users Section ─────────────────────────────── -function UsersView({ - onSelectUser, -}: { - onSelectUser: (userId: string) => void; -}) { - const [users, setUsers] = useState([]); - const [loading, setLoading] = useState(true); - const [_cursor, setCursor] = useState(null); - const [search, setSearch] = useState(""); - - const fetchUsers = useCallback(async (searchQuery?: string) => { - setLoading(true); - try { - const result = await dashboardApi.listUsers(20, undefined, searchQuery); - setUsers(result.data); - setCursor(result.nextCursor); - } catch { - // ignore - } finally { - setLoading(false); - } - }, []); +function UsersSection({ onSelect }: { onSelect: (id: string) => void }) { + const { users, loading, search, setSearch, refetch } = useUsers(); useEffect(() => { - fetchUsers(); - }, [fetchUsers]); - - useEffect(() => { - const timer = setTimeout(() => { - if (search) fetchUsers(search); - else fetchUsers(); - }, 300); + const timer = setTimeout(refetch, 300); return () => clearTimeout(timer); - }, [search, fetchUsers]); + }, [refetch]); return (
setSearch(e.target.value)} @@ -392,118 +296,81 @@ function UsersView({
{loading ? ( -
- {Array.from({ length: 6 }, (_, i) => ( - - ))} -
+ + ) : users.length === 0 ? ( + ) : (
- {users.length === 0 ? ( -
- -

No users found.

-
- ) : ( - users.map((user) => ( - onSelectUser(user.user_id)} - > - -
-
- {user.avatar_url ? ( - - ) : ( - (user.username ?? "?").charAt(0).toUpperCase() - )} -
-
-

- {user.username ?? "Unknown"} -

-

- {user.total_messages} messages - {user.flagged_count > 0 && ( - - {user.flagged_count} flagged - - )} -

-
- + {users.map((user) => ( + onSelect(user.user_id)} + > + +
+
+ {user.avatar_url ? ( + + ) : ( + (user.username ?? "?").charAt(0).toUpperCase() + )}
- - - )) - )} +
+

+ {user.username ?? "Unknown"} +

+

+ {user.total_messages} messages + {user.flagged_count > 0 && ( + + {user.flagged_count} flagged + + )} +

+
+ +
+
+
+ ))}
)}
); } -// ── Channels View ─────────────────────────────── +// ── Channels Section ──────────────────────────── -function ChannelsView({ - onSelectChannel, +function ChannelsSection({ guildId, + onSelect, }: { - onSelectChannel: (channelId: string) => void; guildId: string; + onSelect: (id: string) => void; }) { - const [channels, setChannels] = useState([]); - const [loading, setLoading] = useState(true); - const [search, setSearch] = useState(""); - - const fetchChannels = useCallback( - async (searchQuery?: string) => { - setLoading(true); - try { - const result = await dashboardApi.listChannels( - 20, - searchQuery, - guildId || undefined, - ); - setChannels(result.data); - } catch { - // ignore - } finally { - setLoading(false); - } - }, - [guildId], - ); + const { channels, loading, search, setSearch, refetch } = + useChannels(guildId); useEffect(() => { - fetchChannels(); - }, [fetchChannels]); - - useEffect(() => { - const timer = setTimeout(() => { - if (search) fetchChannels(search); - else fetchChannels(); - }, 300); + const timer = setTimeout(refetch, 300); return () => clearTimeout(timer); - }, [search, fetchChannels]); + }, [refetch]); return (
setSearch(e.target.value)} @@ -512,59 +379,48 @@ function ChannelsView({
{loading ? ( -
- {Array.from({ length: 6 }, (_, i) => ( - - ))} -
+ + ) : channels.length === 0 ? ( + ) : (
- {channels.length === 0 ? ( -
- -

- No channels found. -

-
- ) : ( - channels.map((ch) => ( - onSelectChannel(ch.channel_id)} - > - -
-
-
- -

- {ch.channel_name ?? ch.channel_id.slice(0, 8)} -

-
-

- {ch.total_messages} messages - {ch.flagged_count > 0 && ( - - {ch.flagged_count} flagged - - )} + {channels.map((ch) => ( + onSelect(ch.channel_id)} + > + +

+
+
+ +

+ {ch.channel_name ?? ch.channel_id.slice(0, 8)}

- -
- {ch.culture_summary && ( -

- “{ch.culture_summary}” +

+ {ch.total_messages} messages + {ch.flagged_count > 0 && ( + + {ch.flagged_count} flagged + + )}

- )} - - - )) - )} +
+ +
+ {ch.culture_summary && ( +

+ “{ch.culture_summary}” +

+ )} + + + ))}
)}
@@ -582,12 +438,10 @@ function UserDetailView({ }) { return (
-
- -
+ @@ -609,7 +463,7 @@ function UserDetailView({

{user.username ?? "Unknown"}

-

+

{user.user_id}

@@ -684,12 +538,10 @@ function ChannelDetailView({ }) { return (
-
- -
+ @@ -698,7 +550,7 @@ function ChannelDetailView({ {channel.channel_name ?? channel.channel_id.slice(0, 8)} -

+

{channel.channel_id}

@@ -762,35 +614,3 @@ function ChannelDetailView({
); } - -// ── Shared Components ─────────────────────────── - -function DetailStat({ - label, - value, - variant, - suffix, -}: { - label: string; - value: number; - variant?: "default" | "danger" | "success"; - suffix?: string; -}) { - return ( - - -

{label}

-

- {formatNumber(value)} - {suffix} -

-
-
- ); -} diff --git a/services/frontend/src/app/(dashboard)/layout.tsx b/services/frontend/src/app/(dashboard)/layout.tsx index 06eaf4e..d2e9ea2 100644 --- a/services/frontend/src/app/(dashboard)/layout.tsx +++ b/services/frontend/src/app/(dashboard)/layout.tsx @@ -2,24 +2,12 @@ import { Suspense } from "react"; -import { Header } from "@/components/layout/header"; -import { MobileTabBar } from "@/components/layout/mobile-tab-bar"; -import { Sidebar } from "@/components/layout/sidebar"; -import { MascotChatbot } from "@/components/mascot/mascot-chatbot"; -import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"; +import { Chatbot } from "@/components/chatbot/chatbot"; +import { AppHeader } from "@/components/layout/app-header"; +import { AppSidebar } from "@/components/layout/app-sidebar"; +import { MobileNav } from "@/components/layout/mobile-nav"; import { WsProvider } from "@/lib/ws/context"; -function LoadingFallback() { - return ( -
-
-
-

Loading dashboard…

-
-
- ); -} - export default function DashboardLayout({ children, }: { @@ -27,19 +15,25 @@ export default function DashboardLayout({ }) { return ( - -
- - -
-
- }>{children} -
- - +
+ +
+ +
+ +
+
+ } + > + {children} +
+
- - + +
+ ); } diff --git a/services/frontend/src/app/(dashboard)/media/page.tsx b/services/frontend/src/app/(dashboard)/media/page.tsx index ac9b627..d01d181 100644 --- a/services/frontend/src/app/(dashboard)/media/page.tsx +++ b/services/frontend/src/app/(dashboard)/media/page.tsx @@ -8,81 +8,29 @@ import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Slider } from "@/components/ui/slider"; -import { voiceApi } from "@/lib/api"; -import type { MediaState } from "@/lib/types"; +import { useMediaState } from "@/hooks"; import { useWebSocket } from "@/lib/ws/context"; export default function MediaPage() { const ws = useWebSocket(); - - const [mediaState, setMediaState] = useState(null); + const { mediaState, refresh, queue, skip, stop, setVolume } = useMediaState(); const [queueUrl, setQueueUrl] = useState(""); - const fetchMediaStatus = useCallback(async () => { - try { - const state = await voiceApi.getMediaStatus(); - setMediaState(state); - } catch { - // ignore - } - }, []); - useEffect(() => { - fetchMediaStatus(); - }, [fetchMediaStatus]); + refresh(); + }, [refresh]); - // WS subscription + // WS subscription for real-time media state useEffect(() => { - const unsubMedia = ws.on("media_state", (state) => { - setMediaState(state as MediaState); - }); + const unsub = ws.on("media_state", () => refresh()); + return unsub; + }, [ws, refresh]); - return () => { - unsubMedia(); - }; - }, [ws]); - - const handleQueueMedia = useCallback(async () => { + const handleQueue = useCallback(() => { if (!queueUrl.trim()) return; - try { - const state = await voiceApi.mediaQueue(queueUrl.trim(), "music"); - setMediaState(state); - setQueueUrl(""); - } catch { - // ignore - } - }, [queueUrl]); - - const handleSkip = useCallback(async () => { - try { - const state = await voiceApi.mediaSkip(); - setMediaState(state); - } catch { - // ignore - } - }, []); - - const handleStop = useCallback(async () => { - try { - const state = await voiceApi.mediaStop(); - setMediaState(state); - } catch { - // ignore - } - }, []); - - const handleVolume = useCallback( - async (value: number | readonly number[]) => { - const vol = Array.isArray(value) ? value[0] : value; - try { - const state = await voiceApi.mediaVolume(vol); - setMediaState(state); - } catch { - // ignore - } - }, - [], - ); + queue(queueUrl.trim()); + setQueueUrl(""); + }, [queueUrl, queue]); return (
@@ -94,23 +42,20 @@ export default function MediaPage() { - {/* Queue URL */}
setQueueUrl(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && handleQueueMedia()} + onKeyDown={(e) => e.key === "Enter" && handleQueue()} className="flex-1 h-9" /> -
- {/* Now Playing */} {mediaState?.current && (

@@ -133,9 +78,7 @@ export default function MediaPage() {

{mediaState.current.durationMs - ? `${Math.floor( - mediaState.current.durationMs / 60000, - )}:${String( + ? `${Math.floor(mediaState.current.durationMs / 60000)}:${String( Math.floor( (mediaState.current.durationMs % 60000) / 1000, ), @@ -153,13 +96,12 @@ export default function MediaPage() {

)} - {/* Controls */}
- - @@ -169,7 +111,7 @@ export default function MediaPage() { className="w-24" defaultValue={[mediaState?.musicVolume ?? 0.5]} value={[mediaState?.musicVolume ?? 0.5]} - onValueChange={handleVolume} + onValueChange={setVolume} min={0} max={1} step={0.05} @@ -177,7 +119,6 @@ export default function MediaPage() {
- {/* Queue */} {mediaState && mediaState.queue.length > 0 && (

diff --git a/services/frontend/src/app/(dashboard)/messages/page.tsx b/services/frontend/src/app/(dashboard)/messages/page.tsx index e51f99c..55a752d 100644 --- a/services/frontend/src/app/(dashboard)/messages/page.tsx +++ b/services/frontend/src/app/(dashboard)/messages/page.tsx @@ -1,9 +1,9 @@ "use client"; import { - AlertCircle, ExternalLink, Flag, + Hash, Loader2, MessageSquare, RefreshCw, @@ -11,7 +11,8 @@ import { Sparkles, } from "lucide-react"; import Image from "next/image"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { ErrorState, LoadingSkeleton } from "@/components/shared"; import { GuildSelector } from "@/components/shared/guild-selector"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Badge } from "@/components/ui/badge"; @@ -33,222 +34,116 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { Skeleton } from "@/components/ui/skeleton"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { messagesApi, voiceApi } from "@/lib/api"; +import { + useImages, + useMessageDetail, + useMessages, + useMessageWsSubscription, + useReview, + useSearch, + useTextChannels, +} from "@/hooks"; import { formatBytes, safeParseJsonArray } from "@/lib/format"; -import type { AttachmentRecord, Channel, MessageRecord } from "@/lib/types"; +import type { MessageRecord } from "@/lib/types"; import { cn } from "@/lib/utils"; import { useWebSocket } from "@/lib/ws/context"; export default function MessagesPage() { const [guildId, setGuildId] = useState(""); - const [messages, setMessages] = useState([]); - const [loading, setLoading] = useState(true); - const [loadingMore, setLoadingMore] = useState(false); - const [error, setError] = useState(null); - const [cursor, setCursor] = useState(null); - const [hasMore, setHasMore] = useState(true); - const [searchQuery, setSearchQuery] = useState(""); - const [searchResults, setSearchResults] = useState( - null, - ); - const [_searching, setSearching] = useState(false); - const [viewTab, setViewTab] = useState<"all" | "images" | "review">("all"); - const [imageMessages, setImageMessages] = useState([]); - const [reviewMessages, setReviewMessages] = useState([]); - const [channels, setChannels] = useState([]); - const [detailMessage, setDetailMessage] = useState( - null, - ); - const [detailAttachments, setDetailAttachments] = useState< - AttachmentRecord[] - >([]); - const [detailLoading, setDetailLoading] = useState(false); const [selectedChannel, setSelectedChannel] = useState(""); const ws = useWebSocket(); + const { channels } = useTextChannels(guildId); + const { + messages, + loading, + loadingMore, + error, + hasMore, + refetch, + loadMore, + prepend, + update, + remove, + } = useMessages(guildId, selectedChannel || undefined); + const { images, refetch: refetchImages } = useImages(guildId); + const { reviews, refetch: refetchReviews } = useReview( + selectedChannel || undefined, + ); + const { results: searchResults, searching, search } = useSearch(); + const { + message: detailMessage, + attachments: detailAttachments, + loading: detailLoading, + open: openDetail, + close: closeDetail, + } = useMessageDetail(); - // Fetch channels when guild changes + const [viewTab, setViewTab] = useState<"all" | "images" | "review">("all"); + const [searchQuery, setSearchQuery] = useState(""); + + // WS real-time subscriptions + const handleCreated = useCallback( + (msg: MessageRecord) => prepend(msg), + [prepend], + ); + const handleUpdated = useCallback( + (msg: MessageRecord) => update(msg), + [update], + ); + const handleDeleted = useCallback((id: string) => remove(id), [remove]); + const handleAnalyzed = useCallback( + (msg: MessageRecord) => update(msg), + [update], + ); + + useMessageWsSubscription( + ws, + guildId, + handleCreated, + handleUpdated, + handleDeleted, + handleAnalyzed, + ); + + // Fetch images on mount and when guild changes useEffect(() => { - if (!guildId) return; - voiceApi - .getTextChannels(guildId) - .then(setChannels) - .catch(() => {}); - }, [guildId]); - - const fetchMessages = useCallback(async () => { - if (!guildId) return; - setLoading(true); - setError(null); - try { - const result = await messagesApi.list( - guildId, - 50, - selectedChannel || undefined, - ); - setMessages(result.data); - setCursor(result.nextCursor); - setHasMore(result.nextCursor !== null); - } catch (err) { - setError(err instanceof Error ? err.message : "Failed to load messages"); - } finally { - setLoading(false); - } - }, [guildId, selectedChannel]); - - const fetchImages = useCallback(async () => { - if (!guildId) return; - try { - const result = await messagesApi.getImages(guildId, 50); - setImageMessages(result.data); - } catch { - // silently fail - } - }, [guildId]); - - const fetchReview = useCallback(async () => { - try { - const result = await messagesApi.getReview( - 50, - selectedChannel || undefined, - ); - setReviewMessages(result.results); - } catch { - // silently fail - } - }, [selectedChannel]); + refetchImages(); + }, [refetchImages]); + // Fetch reviews when tab switches useEffect(() => { - fetchMessages(); - fetchImages(); - }, [fetchMessages, fetchImages]); - - useEffect(() => { - if (viewTab === "review") fetchReview(); - }, [viewTab, fetchReview]); - - // WS subscriptions - useEffect(() => { - if (!guildId) return; - const unsubCreated = ws.on("message_created", (msg) => { - setMessages((prev) => [msg as MessageRecord, ...prev]); - }); - const unsubUpdated = ws.on("message_updated", (msg) => { - setMessages((prev) => - prev.map((m) => - (msg as MessageRecord).id === m.id ? (msg as MessageRecord) : m, - ), - ); - }); - const unsubDeleted = ws.on("message_deleted", (id) => { - setMessages((prev) => - prev.filter((m) => m.id !== (id as unknown as string)), - ); - }); - const unsubAnalyzed = ws.on("message_analyzed", (msg) => { - setMessages((prev) => - prev.map((m) => - (msg as MessageRecord).id === m.id ? (msg as MessageRecord) : m, - ), - ); - }); - - return () => { - unsubCreated(); - unsubUpdated(); - unsubDeleted(); - unsubAnalyzed(); - }; - }, [ws, guildId]); - - const handleSearch = useCallback(async () => { - if (!searchQuery.trim()) { - setSearchResults(null); - return; - } - setSearching(true); - try { - const result = await messagesApi.search(searchQuery, 50); - setSearchResults(result.results); - } catch { - setSearchResults([]); - } finally { - setSearching(false); - } - }, [searchQuery]); - - const handleLoadMore = useCallback(async () => { - if (!cursor || loadingMore) return; - setLoadingMore(true); - try { - const result = await messagesApi.list( - guildId, - 50, - selectedChannel || undefined, - cursor, - ); - setMessages((prev) => [...prev, ...result.data]); - setCursor(result.nextCursor); - setHasMore(result.nextCursor !== null); - } catch { - // ignore - } finally { - setLoadingMore(false); - } - }, [cursor, loadingMore, guildId, selectedChannel]); - - const handleMessageClick = useCallback(async (id: string) => { - setDetailLoading(true); - setDetailAttachments([]); - try { - const detail = await messagesApi.getDetail(id); - setDetailMessage(detail); - if (detail.channel_id && id) { - messagesApi - .getAttachments(detail.channel_id, 10) - .then((res) => setDetailAttachments(res.data)) - .catch(() => {}); - } - } catch { - setDetailMessage(null); - } finally { - setDetailLoading(false); - } - }, []); + if (viewTab === "review") refetchReviews(); + }, [viewTab, refetchReviews]); const handleReanalyze = useCallback(async (id: string) => { + const { messagesApi } = await import("@/lib/api"); try { await messagesApi.reanalyze(id); } catch { - // ignore + /* ignore */ } }, []); const handleReanalyzeBatch = useCallback(async () => { + if (!guildId) return; + const { messagesApi } = await import("@/lib/api"); try { await messagesApi.reanalyzeBatch(guildId); } catch { - // ignore + /* ignore */ } }, [guildId]); const displayMessages = searchResults ?? messages; - const isEmpty = !loading && displayMessages.length === 0; + const _isEmpty = !loading && displayMessages.length === 0; if (error) { return (

-
- -

{error}

- -
+
); } @@ -262,11 +157,10 @@ export default function MessagesPage() {
setSearchQuery(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && handleSearch()} + onKeyDown={(e) => e.key === "Enter" && search(searchQuery)} className="pl-9 h-9" />
@@ -296,26 +190,21 @@ export default function MessagesPage() {
- {/* Tab bar */} + {/* Tabs */} setViewTab(v as "all" | "images" | "review")} > - setViewTab("all")}> - All ({messages.length}) - - setViewTab("images")}> - Images ({imageMessages.length}) - - setViewTab("review")}> + All ({messages.length}) + Images ({images.length}) + - Review ({reviewMessages.length}) + Review ({reviews.length}) - {/* Search results count */} {searchResults !== null && (

Found {searchResults.length} result @@ -323,138 +212,35 @@ export default function MessagesPage() {

)} - {/* Messages feed */} - {viewTab === "all" ? ( -
- {loading ? ( -
- {Array.from({ length: 8 }, (_, i) => ( - - ))} -
- ) : isEmpty ? ( -
- -

- {searchResults !== null - ? "No messages found matching your search." - : "No captures yet."} -

-
- ) : ( - <> - {displayMessages.map((msg) => ( - - ))} - - {hasMore && searchResults === null && ( -
- -
- )} - - )} -
- ) : viewTab === "images" ? ( -
- {imageMessages.length === 0 ? ( -
- -

No images yet.

-
- ) : ( - imageMessages.map((msg) => { - let imageUrl: string | null = null; - try { - const meta = JSON.parse(msg.metadata ?? "{}"); - const attachments: Array<{ - url: string; - contentType?: string; - }> = meta.attachments ?? []; - const img = attachments.find((a) => - a.contentType?.startsWith("image/"), - ); - imageUrl = img?.url ?? null; - } catch { - // metadata malformed - } - - return ( - handleMessageClick(msg.id)} - > -
- {imageUrl ? ( - {msg.content - ) : ( -
- No image -
- )} - {msg.content && ( -
-

- {msg.username}: {msg.content} -

-
- )} -
-
- ); - }) - )} -
- ) : ( - /* Review tab */ -
- {reviewMessages.length === 0 ? ( -
- -

- No flagged messages to review. -

-
- ) : ( - reviewMessages.map((msg) => ( - - )) - )} -
+ {viewTab === "all" && ( + )} - {/* Message Detail Dialog */} + {viewTab === "images" && ( + openDetail(id)} /> + )} + + {viewTab === "review" && ( + + )} + + {/* Detail dialog */} { - if (!open) setDetailMessage(null); - }} + onOpenChange={(o) => !o && closeDetail()} > @@ -463,185 +249,12 @@ export default function MessagesPage() { Message Detail - -
- {detailLoading ? ( -
- -
- ) : detailMessage ? ( - <> -
- - - - {detailMessage.username.charAt(0).toUpperCase()} - - -
-
- - {detailMessage.username} - - - {new Date(detailMessage.created_at).toLocaleString()} - - {detailMessage.type === "deleted" && ( - - deleted - - )} - {detailMessage.type === "edited" && ( - - edited - - )} -
-

- {detailMessage.content} -

-
-
- - {detailMessage.ai_analysis && ( -
-
- -

- AI Analysis -

-
-

- {detailMessage.ai_analysis} -

-
- )} - - {detailMessage.ai_moderation_flags && - detailMessage.ai_moderation_flags !== "[]" && ( -
-

- Moderation Flags -

-
- {safeParseJsonArray( - detailMessage.ai_moderation_flags, - ).map((flag) => ( - - {flag} - - ))} -
-
- )} - -
- {detailMessage.ai_status && ( - - -

- Status -

-

- {detailMessage.ai_status} -

-
-
- )} - {detailMessage.ai_severity && - detailMessage.ai_severity !== "none" && ( - - -

- Severity -

-

- {detailMessage.ai_severity} -

-
-
- )} - {detailMessage.ai_confidence != null && ( - - -

- Confidence -

-

- {(detailMessage.ai_confidence * 100).toFixed(0)}% -

-
-
- )} - {detailMessage.ai_recommended_action && - detailMessage.ai_recommended_action !== "none" && ( - - -

- Action -

-

- {detailMessage.ai_recommended_action} -

-
-
- )} -
- - {detailAttachments.length > 0 && ( -
-

- Attachments ({detailAttachments.length}) -

- -
- )} - - {detailMessage.metadata && - detailMessage.metadata !== "{}" && ( -
-

- Metadata (raw) -

-
-                          {JSON.stringify(
-                            safeParseObject(detailMessage.metadata),
-                            null,
-                            2,
-                          )}
-                        
-
- )} - - ) : null} -
+
@@ -649,6 +262,164 @@ export default function MessagesPage() { ); } +// ── Feed Sub-components ───────────────────────── + +function MessageFeed({ + messages, + searchResult, + loading, + hasMore, + loadingMore, + onLoadMore, + onMessageClick, + onReanalyze, +}: { + messages: MessageRecord[]; + searchResult: boolean; + loading: boolean; + hasMore: boolean; + loadingMore: boolean; + onLoadMore: () => void; + onMessageClick: (id: string) => void; + onReanalyze: (id: string) => void; +}) { + if (loading) return ; + + if (messages.length === 0) { + return ( +
+ +

+ {searchResult + ? "No messages found matching your search." + : "No captures yet."} +

+
+ ); + } + + return ( +
+ {messages.map((msg) => ( + + ))} + + {hasMore && ( +
+ +
+ )} +
+ ); +} + +function ImageGrid({ + images, + onImageClick, +}: { + images: MessageRecord[]; + onImageClick: (id: string) => void; +}) { + if (images.length === 0) { + return ( +
+ +

No images yet.

+
+ ); + } + + return ( +
+ {images.map((msg) => ( + + ))} +
+ ); +} + +function ImageCard({ + message: msg, + onClick, +}: { + message: MessageRecord; + onClick: (id: string) => void; +}) { + const imageUrl = useMemo(() => extractImageUrl(msg.metadata), [msg.metadata]); + + return ( + onClick(msg.id)} + > +
+ {imageUrl ? ( + {msg.content + ) : ( +
+ No image +
+ )} + {msg.content && ( +
+

+ {msg.username}: {msg.content} +

+
+ )} +
+
+ ); +} + +function ReviewFeed({ + messages, + onMessageClick, + onReanalyze, +}: { + messages: MessageRecord[]; + onMessageClick: (id: string) => void; + onReanalyze: (id: string) => void; +}) { + if (messages.length === 0) { + return ( +
+ +

+ No flagged messages to review. +

+
+ ); + } + + return ( +
+ {messages.map((msg) => ( + + ))} +
+ ); +} + // ── Message Card ──────────────────────────────── function MessageCard({ @@ -660,36 +431,22 @@ function MessageCard({ onClick: (id: string) => void; onReanalyze: (id: string) => void; }) { - const aiStatusColor: Record = { - clean: - "bg-green-500/15 text-green-600 dark:text-green-400 border-green-500/20", - warn: "bg-yellow-500/15 text-yellow-600 dark:text-yellow-400 border-yellow-500/20", - flagged: "bg-red-500/15 text-red-600 dark:text-red-400 border-red-500/20", - error: "bg-gray-500/15 text-gray-600 dark:text-gray-400 border-gray-500/20", - pending: - "bg-blue-500/15 text-blue-600 dark:text-blue-400 border-blue-500/20", - processing: - "bg-blue-500/15 text-blue-600 dark:text-blue-400 border-blue-500/20", - }; - - const severityLeftBorder: Record = { - low: "border-l-sky-400", - medium: "border-l-yellow-400", - high: "border-l-orange-400", - critical: "border-l-red-500", - }; - - const hasSeverity = - msg.ai_severity && - msg.ai_severity !== "none" && - severityLeftBorder[msg.ai_severity]; + const severityBorder = ( + { + low: "border-l-sky-400", + medium: "border-l-yellow-400", + high: "border-l-orange-400", + critical: "border-l-red-500", + } as Record + )[msg.ai_severity ?? ""]; + const hasSeverity = !!severityBorder; return ( onClick(msg.id)} > @@ -709,22 +466,10 @@ function MessageCard({ {new Date(msg.created_at).toLocaleString()} - + {msg.channel_id.slice(0, 8)} - - {msg.ai_status && aiStatusColor[msg.ai_status] && ( - - {msg.ai_status} - - )} - + {msg.ai_severity && msg.ai_severity !== "none" && ( )} - {msg.type === "deleted" && ( )} - {msg.ai_confidence !== undefined && msg.ai_confidence !== null && ( + {msg.ai_confidence != null && (
@@ -791,19 +535,17 @@ function MessageCard({
)} -
- -
+
@@ -811,46 +553,207 @@ function MessageCard({ ); } -// ── Helpers ───────────────────────────────────── +function AiBadge({ status }: { status?: string | null }) { + const colors: Record = { + clean: + "bg-green-500/15 text-green-600 dark:text-green-400 border-green-500/20", + warn: "bg-yellow-500/15 text-yellow-600 dark:text-yellow-400 border-yellow-500/20", + flagged: "bg-red-500/15 text-red-600 dark:text-red-400 border-red-500/20", + error: "bg-gray-500/15 text-gray-600 dark:text-gray-400 border-gray-500/20", + pending: + "bg-blue-500/15 text-blue-600 dark:text-blue-400 border-blue-500/20", + processing: + "bg-blue-500/15 text-blue-600 dark:text-blue-400 border-blue-500/20", + }; -function safeParseObject( - value: string | null | undefined, -): Record { - if (!value) return {}; - try { - const parsed = JSON.parse(value); - if (typeof parsed === "object" && parsed !== null) return parsed; - return {}; - } catch { - return {}; - } + if (!status || !colors[status]) return null; + + return ( + + {status} + + ); } -function HashIcon({ className }: { className?: string }) { +// ── Message Detail ────────────────────────────── + +function MessageDetail({ + message, + attachments, + loading, +}: { + message: MessageRecord | null; + attachments: { + id: string; + filename: string; + type: string; + size: number; + uploaded_url?: string | null; + discord_url?: string | null; + }[]; + loading: boolean; +}) { + if (loading) { + return ( +
+ +
+ ); + } + + if (!message) return null; + return ( - - Hash - - - - - +
+
+ + + + {message.username.charAt(0).toUpperCase()} + + +
+
+ {message.username} + + {new Date(message.created_at).toLocaleString()} + + {message.type === "deleted" && ( + + deleted + + )} + {message.type === "edited" && ( + + edited + + )} +
+

+ {message.content} +

+
+
+ + {message.ai_analysis && ( +
+
+ +

+ AI Analysis +

+
+

{message.ai_analysis}

+
+ )} + + {message.ai_moderation_flags && message.ai_moderation_flags !== "[]" && ( +
+

+ Moderation Flags +

+
+ {safeParseJsonArray(message.ai_moderation_flags).map((flag) => ( + + {flag} + + ))} +
+
+ )} + +
+ {message.ai_status && ( + + +

Status

+

+ {message.ai_status} +

+
+
+ )} + {message.ai_severity && message.ai_severity !== "none" && ( + + +

Severity

+

+ {message.ai_severity} +

+
+
+ )} + {message.ai_confidence != null && ( + + +

Confidence

+

+ {(message.ai_confidence * 100).toFixed(0)}% +

+
+
+ )} + {message.ai_recommended_action && + message.ai_recommended_action !== "none" && ( + + +

Action

+

+ {message.ai_recommended_action} +

+
+
+ )} +
+ + {attachments.length > 0 && ( +
+

+ Attachments ({attachments.length}) +

+ +
+ )} +
); } +// ── Helpers ───────────────────────────────────── + +function extractImageUrl(metadata: string | null | undefined): string | null { + if (!metadata) return null; + try { + const meta = JSON.parse(metadata); + const attachments: Array<{ url: string; contentType?: string }> = + meta.attachments ?? []; + const img = attachments.find((a) => a.contentType?.startsWith("image/")); + return img?.url ?? null; + } catch { + return null; + } +} + function ImageIcon({ className }: { className?: string }) { return ( ([]); - const [loading, setLoading] = useState(true); - - const fetchRecordings = useCallback(async () => { - setLoading(true); - try { - const result = await recordingsApi.list(50); - setRecordings(result.items); - } catch { - // ignore - } finally { - setLoading(false); - } - }, []); + const { recordings, loading, refresh, remove, prepend } = useRecordings(); useEffect(() => { - fetchRecordings(); - }, [fetchRecordings]); + refresh(); + }, [refresh]); - // WS subscription for live updates + // WS subscription for real-time updates useEffect(() => { - const unsub = ws.on("voice_recording_uploaded", (rec) => { - setRecordings((prev) => [rec as VoiceRecording, ...prev]); + const unsub = ws.on("voice_recording_uploaded", (data) => { + prepend(data as import("@/lib/types").VoiceRecording); }); - return () => unsub(); - }, [ws]); - - const handleDelete = useCallback(async (id: string) => { - try { - await recordingsApi.delete(id); - setRecordings((prev) => prev.filter((r) => r.id !== id)); - } catch { - // ignore - } - }, []); + return unsub; + }, [ws, prepend]); return (
@@ -61,18 +38,9 @@ export default function RecordingsPage() { {loading ? ( -
- {Array.from({ length: 5 }, (_, i) => ( -
- ))} -
+ ) : recordings.length === 0 ? ( -

- No recordings yet. -

+ ) : (
{recordings.map((rec) => ( @@ -117,7 +85,7 @@ export default function RecordingsPage() { + +

{pageTitle}

+ +
+ + + + {statusLabel} + + + + + + {/* Mobile overlay menu */} + {mobileOpen && ( +
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: overlay backdrop */} +
setMobileOpen(false)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") setMobileOpen(false); + }} + /> + +
+ )} + + ); +} + +function isActivePath(pathname: string, prefix: string) { + if (prefix === "/dashboard") return pathname === "/dashboard"; + return pathname.startsWith(prefix); +} diff --git a/services/frontend/src/components/layout/app-sidebar.tsx b/services/frontend/src/components/layout/app-sidebar.tsx new file mode 100644 index 0000000..ad441b8 --- /dev/null +++ b/services/frontend/src/components/layout/app-sidebar.tsx @@ -0,0 +1,106 @@ +"use client"; + +import { usePathname, useRouter } from "next/navigation"; + +import { navItems } from "@/lib/navigation"; +import { cn } from "@/lib/utils"; +import { useWebSocket } from "@/lib/ws/context"; + +export function AppSidebar() { + const pathname = usePathname(); + const router = useRouter(); + const { status } = useWebSocket(); + + const isActive = (prefix: string) => { + if (prefix === "/dashboard") return pathname === "/dashboard"; + return pathname.startsWith(prefix); + }; + + const connectionDot = { + connected: "bg-green-500", + connecting: "bg-yellow-500 animate-pulse", + disconnected: "bg-destructive", + error: "bg-destructive", + }[status]; + + const connectionLabel = { + connected: "Connected", + connecting: "Connecting", + disconnected: "Disconnected", + error: "Error", + }[status]; + + return ( + + ); +} diff --git a/services/frontend/src/components/layout/header.tsx b/services/frontend/src/components/layout/header.tsx deleted file mode 100644 index a966482..0000000 --- a/services/frontend/src/components/layout/header.tsx +++ /dev/null @@ -1,136 +0,0 @@ -"use client"; - -import { Moon, Sun } from "lucide-react"; -import { usePathname } from "next/navigation"; -import { useEffect, useState } from "react"; - -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { SidebarTrigger } from "@/components/ui/sidebar"; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@/components/ui/tooltip"; -import { navItems } from "@/lib/navigation"; -import { cn } from "@/lib/utils"; -import { useWebSocket } from "@/lib/ws/context"; - -function usePageTitle(): string { - const pathname = usePathname(); - - // Exact match first, then prefix match - const item = navItems.find((n) => { - if (n.matchPrefix === "/dashboard") return pathname === "/dashboard"; - return pathname.startsWith(n.matchPrefix); - }); - - if (item) return item.label; - - // Fallback: derive from pathname - const segment = pathname.split("/").filter(Boolean)[0]; - if (segment) { - return segment.charAt(0).toUpperCase() + segment.slice(1); - } - return "Dashboard"; -} - -export function Header() { - const { status } = useWebSocket(); - const pageTitle = usePageTitle(); - const [theme, setTheme] = useState<"light" | "dark">("dark"); - - useEffect(() => { - const stored = localStorage.getItem("theme") as "light" | "dark" | null; - if (stored) setTheme(stored); - }, []); - - const toggleTheme = () => { - const next = theme === "dark" ? "light" : "dark"; - setTheme(next); - localStorage.setItem("theme", next); - document.documentElement.classList.remove("light", "dark"); - document.documentElement.classList.add(next); - }; - - const statusVariant = - status === "connected" - ? "default" - : status === "connecting" - ? "secondary" - : "destructive"; - - const statusLabel = - status === "connected" - ? "Connected" - : status === "connecting" - ? "Connecting" - : status === "error" - ? "Error" - : "Disconnected"; - - return ( -
- - -

{pageTitle}

- -
- - {/* Connection status */} - - - - - - {statusLabel} - - - - -

WebSocket: {statusLabel}

-
-
- - {/* Theme toggle */} - -
- ); -} diff --git a/services/frontend/src/components/layout/mobile-tab-bar.tsx b/services/frontend/src/components/layout/mobile-nav.tsx similarity index 73% rename from services/frontend/src/components/layout/mobile-tab-bar.tsx rename to services/frontend/src/components/layout/mobile-nav.tsx index 9833750..d83619c 100644 --- a/services/frontend/src/components/layout/mobile-tab-bar.tsx +++ b/services/frontend/src/components/layout/mobile-nav.tsx @@ -6,12 +6,12 @@ import { usePathname } from "next/navigation"; import { mobileNavItems } from "@/lib/navigation"; import { cn } from "@/lib/utils"; -export function MobileTabBar() { +export function MobileNav() { const pathname = usePathname(); - const isActive = (matchPrefix: string) => { - if (matchPrefix === "/dashboard") return pathname === "/dashboard"; - return pathname.startsWith(matchPrefix); + const isActive = (prefix: string) => { + if (prefix === "/dashboard") return pathname === "/dashboard"; + return pathname.startsWith(prefix); }; return ( @@ -24,10 +24,8 @@ export function MobileTabBar() { key={href} href={href} className={cn( - "flex-1 flex flex-col items-center gap-0.5 py-2 text-xs font-medium transition-all duration-200 relative", - active - ? "text-sky-400" - : "text-muted-foreground hover:text-foreground", + "flex-1 flex flex-col items-center gap-0.5 py-2 text-xs font-medium transition-all relative", + active ? "text-sky-400" : "text-muted-foreground", )} > diff --git a/services/frontend/src/components/layout/sidebar.tsx b/services/frontend/src/components/layout/sidebar.tsx deleted file mode 100644 index 23dcde7..0000000 --- a/services/frontend/src/components/layout/sidebar.tsx +++ /dev/null @@ -1,142 +0,0 @@ -"use client"; - -import { Radio } from "lucide-react"; -import { usePathname, useRouter } from "next/navigation"; - -import { - SidebarContent, - SidebarFooter, - SidebarGroup, - SidebarGroupContent, - SidebarHeader, - SidebarMenu, - SidebarMenuButton, - SidebarMenuItem, - Sidebar as SidebarPrimitive, - useSidebar, -} from "@/components/ui/sidebar"; -import { navItems } from "@/lib/navigation"; -import { cn } from "@/lib/utils"; -import { useWebSocket } from "@/lib/ws/context"; - -export function Sidebar() { - const pathname = usePathname(); - const router = useRouter(); - const { state } = useSidebar(); - const { status } = useWebSocket(); - const collapsed = state === "collapsed"; - - const isActive = (matchPrefix: string) => { - if (matchPrefix === "/dashboard") return pathname === "/dashboard"; - return pathname.startsWith(matchPrefix); - }; - - const connectionLabel = { - connected: "Connected", - connecting: "Connecting", - disconnected: "Disconnected", - error: "Error", - }[status]; - - const connectionColor = { - connected: "bg-green-500", - connecting: "bg-yellow-500", - disconnected: "bg-destructive", - error: "bg-destructive", - }[status]; - - return ( - - - - - router.push("/dashboard")} - > -
- -
-
- - Bete - - - Dashboard - -
-
-
-
-
- - - - - - {navItems.map(({ href, label, icon: Icon, matchPrefix }) => { - const active = isActive(matchPrefix); - return ( - - router.push(href)} - > - - {label} - {active && ( -
- )} - - - ); - })} - - - - - - -
- - - - - {!collapsed && ( - - {connectionLabel} - - )} -
-
- - ); -} diff --git a/services/frontend/src/components/shared/detail-stat.tsx b/services/frontend/src/components/shared/detail-stat.tsx new file mode 100644 index 0000000..1936970 --- /dev/null +++ b/services/frontend/src/components/shared/detail-stat.tsx @@ -0,0 +1,38 @@ +import { Card, CardContent } from "@/components/ui/card"; +import { formatNumber } from "@/lib/format"; +import { cn } from "@/lib/utils"; + +interface DetailStatProps { + label: string; + value: number; + variant?: "default" | "danger" | "success"; + suffix?: string; +} + +/** + * Small stat label used inside detail views. + */ +export function DetailStat({ + label, + value, + variant = "default", + suffix, +}: DetailStatProps) { + return ( + + +

{label}

+

+ {formatNumber(value)} + {suffix} +

+
+
+ ); +} diff --git a/services/frontend/src/components/shared/empty-state.tsx b/services/frontend/src/components/shared/empty-state.tsx new file mode 100644 index 0000000..3f97ffa --- /dev/null +++ b/services/frontend/src/components/shared/empty-state.tsx @@ -0,0 +1,26 @@ +import type { LucideIcon } from "lucide-react"; + +interface EmptyStateProps { + icon: LucideIcon; + title: string; + description?: string; +} + +/** + * Consistent empty state for data-fetching pages. + */ +export function EmptyState({ + icon: Icon, + title, + description, +}: EmptyStateProps) { + return ( +
+ +

{title}

+ {description && ( +

{description}

+ )} +
+ ); +} diff --git a/services/frontend/src/components/shared/error-state.tsx b/services/frontend/src/components/shared/error-state.tsx new file mode 100644 index 0000000..a7a7993 --- /dev/null +++ b/services/frontend/src/components/shared/error-state.tsx @@ -0,0 +1,27 @@ +import { AlertCircle, RefreshCw } from "lucide-react"; + +import { Button } from "@/components/ui/button"; + +interface ErrorStateProps { + message: string; + onRetry?: () => void; +} + +/** + * Consistent error state for data-fetching pages. + * Shows the error message with an optional retry button. + */ +export function ErrorState({ message, onRetry }: ErrorStateProps) { + return ( +
+ +

{message}

+ {onRetry && ( + + )} +
+ ); +} diff --git a/services/frontend/src/components/shared/index.ts b/services/frontend/src/components/shared/index.ts new file mode 100644 index 0000000..58b3c6c --- /dev/null +++ b/services/frontend/src/components/shared/index.ts @@ -0,0 +1,5 @@ +export { DetailStat } from "./detail-stat"; +export { EmptyState } from "./empty-state"; +export { ErrorState } from "./error-state"; +export { LoadingSkeleton } from "./loading-skeleton"; +export { StatCard } from "./stat-card"; diff --git a/services/frontend/src/components/shared/loading-skeleton.tsx b/services/frontend/src/components/shared/loading-skeleton.tsx new file mode 100644 index 0000000..7627927 --- /dev/null +++ b/services/frontend/src/components/shared/loading-skeleton.tsx @@ -0,0 +1,38 @@ +import { Skeleton } from "@/components/ui/skeleton"; +import { cn } from "@/lib/utils"; + +interface LoadingSkeletonProps { + /** Number of skeleton rows */ + count?: number; + /** Height per skeleton row */ + height?: string; + /** Grid layout: columns */ + columns?: number; + /** Additional classes */ + className?: string; +} + +/** + * Consistent loading skeleton for data-fetching pages. + * Renders a grid of skeleton placeholders. + */ +export function LoadingSkeleton({ + count = 4, + height = "h-28", + columns = 1, + className, +}: LoadingSkeletonProps) { + return ( +
1 ? `grid-cols-1 md:grid-cols-${columns}` : "grid-cols-1", + className, + )} + > + {Array.from({ length: count }, (_, i) => ( + + ))} +
+ ); +} diff --git a/services/frontend/src/components/shared/stat-card.tsx b/services/frontend/src/components/shared/stat-card.tsx new file mode 100644 index 0000000..8c088f3 --- /dev/null +++ b/services/frontend/src/components/shared/stat-card.tsx @@ -0,0 +1,55 @@ +import type { LucideIcon } from "lucide-react"; + +import { Card, CardContent } from "@/components/ui/card"; +import { formatNumber } from "@/lib/format"; +import { cn } from "@/lib/utils"; + +interface StatCardProps { + label: string; + value: number; + icon: LucideIcon; + variant?: "default" | "danger" | "success"; +} + +/** + * Metric card used across dashboard and landing pages. + */ +export function StatCard({ + label, + value, + icon: Icon, + variant = "default", +}: StatCardProps) { + return ( + + +
+
+

{label}

+

+ {formatNumber(value)} +

+
+
+ +
+
+
+
+ ); +} diff --git a/services/frontend/src/hooks/index.ts b/services/frontend/src/hooks/index.ts new file mode 100644 index 0000000..d90a345 --- /dev/null +++ b/services/frontend/src/hooks/index.ts @@ -0,0 +1,22 @@ +export { useAsync } from "./use-async"; +export { useConfig } from "./use-config"; +export { + useChannelDetail, + useChannels, + useStats, + useUserDetail, + useUsers, +} from "./use-dashboard"; +export { useGuilds } from "./use-guilds"; +export { useMediaState, useMediaWsSubscription } from "./use-media"; +export { + useImages, + useMessageDetail, + useMessages, + useMessageWsSubscription, + useReview, + useSearch, + useTextChannels, +} from "./use-messages"; +export { useRecordings, useRecordingsWsSubscription } from "./use-recordings"; +export { useSpeakers, useVoiceChannels, useVoiceStatus } from "./use-voice"; diff --git a/services/frontend/src/hooks/use-async.ts b/services/frontend/src/hooks/use-async.ts new file mode 100644 index 0000000..4df7507 --- /dev/null +++ b/services/frontend/src/hooks/use-async.ts @@ -0,0 +1,58 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +interface UseAsyncState { + data: T | null; + loading: boolean; + error: string | null; +} + +type UseAsyncReturn = UseAsyncState & { refetch: () => void }; + +/** + * Generic async data-fetching hook. + * + * - Cancels requests on unmount + * - Provides loading / error / data states + * - Returns a refetch trigger + */ +export function useAsync( + fetcher: () => Promise, + deps: unknown[] = [], +): UseAsyncReturn { + const [state, setState] = useState>({ + data: null, + loading: true, + error: null, + }); + const cancelledRef = useRef(false); + + const execute = useCallback(() => { + cancelledRef.current = false; + setState((prev) => ({ ...prev, loading: true, error: null })); + fetcher() + .then((data) => { + if (!cancelledRef.current) { + setState({ data, loading: false, error: null }); + } + }) + .catch((err: unknown) => { + if (!cancelledRef.current) { + setState({ + data: null, + loading: false, + error: err instanceof Error ? err.message : "An error occurred", + }); + } + }); + // biome-ignore lint/correctness/useExhaustiveDependencies: deps is intentionally dynamic + }, deps); + + useEffect(() => { + execute(); + return () => { + cancelledRef.current = true; + }; + }, [execute]); + + return { ...state, refetch: execute }; +} diff --git a/services/frontend/src/hooks/use-config.ts b/services/frontend/src/hooks/use-config.ts new file mode 100644 index 0000000..edc1db9 --- /dev/null +++ b/services/frontend/src/hooks/use-config.ts @@ -0,0 +1,21 @@ +import { configApi } from "@/lib/api"; +import type { AppConfig } from "@/lib/types"; +import { useAsync } from "./use-async"; + +interface UseConfigReturn { + config: AppConfig | null; + loading: boolean; + error: string | null; + refetch: () => void; +} + +/** + * Fetch the app configuration from the backend. + */ +export function useConfig(): UseConfigReturn { + const { data, loading, error, refetch } = useAsync( + () => configApi.get(), + [], + ); + return { config: data, loading, error, refetch }; +} diff --git a/services/frontend/src/hooks/use-dashboard.ts b/services/frontend/src/hooks/use-dashboard.ts new file mode 100644 index 0000000..ae212d2 --- /dev/null +++ b/services/frontend/src/hooks/use-dashboard.ts @@ -0,0 +1,169 @@ +import { useCallback, useState } from "react"; + +import { dashboardApi } from "@/lib/api"; +import type { + DashboardChannel, + DashboardChannelDetail, + DashboardStats, + DashboardUser, + DashboardUserDetail, +} from "@/lib/types"; + +// ── Stats ─────────────────────────────────────── + +interface UseStatsReturn { + stats: DashboardStats | null; + loading: boolean; + error: string | null; + refetch: () => void; +} + +export function useStats(): UseStatsReturn { + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const fetch = useCallback(async () => { + setLoading(true); + setError(null); + try { + const data = await dashboardApi.getStats(); + setStats(data); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load stats"); + } finally { + setLoading(false); + } + }, []); + + return { stats, loading, error, refetch: fetch }; +} + +// ── Users ─────────────────────────────────────── + +interface UseUsersReturn { + users: DashboardUser[]; + loading: boolean; + search: string; + setSearch: (q: string) => void; + refetch: () => void; +} + +export function useUsers(): UseUsersReturn { + const [users, setUsers] = useState([]); + const [loading, setLoading] = useState(true); + const [search, setSearch] = useState(""); + + const fetch = useCallback(async (q?: string) => { + setLoading(true); + try { + const result = await dashboardApi.listUsers(20, undefined, q); + setUsers(result.data); + } catch { + // silently fail + } finally { + setLoading(false); + } + }, []); + + const fetchWithSearch = useCallback(() => { + fetch(search || undefined); + }, [fetch, search]); + + return { + users, + loading, + search, + setSearch, + refetch: fetchWithSearch, + }; +} + +// ── Channels ──────────────────────────────────── + +interface UseChannelsReturn { + channels: DashboardChannel[]; + loading: boolean; + search: string; + setSearch: (q: string) => void; + refetch: () => void; +} + +export function useChannels(guildId: string): UseChannelsReturn { + const [channels, setChannels] = useState([]); + const [loading, setLoading] = useState(true); + const [search, setSearch] = useState(""); + + const fetch = useCallback( + async (q?: string) => { + setLoading(true); + try { + const result = await dashboardApi.listChannels( + 20, + q, + guildId || undefined, + ); + setChannels(result.data); + } catch { + // silently fail + } finally { + setLoading(false); + } + }, + [guildId], + ); + + const fetchWithSearch = useCallback(() => { + fetch(search || undefined); + }, [fetch, search]); + + return { + channels, + loading, + search, + setSearch, + refetch: fetchWithSearch, + }; +} + +// ── User Detail ───────────────────────────────── + +export function useUserDetail() { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(false); + + const fetch = useCallback(async (userId: string) => { + setLoading(true); + try { + const detail = await dashboardApi.getUserDetail(userId); + setUser(detail); + } catch { + // ignore + } finally { + setLoading(false); + } + }, []); + + return { user, loading, fetch }; +} + +// ── Channel Detail ────────────────────────────── + +export function useChannelDetail() { + const [channel, setChannel] = useState(null); + const [loading, setLoading] = useState(false); + + const fetch = useCallback(async (channelId: string) => { + setLoading(true); + try { + const detail = await dashboardApi.getChannelDetail(channelId); + setChannel(detail); + } catch { + // ignore + } finally { + setLoading(false); + } + }, []); + + return { channel, loading, fetch }; +} diff --git a/services/frontend/src/hooks/use-guilds.ts b/services/frontend/src/hooks/use-guilds.ts new file mode 100644 index 0000000..c08501d --- /dev/null +++ b/services/frontend/src/hooks/use-guilds.ts @@ -0,0 +1,38 @@ +import { useCallback, useEffect, useState } from "react"; + +import { voiceApi } from "@/lib/api"; +import type { Guild } from "@/lib/types"; + +interface UseGuildsReturn { + guilds: Guild[]; + loading: boolean; + error: string | null; + refetch: () => void; +} + +/** + * Fetch the list of available Discord guilds. + */ +export function useGuilds(): UseGuildsReturn { + const [guilds, setGuilds] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchGuilds = useCallback(() => { + setLoading(true); + setError(null); + voiceApi + .getGuilds() + .then(setGuilds) + .catch((err: unknown) => + setError(err instanceof Error ? err.message : "Failed to load guilds"), + ) + .finally(() => setLoading(false)); + }, []); + + useEffect(() => { + fetchGuilds(); + }, [fetchGuilds]); + + return { guilds, loading, error, refetch: fetchGuilds }; +} diff --git a/services/frontend/src/hooks/use-media.ts b/services/frontend/src/hooks/use-media.ts new file mode 100644 index 0000000..d2c233f --- /dev/null +++ b/services/frontend/src/hooks/use-media.ts @@ -0,0 +1,80 @@ +import { useCallback, useState } from "react"; + +import { voiceApi } from "@/lib/api"; +import type { MediaState } from "@/lib/types"; +import type { WsEventType } from "@/lib/ws/types"; + +type WsHook = { + on: ( + eventType: E, + handler: (data: unknown) => void, + ) => () => void; +}; + +interface UseMediaStateReturn { + mediaState: MediaState | null; + refresh: () => void; + queue: (url: string) => void; + skip: () => void; + stop: () => void; + setVolume: (value: number | readonly number[]) => void; +} + +export function useMediaState(): UseMediaStateReturn { + const [mediaState, setMediaState] = useState(null); + + const refresh = useCallback(async () => { + try { + const state = await voiceApi.getMediaStatus(); + setMediaState(state); + } catch { + // ignore + } + }, []); + + const queue = useCallback(async (url: string) => { + try { + const state = await voiceApi.mediaQueue(url, "music"); + setMediaState(state); + } catch { + // ignore + } + }, []); + + const skip = useCallback(async () => { + try { + const state = await voiceApi.mediaSkip(); + setMediaState(state); + } catch { + // ignore + } + }, []); + + const stop = useCallback(async () => { + try { + const state = await voiceApi.mediaStop(); + setMediaState(state); + } catch { + // ignore + } + }, []); + + const setVolume = useCallback(async (value: number | readonly number[]) => { + const vol = Array.isArray(value) ? value[0] : value; + try { + const state = await voiceApi.mediaVolume(vol); + setMediaState(state); + } catch { + // ignore + } + }, []); + + return { mediaState, refresh, queue, skip, stop, setVolume }; +} + +export function useMediaWsSubscription( + ws: WsHook, + onState: (state: MediaState) => void, +) { + return ws.on("media_state", (data) => onState(data as MediaState)); +} diff --git a/services/frontend/src/hooks/use-messages.ts b/services/frontend/src/hooks/use-messages.ts new file mode 100644 index 0000000..46133f0 --- /dev/null +++ b/services/frontend/src/hooks/use-messages.ts @@ -0,0 +1,265 @@ +import { useCallback, useEffect, useState } from "react"; + +import { messagesApi, voiceApi } from "@/lib/api"; +import type { AttachmentRecord, Channel, MessageRecord } from "@/lib/types"; +import type { WsEventType } from "@/lib/ws/types"; + +type WsHook = { + on: ( + eventType: E, + handler: (data: unknown) => void, + ) => () => void; +}; + +// ── Messages list ─────────────────────────────── + +interface UseMessagesReturn { + messages: MessageRecord[]; + loading: boolean; + loadingMore: boolean; + error: string | null; + hasMore: boolean; + refetch: () => void; + loadMore: () => void; + prepend: (msg: MessageRecord) => void; + update: (msg: MessageRecord) => void; + remove: (id: string) => void; +} + +export function useMessages( + guildId: string, + channelId?: string, +): UseMessagesReturn { + const [messages, setMessages] = useState([]); + const [loading, setLoading] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); + const [error, setError] = useState(null); + const [cursor, setCursor] = useState(null); + const [hasMore, setHasMore] = useState(true); + + const fetch = useCallback(async () => { + if (!guildId) return; + setLoading(true); + setError(null); + try { + const result = await messagesApi.list( + guildId, + 50, + channelId || undefined, + ); + setMessages(result.data); + setCursor(result.nextCursor); + setHasMore(result.nextCursor !== null); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load messages"); + } finally { + setLoading(false); + } + }, [guildId, channelId]); + + const loadMore = useCallback(async () => { + if (!cursor || loadingMore) return; + setLoadingMore(true); + try { + const result = await messagesApi.list( + guildId, + 50, + channelId || undefined, + cursor, + ); + setMessages((prev) => [...prev, ...result.data]); + setCursor(result.nextCursor); + setHasMore(result.nextCursor !== null); + } catch { + // ignore + } finally { + setLoadingMore(false); + } + }, [cursor, loadingMore, guildId, channelId]); + + const prepend = useCallback((msg: MessageRecord) => { + setMessages((prev) => [msg, ...prev]); + }, []); + + const update = useCallback((msg: MessageRecord) => { + setMessages((prev) => prev.map((m) => (m.id === msg.id ? msg : m))); + }, []); + + const remove = useCallback((id: string) => { + setMessages((prev) => prev.filter((m) => m.id !== id)); + }, []); + + return { + messages, + loading, + loadingMore, + error, + hasMore, + refetch: fetch, + loadMore, + prepend, + update, + remove, + }; +} + +// ── Channels list ─────────────────────────────── + +interface UseTextChannelsReturn { + channels: Channel[]; + loading: boolean; +} + +export function useTextChannels(guildId: string): UseTextChannelsReturn { + const [channels, setChannels] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (!guildId) return; + voiceApi + .getTextChannels(guildId) + .then(setChannels) + .catch(() => {}) + .finally(() => setLoading(false)); + }, [guildId]); + + return { channels, loading }; +} + +// ── Search ────────────────────────────────────── + +interface UseSearchReturn { + results: MessageRecord[] | null; + searching: boolean; + search: (query: string) => void; +} + +export function useSearch(): UseSearchReturn { + const [results, setResults] = useState(null); + const [searching, setSearching] = useState(false); + + const search = useCallback(async (query: string) => { + if (!query.trim()) { + setResults(null); + return; + } + setSearching(true); + try { + const result = await messagesApi.search(query, 50); + setResults(result.results); + } catch { + setResults([]); + } finally { + setSearching(false); + } + }, []); + + return { results, searching, search }; +} + +// ── Images ────────────────────────────────────── + +export function useImages(guildId: string) { + const [images, setImages] = useState([]); + + const fetch = useCallback(async () => { + if (!guildId) return; + try { + const result = await messagesApi.getImages(guildId, 50); + setImages(result.data); + } catch { + // silently fail + } + }, [guildId]); + + return { images, refetch: fetch }; +} + +// ── Review ────────────────────────────────────── + +export function useReview(channelId?: string) { + const [reviews, setReviews] = useState([]); + + const fetch = useCallback(async () => { + try { + const result = await messagesApi.getReview(50, channelId || undefined); + setReviews(result.results); + } catch { + // silently fail + } + }, [channelId]); + + return { reviews, refetch: fetch }; +} + +// ── Detail ────────────────────────────────────── + +interface UseMessageDetailReturn { + message: MessageRecord | null; + attachments: AttachmentRecord[]; + loading: boolean; + open: (id: string) => void; + close: () => void; +} + +export function useMessageDetail(): UseMessageDetailReturn { + const [message, setMessage] = useState(null); + const [attachments, setAttachments] = useState([]); + const [loading, setLoading] = useState(false); + + const open = useCallback(async (id: string) => { + setLoading(true); + setAttachments([]); + try { + const detail = await messagesApi.getDetail(id); + setMessage(detail); + if (detail.channel_id && id) { + messagesApi + .getAttachments(detail.channel_id, 10) + .then((res) => setAttachments(res.data)) + .catch(() => {}); + } + } catch { + setMessage(null); + } finally { + setLoading(false); + } + }, []); + + const close = useCallback(() => setMessage(null), []); + + return { message, attachments, loading, open, close }; +} + +// ── WS Subscription helper ────────────────────── + +export function useMessageWsSubscription( + ws: WsHook | undefined, + guildId: string, + onCreated: (msg: MessageRecord) => void, + onUpdated: (msg: MessageRecord) => void, + onDeleted: (id: string) => void, + onAnalyzed: (msg: MessageRecord) => void, +) { + useEffect(() => { + if (!ws || !guildId) return; + const unsub1 = ws.on("message_created", (data) => + onCreated(data as MessageRecord), + ); + const unsub2 = ws.on("message_updated", (data) => + onUpdated(data as MessageRecord), + ); + const unsub3 = ws.on("message_deleted", (data) => + onDeleted(data as unknown as string), + ); + const unsub4 = ws.on("message_analyzed", (data) => + onAnalyzed(data as MessageRecord), + ); + return () => { + unsub1(); + unsub2(); + unsub3(); + unsub4(); + }; + }, [ws, guildId, onCreated, onUpdated, onDeleted, onAnalyzed]); +} diff --git a/services/frontend/src/hooks/use-recordings.ts b/services/frontend/src/hooks/use-recordings.ts new file mode 100644 index 0000000..543590d --- /dev/null +++ b/services/frontend/src/hooks/use-recordings.ts @@ -0,0 +1,61 @@ +import { useCallback, useState } from "react"; + +import { recordingsApi } from "@/lib/api"; +import type { VoiceRecording } from "@/lib/types"; +import type { WsEventType } from "@/lib/ws/types"; + +type WsHook = { + on: ( + eventType: E, + handler: (data: unknown) => void, + ) => () => void; +}; + +interface UseRecordingsReturn { + recordings: VoiceRecording[]; + loading: boolean; + refresh: () => void; + remove: (id: string) => void; + prepend: (rec: VoiceRecording) => void; +} + +export function useRecordings(): UseRecordingsReturn { + const [recordings, setRecordings] = useState([]); + const [loading, setLoading] = useState(true); + + const refresh = useCallback(async () => { + setLoading(true); + try { + const result = await recordingsApi.list(50); + setRecordings(result.items); + } catch { + // ignore + } finally { + setLoading(false); + } + }, []); + + const remove = useCallback(async (id: string) => { + try { + await recordingsApi.delete(id); + setRecordings((prev) => prev.filter((r) => r.id !== id)); + } catch { + // ignore + } + }, []); + + const prepend = useCallback((rec: VoiceRecording) => { + setRecordings((prev) => [rec, ...prev]); + }, []); + + return { recordings, loading, refresh, remove, prepend }; +} + +export function useRecordingsWsSubscription( + ws: WsHook, + onUploaded: (rec: VoiceRecording) => void, +) { + return ws.on("voice_recording_uploaded", (data) => + onUploaded(data as VoiceRecording), + ); +} diff --git a/services/frontend/src/hooks/use-voice.ts b/services/frontend/src/hooks/use-voice.ts new file mode 100644 index 0000000..05f86ad --- /dev/null +++ b/services/frontend/src/hooks/use-voice.ts @@ -0,0 +1,89 @@ +import { useCallback, useState } from "react"; + +import { voiceApi } from "@/lib/api"; +import type { ActiveSpeaker, VoiceStatus } from "@/lib/types"; +import type { WsEventType } from "@/lib/ws/types"; + +type WsHook = { + on: ( + eventType: E, + handler: (data: unknown) => void, + ) => () => void; +}; + +interface UseVoiceStatusReturn { + voiceStatus: VoiceStatus | null; + refresh: () => void; +} + +export function useVoiceStatus(): UseVoiceStatusReturn { + const [voiceStatus, setVoiceStatus] = useState(null); + + const refresh = useCallback(async () => { + try { + const status = await voiceApi.getStatus(); + setVoiceStatus(status); + } catch { + // ignore + } + }, []); + + return { voiceStatus, refresh }; +} + +interface UseVoiceChannelsReturn { + channels: Array<{ id: string; name: string }>; + loading: boolean; + fetch: (guildId: string) => void; +} + +export function useVoiceChannels(): UseVoiceChannelsReturn { + const [channels, setChannels] = useState>( + [], + ); + const [loading, setLoading] = useState(false); + + const fetch = useCallback(async (guildId: string) => { + setLoading(true); + try { + const ch = await voiceApi.getVoiceChannels(guildId); + setChannels(ch); + } catch { + setChannels([]); + } finally { + setLoading(false); + } + }, []); + + return { channels, loading, fetch }; +} + +interface UseSpeakersReturn { + speakers: ActiveSpeaker[]; + subscribe: (ws: WsHook) => () => void; +} + +export function useSpeakers(): UseSpeakersReturn { + const [speakers, setSpeakers] = useState([]); + + const subscribe = useCallback((ws: WsHook) => { + const unsub = ws.on("voice_active_user", (data) => { + const speaker = data as ActiveSpeaker; + setSpeakers((prev) => { + const idx = prev.findIndex((s) => s.userId === speaker.userId); + if (idx >= 0) { + const next = [...prev]; + next[idx] = speaker; + return next; + } + return [...prev, speaker]; + }); + }); + return () => { + unsub(); + setSpeakers([]); + }; + }, []); + + return { speakers, subscribe }; +} diff --git a/services/frontend/src/lib/api/mascot.ts b/services/frontend/src/lib/api/chatbot.ts similarity index 57% rename from services/frontend/src/lib/api/mascot.ts rename to services/frontend/src/lib/api/chatbot.ts index 61711d5..7f64a70 100644 --- a/services/frontend/src/lib/api/mascot.ts +++ b/services/frontend/src/lib/api/chatbot.ts @@ -1,9 +1,9 @@ -import type { ChatHistoryMessage, MascotChatResponse } from "@/lib/types"; +import type { ChatbotResponse, ChatHistoryMessage } from "@/lib/types"; import { api } from "./client"; -export const mascotApi = { +export const chatbotApi = { send: (message: string) => - api.post("/api/mascot/chat", { message }), + api.post("/api/mascot/chat", { message }), getHistory: () => api.get("/api/mascot/chat/history"), diff --git a/services/frontend/src/lib/api/index.ts b/services/frontend/src/lib/api/index.ts index db424fd..b6fc3f7 100644 --- a/services/frontend/src/lib/api/index.ts +++ b/services/frontend/src/lib/api/index.ts @@ -1,7 +1,7 @@ +export { chatbotApi } from "./chatbot"; export { ApiError, api, apiRequest } from "./client"; export { configApi } from "./config"; export { dashboardApi } from "./dashboard"; -export { mascotApi } from "./mascot"; export { messagesApi } from "./messages"; export { recordingsApi } from "./recordings"; export { uiStateApi } from "./ui-state"; diff --git a/services/frontend/src/lib/types/ui.ts b/services/frontend/src/lib/types/ui.ts index 61a8e0e..87fdde6 100644 --- a/services/frontend/src/lib/types/ui.ts +++ b/services/frontend/src/lib/types/ui.ts @@ -11,7 +11,7 @@ export interface UiState { is_streaming?: boolean | null; } -export interface MascotChatResponse { +export interface ChatbotResponse { response: string; timestamp: string; }