From 05bbae66dae867657c265a94af3869486d2c86b3 Mon Sep 17 00:00:00 2001 From: asepharyana Date: Sun, 26 Jul 2026 16:55:20 +0700 Subject: [PATCH] Refactor voice page and hooks to use React Query for data fetching - Updated VoicePage component to utilize useVoiceConnect, useVoiceDisconnect, and useMicTransmit mutations. - Replaced local state management with React Query's useQuery for voice status, guilds, and channels. - Removed custom useAsync hook and replaced it with useQuery in useConfig, useStats, useUsers, useChannels, and useMessages hooks. - Simplified useRecordings and useSpeakers hooks to leverage React Query for data fetching and mutations. - Removed deprecated use-async hook and related code. - Enhanced error handling and loading states across various hooks. --- pnpm-lock.yaml | 18 + services/frontend/package.json | 1 + .../src/app/(dashboard)/analysis/page.tsx | 210 +++--- .../src/app/(dashboard)/dashboard/page.tsx | 392 +++-------- .../frontend/src/app/(dashboard)/layout.tsx | 53 +- .../src/app/(dashboard)/media/page.tsx | 75 +- .../src/app/(dashboard)/messages/page.tsx | 643 ++++++++---------- .../src/app/(dashboard)/recordings/page.tsx | 29 +- .../src/app/(dashboard)/settings/page.tsx | 36 +- .../src/app/(dashboard)/voice/page.tsx | 93 +-- services/frontend/src/hooks/index.ts | 31 +- services/frontend/src/hooks/use-async.ts | 58 -- services/frontend/src/hooks/use-config.ts | 22 +- services/frontend/src/hooks/use-dashboard.ts | 187 +---- services/frontend/src/hooks/use-guilds.ts | 37 +- services/frontend/src/hooks/use-media.ts | 110 ++- services/frontend/src/hooks/use-messages.ts | 395 +++++------ services/frontend/src/hooks/use-recordings.ts | 75 +- services/frontend/src/hooks/use-voice.ts | 75 +- services/frontend/src/lib/hooks/use-config.ts | 22 - 20 files changed, 980 insertions(+), 1582 deletions(-) delete mode 100644 services/frontend/src/hooks/use-async.ts delete mode 100644 services/frontend/src/lib/hooks/use-config.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ff30f38..e23b702 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -227,6 +227,9 @@ importers: '@shadcn/react': specifier: ^0.2.1 version: 0.2.1(@types/react@19.2.17)(react@19.2.4) + '@tanstack/react-query': + specifier: ^5.101.4 + version: 5.101.4(react@19.2.4) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -2147,6 +2150,14 @@ packages: '@tailwindcss/postcss@4.3.3': resolution: {integrity: sha512-JTSZZGQi1AyKirbLN3azmjVzef92tcX7h+iSqPdaeStyFpGpDlKvvpxeOE8njhbUanbRwr3z8DyzhICWnMtQeg==} + '@tanstack/query-core@5.101.4': + resolution: {integrity: sha512-gNwcvOJcRbLWPOLG/2OBm+zM+Yv+MKsXKEOWC57USuZDEsI71hEErQsiEGx5wX9rzWWkfwM0fVSPoiIFSsxfiw==} + + '@tanstack/react-query@5.101.4': + resolution: {integrity: sha512-yRg2pfOCxIs4ZJW3XYYHU/WgtD04FHSnfHlpRT7h7pR77hwkdRG4wxbKe4aq6P0RvXUTBSQpQeadS1SUYUe+KA==} + peerDependencies: + react: ^18 || ^19 + '@ts-morph/common@0.27.0': resolution: {integrity: sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ==} @@ -6442,6 +6453,13 @@ snapshots: postcss: 8.5.23 tailwindcss: 4.3.3 + '@tanstack/query-core@5.101.4': {} + + '@tanstack/react-query@5.101.4(react@19.2.4)': + dependencies: + '@tanstack/query-core': 5.101.4 + react: 19.2.4 + '@ts-morph/common@0.27.0': dependencies: fast-glob: 3.3.3 diff --git a/services/frontend/package.json b/services/frontend/package.json index 93763c2..cc430b2 100644 --- a/services/frontend/package.json +++ b/services/frontend/package.json @@ -12,6 +12,7 @@ "dependencies": { "@base-ui/react": "^1.6.0", "@shadcn/react": "^0.2.1", + "@tanstack/react-query": "^5.101.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", diff --git a/services/frontend/src/app/(dashboard)/analysis/page.tsx b/services/frontend/src/app/(dashboard)/analysis/page.tsx index 924563e..bf9aa92 100644 --- a/services/frontend/src/app/(dashboard)/analysis/page.tsx +++ b/services/frontend/src/app/(dashboard)/analysis/page.tsx @@ -1,5 +1,6 @@ "use client"; +import { useQuery } from "@tanstack/react-query"; import { Loader2, RefreshCw, Search, Sparkles } from "lucide-react"; import { useCallback, useState } from "react"; import { EmptyState, LoadingSkeleton } from "@/components/shared"; @@ -9,30 +10,30 @@ 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 { useSearch } from "@/hooks"; +import { useReanalyze } from "@/hooks"; +import { messagesApi } from "@/lib/api"; 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 [searched, setSearched] = useState(false); + const [enabled, setEnabled] = useState(false); + const reanalyzeMut = useReanalyze(); + + const { data: results, isFetching } = useQuery({ + queryKey: ["analysis-search", query], + queryFn: async () => { + const result = await messagesApi.search(query, 50); + return result.results; + }, + enabled, + }); const handleSearch = useCallback(() => { if (!query.trim()) return; - setSearched(true); - search(query); - }, [query, search]); - - const handleReanalyze = useCallback(async (id: string) => { - const { messagesApi } = await import("@/lib/api"); - try { - await messagesApi.reanalyze(id); - } catch (err) { - console.error("analysis/reanalyze:", err); - } - }, []); + setEnabled(true); + }, [query]); return (
@@ -47,20 +48,19 @@ export default function AnalysisPage() { className="pl-9 h-9" />
- - {searching ? ( + {isFetching ? ( - ) : results !== null ? ( + ) : results !== undefined ? ( <>

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

- {results.length === 0 ? ( {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)}% + +
+ )} + +
+
+
+
))} )} - ) : !searched ? ( + ) : (

@@ -88,92 +159,7 @@ export default function AnalysisPage() { Searches message content, AI flags, and analysis text.

- ) : null} + )} ); } - -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 85ae875..bd7c14a 100644 --- a/services/frontend/src/app/(dashboard)/dashboard/page.tsx +++ b/services/frontend/src/app/(dashboard)/dashboard/page.tsx @@ -13,7 +13,7 @@ import { Users, } from "lucide-react"; import Image from "next/image"; -import { useEffect, useState } from "react"; +import { useCallback, useState } from "react"; import { DetailStat, EmptyState, @@ -28,20 +28,26 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Progress } from "@/components/ui/progress"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { useChannels, useStats, useUsers } from "@/hooks"; +import { + useChannelDetail, + useChannels, + useStats, + useUserDetail, + useUsers, +} from "@/hooks"; +import { dashboardApi } from "@/lib/api"; import { formatNumber } from "@/lib/format"; -import type { DashboardChannelDetail, DashboardUserDetail } from "@/lib/types"; +import type { DashboardUser } from "@/lib/types"; type View = "stats" | "users" | "channels" | "user-detail" | "channel-detail"; export default function DashboardPage() { const [view, setView] = useState("stats"); const [guildId, setGuildId] = useState(""); - const [activeUser, setActiveUser] = useState( + const [selectedUserId, setSelectedUserId] = useState(null); + const [selectedChannelId, setSelectedChannelId] = useState( null, ); - const [activeChannel, setActiveChannel] = - useState(null); return (
@@ -59,56 +65,30 @@ export default function DashboardPage() { > setView("stats")}> - - Stats + Stats setView("users")}> - - Users + Users setView("channels")}> - - Channels + Channels {view === "stats" && } - {view === "users" && ( - { - try { - const { dashboardApi } = await import("@/lib/api"); - const detail = await dashboardApi.getUserDetail(userId); - setActiveUser(detail); - setView("user-detail"); - } catch (err) { - console.error("dashboard/userDetail:", err); - } - }} - /> - )} {view === "channels" && ( { - try { - const { dashboardApi } = await import("@/lib/api"); - const detail = await dashboardApi.getChannelDetail(chId); - setActiveChannel(detail); - setView("channel-detail"); - } catch (err) { - console.error("dashboard/channelDetail:", err); - } + onSelect={(chId) => { + setSelectedChannelId(chId); + setView("channel-detail"); }} /> )} - {view === "user-detail" && activeUser && ( - setView("users")} /> - )} - {view === "channel-detail" && activeChannel && ( - setView("channels")} /> )} @@ -116,18 +96,17 @@ export default function DashboardPage() { ); } -// ── Stats Section ─────────────────────────────── +// ── Stats ────────────────────────────────────────────────────── function StatsSection() { - const { stats, loading, error, refetch } = useStats(); - - if (loading || !stats) { + const { data: stats, isLoading, error, refetch } = useStats(); + if (error) return ; + if (isLoading || !stats) return (
); - } return (
@@ -167,13 +146,11 @@ function StatsSection() { icon={Sparkles} />
-
- - Top Channels + Top Channels @@ -204,30 +181,44 @@ function StatsSection() { )} - - - Moderation Queue + Moderation + Queue
- - - + {[ + { + label: "Pending", + value: stats.moderation_overview.pending, + cls: "bg-muted/50", + }, + { + label: "Processing", + value: stats.moderation_overview.processing, + cls: "bg-yellow-500/10 text-yellow-500", + }, + { + label: "Errors", + value: stats.moderation_overview.error, + cls: "bg-destructive/10 text-destructive", + }, + ].map(({ label, value, cls }) => ( +
+
+ {value} +
+
{label}
+
+ ))}
@@ -236,118 +227,7 @@ function StatsSection() { ); } -function QueueStat({ - label, - value, - variant, -}: { - label: string; - value: number; - variant?: "default" | "warning" | "danger"; -}) { - return ( -
-
- {value} -
-
{label}
-
- ); -} - -// ── Users Section ─────────────────────────────── - -function UsersSection({ onSelect }: { onSelect: (id: string) => void }) { - const { users, loading, search, setSearch, refetch } = useUsers(); - - useEffect(() => { - const timer = setTimeout(refetch, 300); - return () => clearTimeout(timer); - }, [refetch]); - - return ( -
-
- - setSearch(e.target.value)} - className="pl-9 h-9" - /> -
- - {loading ? ( - - ) : users.length === 0 ? ( - - ) : ( -
- {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 Section ──────────────────────────── +// ── Channels ────────────────────────────────────────────────── function ChannelsSection({ guildId, @@ -356,13 +236,12 @@ function ChannelsSection({ guildId: string; onSelect: (id: string) => void; }) { - const { channels, loading, search, setSearch, refetch } = - useChannels(guildId); - - useEffect(() => { - const timer = setTimeout(refetch, 300); - return () => clearTimeout(timer); - }, [refetch]); + const [search, setSearch] = useState(""); + const { + data: channels, + isLoading, + refetch, + } = useChannels(guildId, search || undefined); return (
@@ -375,10 +254,9 @@ function ChannelsSection({ className="pl-9 h-9" />
- - {loading ? ( + {isLoading ? ( - ) : channels.length === 0 ? ( + ) : !channels || channels.length === 0 ? ( ) : (
@@ -397,16 +275,11 @@ function ChannelsSection({ {ch.channel_name ?? ch.channel_id.slice(0, 8)}

-

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

+ {ch.total_messages} messages + {ch.flagged_count > 0 + ? ` · ${ch.flagged_count} flagged` + : ""}

@@ -425,122 +298,24 @@ function ChannelsSection({ ); } -// ── User Detail View ──────────────────────────── +// ── Channel Detail ──────────────────────────────────────────── -function UserDetailView({ - user, +function ChannelDetailSection({ + channelId, onBack, }: { - user: DashboardUserDetail; + channelId: string; onBack: () => void; }) { + const { data: channel, isLoading } = useChannelDetail(channelId); + if (isLoading) return ; + if (!channel) return ; + return (
- - - -
-
- {user.avatar_url ? ( - - ) : ( - (user.username ?? "?").charAt(0).toUpperCase() - )} -
-
-

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

-

- {user.user_id} -

-
-
- -
- - - - -
- - {user.profile_summary && ( -
-
- -

- AI Profile -

-
-

{user.profile_summary}

-
- )} - - {user.recent_messages.length > 0 && ( -
-

- - Recent Messages -

-
- {user.recent_messages.slice(0, 5).map((msg) => ( -
-

- - {new Date(msg.created_at).toLocaleString()} -

-

{msg.content}

-
- ))} -
-
- )} -
-
-
- ); -} - -// ── Channel Detail View ───────────────────────── - -function ChannelDetailView({ - channel, - onBack, -}: { - channel: DashboardChannelDetail; - onBack: () => void; -}) { - return ( -
- -
@@ -552,7 +327,6 @@ function ChannelDetailView({ {channel.channel_id}

-
- {channel.culture_summary && (
@@ -580,12 +353,11 @@ function ChannelDetailView({

)} - {channel.recent_messages.length > 0 && (

- - Recent Messages + Recent + Messages

{channel.recent_messages.slice(0, 5).map((msg) => ( diff --git a/services/frontend/src/app/(dashboard)/layout.tsx b/services/frontend/src/app/(dashboard)/layout.tsx index d2e9ea2..a1c5453 100644 --- a/services/frontend/src/app/(dashboard)/layout.tsx +++ b/services/frontend/src/app/(dashboard)/layout.tsx @@ -1,5 +1,6 @@ "use client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { Suspense } from "react"; import { Chatbot } from "@/components/chatbot/chatbot"; @@ -8,32 +9,44 @@ import { AppSidebar } from "@/components/layout/app-sidebar"; import { MobileNav } from "@/components/layout/mobile-nav"; import { WsProvider } from "@/lib/ws/context"; +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 15_000, + retry: 1, + refetchOnWindowFocus: false, + }, + }, +}); + export default function DashboardLayout({ children, }: { children: React.ReactNode; }) { return ( - -
- -
- -
- -
-
- } - > - {children} -
-
+ + +
+ +
+ +
+ +
+
+ } + > + {children} +
+
+
+
- -
- - + + + ); } diff --git a/services/frontend/src/app/(dashboard)/media/page.tsx b/services/frontend/src/app/(dashboard)/media/page.tsx index 9897663..7af1464 100644 --- a/services/frontend/src/app/(dashboard)/media/page.tsx +++ b/services/frontend/src/app/(dashboard)/media/page.tsx @@ -2,31 +2,47 @@ import { Disc3, Music, Play, SkipForward, Square, Volume2 } from "lucide-react"; import Image from "next/image"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useState } from "react"; 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 { useMediaState } from "@/hooks"; +import { + useMediaQueue, + useMediaSkip, + useMediaState, + useMediaStop, + useMediaVolume, + useMediaWsSync, +} from "@/hooks"; import { useWebSocket } from "@/lib/ws/context"; export default function MediaPage() { const ws = useWebSocket(); - const { mediaState, refresh, queue, skip, stop, setVolume } = useMediaState(); + const { data: mediaState } = useMediaState(); + const queueMut = useMediaQueue(); + const skipMut = useMediaSkip(); + const stopMut = useMediaStop(); + const volumeMut = useMediaVolume(); const [queueUrl, setQueueUrl] = useState(""); - // WS subscription for real-time media state - useEffect(() => { - const unsub = ws.on("media_state", () => refresh()); - return unsub; - }, [ws, refresh]); + // Sync WS media_state into the query cache + useMediaWsSync(ws); const handleQueue = useCallback(() => { if (!queueUrl.trim()) return; - queue(queueUrl.trim()); + queueMut.mutate(queueUrl.trim()); setQueueUrl(""); - }, [queueUrl, queue]); + }, [queueUrl, queueMut]); + + const handleVolume = useCallback( + (value: number | readonly number[]) => { + const vol = Array.isArray(value) ? value[0] : value; + volumeMut.mutate(vol); + }, + [volumeMut], + ); return (
@@ -46,13 +62,16 @@ export default function MediaPage() { onKeyDown={(e) => e.key === "Enter" && handleQueue()} className="flex-1 h-9" /> -
- {mediaState?.current && ( + {mediaState?.current ? (

@@ -74,30 +93,34 @@ export default function MediaPage() {

{mediaState.current.durationMs - ? `${Math.floor(mediaState.current.durationMs / 60000)}:${String( - Math.floor( - (mediaState.current.durationMs % 60000) / 1000, - ), - ).padStart(2, "0")}` + ? `${Math.floor(mediaState.current.durationMs / 60000)}:${String(Math.floor((mediaState.current.durationMs % 60000) / 1000)).padStart(2, "0")}` : "Live"}

- )} - - {!mediaState?.current && !mediaState?.queue?.length && ( -

- No media queued. Paste a URL above to start playing. -

+ ) : ( + !mediaState?.queue?.length && ( +

+ No media queued. Paste a URL above to start playing. +

+ ) )}
- - @@ -107,7 +130,7 @@ export default function MediaPage() { className="w-24" defaultValue={[mediaState?.musicVolume ?? 0.5]} value={[mediaState?.musicVolume ?? 0.5]} - onValueChange={setVolume} + onValueChange={handleVolume} min={0} max={1} step={0.05} diff --git a/services/frontend/src/app/(dashboard)/messages/page.tsx b/services/frontend/src/app/(dashboard)/messages/page.tsx index e85f54c..ac3488b 100644 --- a/services/frontend/src/app/(dashboard)/messages/page.tsx +++ b/services/frontend/src/app/(dashboard)/messages/page.tsx @@ -1,5 +1,6 @@ "use client"; +import { useQuery } from "@tanstack/react-query"; import { ExternalLink, Flag, @@ -12,7 +13,7 @@ import { } from "lucide-react"; import Image from "next/image"; import { useCallback, useEffect, useMemo, useState } from "react"; -import { ErrorState, LoadingSkeleton } from "@/components/shared"; +import { EmptyState, 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"; @@ -37,13 +38,17 @@ import { import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { useImages, + useLoadMore, useMessageDetail, useMessages, - useMessageWsSubscription, + useMessagesHasMore, + useMessagesWsSync, + useReanalyze, + useReanalyzeBatch, useReview, - useSearch, useTextChannels, } from "@/hooks"; +import { messagesApi } from "@/lib/api"; import { formatBytes, safeParseJsonArray } from "@/lib/format"; import type { MessageRecord } from "@/lib/types"; import { cn } from "@/lib/utils"; @@ -52,98 +57,77 @@ import { useWebSocket } from "@/lib/ws/context"; export default function MessagesPage() { const [guildId, setGuildId] = useState(""); const [selectedChannel, setSelectedChannel] = useState(""); + const [viewTab, setViewTab] = useState<"all" | "images" | "review">("all"); + const [searchQuery, setSearchQuery] = useState(""); + const [detailId, setDetailId] = useState(null); const ws = useWebSocket(); - const { channels } = useTextChannels(guildId); + const { data: channels = [] } = useTextChannels(guildId); const { - messages, - loading, - loadingMore, + data: messages, + isLoading, error, - hasMore, refetch, - loadMore, - prepend, - update, - remove, } = useMessages(guildId, selectedChannel || undefined); - const { images, refetch: refetchImages } = useImages(guildId); - const { reviews, refetch: refetchReviews } = useReview( + const { data: cursorData, refetch: refetchCursor } = useMessagesHasMore( + guildId, selectedChannel || undefined, ); - const { results: searchResults, searching, search } = useSearch(); + const loadMoreMut = useLoadMore(); + const { data: images } = useImages(guildId); + const { data: reviews } = useReview(selectedChannel || undefined); + const reanalyzeMut = useReanalyze(); + const reanalyzeBatchMut = useReanalyzeBatch(); + + // Sync WS events into the TanStack Query cache + useMessagesWsSync(ws, guildId); + + // Detail dialog const { message: detailMessage, attachments: detailAttachments, loading: detailLoading, - open: openDetail, - close: closeDetail, - } = useMessageDetail(); + } = useMessageDetail(detailId); - const [viewTab, setViewTab] = useState<"all" | "images" | "review">("all"); - const [searchQuery, setSearchQuery] = useState(""); + // Images fetch is managed by the query hook (enabled when guildId is set) + // Review fetch is managed by the query hook - // 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], - ); + // Search query (manual trigger) + const [searchEnabled, setSearchEnabled] = useState(false); + const { data: searchResults, isFetching: searching } = useQuery< + MessageRecord[] + >({ + queryKey: ["messages-search", guildId, searchQuery], + queryFn: async () => { + const result = await messagesApi.search(searchQuery, 50); + return result.results; + }, + enabled: searchEnabled && !!searchQuery && !!guildId, + }); - useMessageWsSubscription( - ws, - guildId, - handleCreated, - handleUpdated, - handleDeleted, - handleAnalyzed, - ); + const handleSearch = useCallback(() => { + if (!searchQuery.trim()) return; + setSearchEnabled(true); + }, [searchQuery]); - // Fetch images on mount and when guild changes - useEffect(() => { - refetchImages(); - }, [refetchImages]); + const handleLoadMore = useCallback(() => { + if (!cursorData?.cursor || loadMoreMut.isPending) return; + loadMoreMut.mutate({ + guildId, + channelId: selectedChannel || undefined, + cursor: cursorData.cursor, + }); + }, [cursorData, loadMoreMut, guildId, selectedChannel]); - // Fetch reviews when tab switches - useEffect(() => { - if (viewTab === "review") refetchReviews(); - }, [viewTab, refetchReviews]); - - const handleReanalyze = useCallback(async (id: string) => { - const { messagesApi } = await import("@/lib/api"); - try { - await messagesApi.reanalyze(id); - } catch (err) { - console.error("messages/reanalyze:", err); - } - }, []); - - const handleReanalyzeBatch = useCallback(async () => { - if (!guildId) return; - const { messagesApi } = await import("@/lib/api"); - try { - await messagesApi.reanalyzeBatch(guildId); - } catch (err) { - console.error("messages/reanalyzeBatch:", err); - } - }, [guildId]); - - const displayMessages = searchResults ?? messages; - const _isEmpty = !loading && displayMessages.length === 0; + const displayMessages = searchResults ?? messages ?? []; + const hasMore = cursorData?.hasMore ?? false; + const isEmpty = !isLoading && displayMessages.length === 0; if (error) { return (
- +
); } @@ -152,7 +136,6 @@ export default function MessagesPage() {
- {/* Search + toolbar */}
@@ -160,11 +143,10 @@ export default function MessagesPage() { placeholder="Search messages…" value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && search(searchQuery)} + onKeyDown={(e) => e.key === "Enter" && handleSearch()} className="pl-9 h-9" />
- {channels.length > 0 && ( )} - -
- {/* Tabs */} setViewTab(v as "all" | "images" | "review")} + onValueChange={(v) => setViewTab(v as typeof viewTab)} > - All ({messages.length}) - Images ({images.length}) + + All ({(searchResults ?? messages)?.length ?? 0}) + + + Images ({images?.length ?? 0}) + - - Review ({reviews.length}) + Review ({reviews?.length ?? 0}) - {searchResults !== null && ( + {searchResults && (

Found {searchResults.length} result {searchResults.length !== 1 ? "s" : ""}

)} + {/* ── ALL tab ── */} {viewTab === "all" && ( - +
+ {isLoading ? ( + + ) : isEmpty ? ( +
+ +

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

+
+ ) : ( + <> + {displayMessages.map((msg) => ( + reanalyzeMut.mutate(id)} + /> + ))} + {hasMore && ( +
+ +
+ )} + + )} +
)} + {/* ── IMAGES tab ── */} {viewTab === "images" && ( - openDetail(id)} /> +
+ {!images || images.length === 0 ? ( +
+ +

No images yet.

+
+ ) : ( + images.map((msg) => { + const imgUrl = extractImage(msg.metadata); + return ( + setDetailId(msg.id)} + > +
+ {imgUrl ? ( + {msg.content + ) : ( +
+ No image +
+ )} + {msg.content && ( +
+

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

+
+ )} +
+
+ ); + }) + )} +
)} + {/* ── REVIEW tab ── */} {viewTab === "review" && ( - +
+ {!reviews || reviews.length === 0 ? ( +
+ +

+ No flagged messages to review. +

+
+ ) : ( + reviews.map((msg) => ( + reanalyzeMut.mutate(id)} + /> + )) + )} +
)} {/* Detail dialog */} !o && closeDetail()} + open={detailId !== null} + onOpenChange={(o) => !o && setDetailId(null)} > - - Message Detail + Message Detail - + {detailLoading ? ( +
+ +
+ ) : detailMessage ? ( + + ) : null}
@@ -262,165 +340,7 @@ 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 ──────────────────────────────── +// ── Message Card ──────────────────────────────────────────────── function MessageCard({ message: msg, @@ -431,7 +351,7 @@ function MessageCard({ onClick: (id: string) => void; onReanalyze: (id: string) => void; }) { - const severityBorder = ( + const severity = ( { low: "border-l-sky-400", medium: "border-l-yellow-400", @@ -439,14 +359,13 @@ function MessageCard({ critical: "border-l-red-500", } as Record )[msg.ai_severity ?? ""]; - const hasSeverity = !!severityBorder; return ( onClick(msg.id)} > @@ -458,7 +377,6 @@ function MessageCard({ {msg.username.charAt(0).toUpperCase()} -
{msg.username} @@ -469,7 +387,7 @@ function MessageCard({ {msg.channel_id.slice(0, 8)} - + {msg.ai_severity && msg.ai_severity !== "none" && ( )}
-

{msg.content}

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

{msg.ai_analysis}

)} - {msg.ai_confidence != null && (
@@ -534,7 +448,6 @@ function MessageCard({
)} -
@@ -553,39 +465,39 @@ function MessageCard({ ); } -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", - }; - - if (!status || !colors[status]) return null; - +function AiStatusBadge({ status }: { status?: string | null }) { + const c = ( + { + 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", + } as Record + )[status ?? ""]; + if (!c) return null; return ( {status} ); } -// ── Message Detail ────────────────────────────── +// ── Detail View ────────────────────────────────────────────────── -function MessageDetail({ +function DetailView({ message, attachments, - loading, }: { - message: MessageRecord | null; + message: MessageRecord; attachments: { id: string; filename: string; @@ -594,18 +506,7 @@ function MessageDetail({ uploaded_url?: string | null; discord_url?: string | null; }[]; - loading: boolean; }) { - if (loading) { - return ( -
- -
- ); - } - - if (!message) return null; - return (
@@ -637,7 +538,6 @@ function MessageDetail({

- {message.ai_analysis && (
@@ -649,84 +549,65 @@ function MessageDetail({

{message.ai_analysis}

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

Moderation Flags

- {safeParseJsonArray(message.ai_moderation_flags).map((flag) => ( - - {flag} + {safeParseJsonArray(message.ai_moderation_flags).map((f) => ( + + {f} ))}
)} -
{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})

- {attachments.map((att) => ( + {attachments.map((a) => (
-

{att.filename}

+

{a.filename}

- {att.type} · {formatBytes(att.size)} + {a.type} · {formatBytes(a.size)}

@@ -739,16 +620,44 @@ function MessageDetail({ ); } -// ── Helpers ───────────────────────────────────── +function MiniStat({ + label, + value, + destructive, + capitalize, +}: { + label: string; + value: string; + destructive?: boolean; + capitalize?: boolean; +}) { + return ( + + +

{label}

+

+ {value} +

+
+
+ ); +} -function extractImageUrl(metadata: string | null | undefined): string | null { +// ── Helpers ────────────────────────────────────────────────────── + +function extractImage(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; + const m = JSON.parse(metadata); + const atts: Array<{ url: string; contentType?: string }> = + m.attachments ?? []; + return atts.find((a) => a.contentType?.startsWith("image/"))?.url ?? null; } catch { return null; } diff --git a/services/frontend/src/app/(dashboard)/recordings/page.tsx b/services/frontend/src/app/(dashboard)/recordings/page.tsx index 6db9cc2..b8a27d5 100644 --- a/services/frontend/src/app/(dashboard)/recordings/page.tsx +++ b/services/frontend/src/app/(dashboard)/recordings/page.tsx @@ -1,27 +1,25 @@ "use client"; import { Download, Headphones, Trash2 } from "lucide-react"; -import { useEffect } 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, CardHeader, CardTitle } from "@/components/ui/card"; -import { useRecordings } from "@/hooks"; +import { + useDeleteRecording, + useRecordings, + useRecordingsWsSync, +} from "@/hooks"; import { formatBytes } from "@/lib/format"; import { useWebSocket } from "@/lib/ws/context"; export default function RecordingsPage() { const ws = useWebSocket(); - const { recordings, loading, remove, prepend } = useRecordings(); + const { data: recordings, isLoading } = useRecordings(); + const deleteMut = useDeleteRecording(); - // WS subscription for real-time updates - useEffect(() => { - const unsub = ws.on("voice_recording_uploaded", (data) => { - prepend(data as import("@/lib/types").VoiceRecording); - }); - return unsub; - }, [ws, prepend]); + useRecordingsWsSync(ws); return (
@@ -33,9 +31,9 @@ export default function RecordingsPage() { - {loading ? ( + {isLoading ? ( - ) : recordings.length === 0 ? ( + ) : !recordings || recordings.length === 0 ? ( ) : (
@@ -55,9 +53,8 @@ export default function RecordingsPage() { {rec.username}

- {rec.channel_name ?? rec.channel_id ?? "Unknown channel"} - {" — "} - {new Date(rec.created_at).toLocaleString()} + {rec.channel_name ?? rec.channel_id ?? "Unknown channel"}{" "} + — {new Date(rec.created_at).toLocaleString()}

remove(rec.id)} + onClick={() => deleteMut.mutate(rec.id)} className="hover:text-destructive hover:bg-destructive/10" > diff --git a/services/frontend/src/app/(dashboard)/settings/page.tsx b/services/frontend/src/app/(dashboard)/settings/page.tsx index dede90a..28710f3 100644 --- a/services/frontend/src/app/(dashboard)/settings/page.tsx +++ b/services/frontend/src/app/(dashboard)/settings/page.tsx @@ -12,7 +12,7 @@ import { useWebSocket } from "@/lib/ws/context"; export default function SettingsPage() { const { status } = useWebSocket(); - const { config, loading: configLoading } = useConfig(); + const { data: config, isLoading: configLoading } = useConfig(); const [theme, setTheme] = useState<"light" | "dark">("dark"); useEffect(() => { @@ -28,7 +28,7 @@ export default function SettingsPage() { document.documentElement.classList.add(next); }; - const statusConfig = { + const statusCfg = { connected: { label: "Connected", variant: "default" as const, @@ -56,19 +56,15 @@ export default function SettingsPage() { - - Connection + Connection
WebSocket - - - {statusConfig.label} + + + {statusCfg.label}
@@ -81,7 +77,7 @@ export default function SettingsPage() { ) : ( - )} + )}{" "} Appearance @@ -102,8 +98,7 @@ export default function SettingsPage() { - - Server Configuration + Server Configuration @@ -111,27 +106,27 @@ export default function SettingsPage() { ) : config ? (
- - - - - @@ -147,8 +142,7 @@ export default function SettingsPage() { - - About + About @@ -168,7 +162,7 @@ export default function SettingsPage() { ); } -function ConfigRow({ label, value }: { label: string; value: string }) { +function CfgRow({ label, value }: { label: string; value: string }) { return (
{label} diff --git a/services/frontend/src/app/(dashboard)/voice/page.tsx b/services/frontend/src/app/(dashboard)/voice/page.tsx index a591417..1e6f15b 100644 --- a/services/frontend/src/app/(dashboard)/voice/page.tsx +++ b/services/frontend/src/app/(dashboard)/voice/page.tsx @@ -23,27 +23,30 @@ import { import { Switch } from "@/components/ui/switch"; import { useGuilds, + useMicTransmit, useSpeakers, useVoiceChannels, + useVoiceConnect, + useVoiceDisconnect, useVoiceStatus, } from "@/hooks"; -import { voiceApi } from "@/lib/api"; import { cn } from "@/lib/utils"; import { useWebSocket } from "@/lib/ws/context"; export default function VoicePage() { const ws = useWebSocket(); - const { voiceStatus, refresh: refreshStatus } = useVoiceStatus(); - const { guilds } = useGuilds(); + const { data: voiceStatus } = useVoiceStatus(); + const { data: guilds = [] } = useGuilds(); const { channels: voiceChannels, fetch: fetchChannels } = useVoiceChannels(); const { speakers, subscribe } = useSpeakers(); + const connectMut = useVoiceConnect(); + const disconnectMut = useVoiceDisconnect(); + const micMut = useMicTransmit(); const [selectedGuild, setSelectedGuild] = useState(""); const [selectedChannel, setSelectedChannel] = useState(""); - const [voiceLoading, setVoiceLoading] = useState(false); const [micActive, setMicActive] = useState(false); - // Subscribe to WS speaker events useEffect(() => { const unsub = subscribe(ws); return () => unsub(); @@ -63,30 +66,8 @@ export default function VoicePage() { [fetchChannels], ); - const handleConnect = useCallback(async () => { - if (!selectedGuild || !selectedChannel) return; - setVoiceLoading(true); - try { - const _status = await voiceApi.connect(selectedGuild, selectedChannel); - // voiceStatus will be refreshed - setVoiceLoading(false); - refreshStatus(); - } finally { - setVoiceLoading(false); - } - }, [selectedGuild, selectedChannel, refreshStatus]); - - const handleDisconnect = useCallback(async () => { - setVoiceLoading(true); - try { - await voiceApi.disconnect(); - refreshStatus(); - } finally { - setVoiceLoading(false); - } - }, [refreshStatus]); - const activeSpeakers = speakers.filter((s) => s.speaking); + const connected = voiceStatus?.connected; return (
@@ -98,26 +79,26 @@ export default function VoicePage() { Voice Connection
- {voiceStatus?.connected ? "Connected" : "Disconnected"} + {connected ? "Connected" : "Disconnected"} - {voiceStatus?.connected && voiceStatus.activeChannelName && ( + {connected && voiceStatus?.activeChannelName && (

Connected to{" "} @@ -149,26 +130,20 @@ export default function VoicePage() { - {voiceChannels.length === 0 ? ( - - No channels loaded + {voiceChannels.map((c) => ( + + {c.name} - ) : ( - voiceChannels.map((c) => ( - - {c.name} - - )) - )} + ))} - {voiceStatus?.connected ? ( + {connected ? ( ) : (

- {!voiceStatus?.connected && ( + {!connected && (

Connect to a voice channel first.

diff --git a/services/frontend/src/hooks/index.ts b/services/frontend/src/hooks/index.ts index d90a345..b76d4f6 100644 --- a/services/frontend/src/hooks/index.ts +++ b/services/frontend/src/hooks/index.ts @@ -1,4 +1,3 @@ -export { useAsync } from "./use-async"; export { useConfig } from "./use-config"; export { useChannelDetail, @@ -8,15 +7,37 @@ export { useUsers, } from "./use-dashboard"; export { useGuilds } from "./use-guilds"; -export { useMediaState, useMediaWsSubscription } from "./use-media"; +export { + useMediaQueue, + useMediaSkip, + useMediaState, + useMediaStop, + useMediaVolume, + useMediaWsSync, +} from "./use-media"; export { useImages, + useLoadMore, useMessageDetail, useMessages, - useMessageWsSubscription, + useMessagesHasMore, + useMessagesWsSync, + useReanalyze, + useReanalyzeBatch, useReview, useSearch, useTextChannels, } from "./use-messages"; -export { useRecordings, useRecordingsWsSubscription } from "./use-recordings"; -export { useSpeakers, useVoiceChannels, useVoiceStatus } from "./use-voice"; +export { + useDeleteRecording, + useRecordings, + useRecordingsWsSync, +} from "./use-recordings"; +export { + useMicTransmit, + useSpeakers, + useVoiceChannels, + useVoiceConnect, + useVoiceDisconnect, + useVoiceStatus, +} from "./use-voice"; diff --git a/services/frontend/src/hooks/use-async.ts b/services/frontend/src/hooks/use-async.ts deleted file mode 100644 index 4df7507..0000000 --- a/services/frontend/src/hooks/use-async.ts +++ /dev/null @@ -1,58 +0,0 @@ -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 index edc1db9..f4c35f4 100644 --- a/services/frontend/src/hooks/use-config.ts +++ b/services/frontend/src/hooks/use-config.ts @@ -1,21 +1,15 @@ +import { useQuery } from "@tanstack/react-query"; + 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 }; +export function useConfig() { + return useQuery({ + queryKey: ["config"], + queryFn: () => configApi.get(), + staleTime: 120_000, + }); } diff --git a/services/frontend/src/hooks/use-dashboard.ts b/services/frontend/src/hooks/use-dashboard.ts index 5106687..483cf86 100644 --- a/services/frontend/src/hooks/use-dashboard.ts +++ b/services/frontend/src/hooks/use-dashboard.ts @@ -1,173 +1,48 @@ -import { useCallback, useEffect, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; 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() { + return useQuery({ + queryKey: ["dashboard-stats"], + queryFn: () => dashboardApi.getStats(), + }); } -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); - } - }, []); - - useEffect(() => { - fetch(); - }, [fetch]); - - return { stats, loading, error, refetch: fetch }; +export function useUsers(search?: string) { + return useQuery({ + queryKey: ["dashboard-users", search ?? ""], + queryFn: () => dashboardApi.listUsers(20, undefined, search), + select: (data) => data.data, + }); } -// ── Users ─────────────────────────────────────── - -interface UseUsersReturn { - users: DashboardUser[]; - loading: boolean; - search: string; - setSearch: (q: string) => void; - refetch: () => void; +export function useChannels(guildId: string, search?: string) { + return useQuery({ + queryKey: ["dashboard-channels", guildId, search ?? ""], + queryFn: () => dashboardApi.listChannels(20, search, guildId || undefined), + select: (data) => data.data, + enabled: !!guildId, + }); } -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 (err) { - console.error("useUsers:", err); - } finally { - setLoading(false); - } - }, []); - - const fetchWithSearch = useCallback(() => { - fetch(search || undefined); - }, [fetch, search]); - - return { - users, - loading, - search, - setSearch, - refetch: fetchWithSearch, - }; +export function useUserDetail(userId: string | null) { + return useQuery({ + queryKey: ["dashboard-user", userId], + queryFn: () => dashboardApi.getUserDetail(userId!), + enabled: !!userId, + }); } -// ── 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 (err) { - console.error("useChannels:", err); - } 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 (err) { - console.error("useUserDetail:", err); - } 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 (err) { - console.error("useChannelDetail:", err); - } finally { - setLoading(false); - } - }, []); - - return { channel, loading, fetch }; +export function useChannelDetail(channelId: string | null) { + return useQuery({ + queryKey: ["dashboard-channel", channelId], + queryFn: () => dashboardApi.getChannelDetail(channelId!), + enabled: !!channelId, + }); } diff --git a/services/frontend/src/hooks/use-guilds.ts b/services/frontend/src/hooks/use-guilds.ts index c08501d..41334bd 100644 --- a/services/frontend/src/hooks/use-guilds.ts +++ b/services/frontend/src/hooks/use-guilds.ts @@ -1,38 +1,15 @@ -import { useCallback, useEffect, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; 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 }; +export function useGuilds() { + return useQuery({ + queryKey: ["guilds"], + queryFn: () => voiceApi.getGuilds(), + staleTime: 60_000, + }); } diff --git a/services/frontend/src/hooks/use-media.ts b/services/frontend/src/hooks/use-media.ts index 8e40668..4dca703 100644 --- a/services/frontend/src/hooks/use-media.ts +++ b/services/frontend/src/hooks/use-media.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { voiceApi } from "@/lib/api"; import type { MediaState } from "@/lib/types"; @@ -11,74 +11,60 @@ type WsHook = { ) => () => 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() { + return useQuery({ + queryKey: ["media-state"], + queryFn: () => voiceApi.getMediaStatus(), + retry: false, + refetchInterval: 10_000, + }); } -export function useMediaState(): UseMediaStateReturn { - const [mediaState, setMediaState] = useState(null); +export function useMediaQueue() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (url: string) => voiceApi.mediaQueue(url, "music"), + onSuccess: (data) => qc.setQueryData(["media-state"], data), + }); +} - const refresh = useCallback(async () => { - try { - const state = await voiceApi.getMediaStatus(); - setMediaState(state); - } catch (err) { - console.error("useMediaState/refresh:", err); - } - }, []); +export function useMediaSkip() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: () => voiceApi.mediaSkip(), + onSuccess: (data) => qc.setQueryData(["media-state"], data), + }); +} - const queue = useCallback(async (url: string) => { - try { - const state = await voiceApi.mediaQueue(url, "music"); - setMediaState(state); - } catch (err) { - console.error("useMediaState/queue:", err); - } - }, []); +export function useMediaStop() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: () => voiceApi.mediaStop(), + onSuccess: (data) => qc.setQueryData(["media-state"], data), + }); +} - const skip = useCallback(async () => { - try { - const state = await voiceApi.mediaSkip(); - setMediaState(state); - } catch (err) { - console.error("useMediaState/skip:", err); - } - }, []); +export function useMediaVolume() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (volume: number) => voiceApi.mediaVolume(volume), + onSuccess: (data) => qc.setQueryData(["media-state"], data), + }); +} - const stop = useCallback(async () => { - try { - const state = await voiceApi.mediaStop(); - setMediaState(state); - } catch (err) { - console.error("useMediaState/stop:", err); - } - }, []); +/** Subscribe to WS media_state events to keep cache fresh */ +export function useMediaWsSync(ws: WsHook) { + const qc = useQueryClient(); + useEffectFn(ws, qc); +} - 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 (err) { - console.error("useMediaState/setVolume:", err); - } - }, []); +import { useEffect } from "react"; +function useEffectFn(ws: WsHook, qc: ReturnType) { useEffect(() => { - refresh(); - }, [refresh]); - - 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)); + const unsub = ws.on("media_state", (data) => { + qc.setQueryData(["media-state"], data as MediaState); + }); + return unsub; + }, [ws, qc]); } diff --git a/services/frontend/src/hooks/use-messages.ts b/services/frontend/src/hooks/use-messages.ts index 9124cd7..bf80e28 100644 --- a/services/frontend/src/hooks/use-messages.ts +++ b/services/frontend/src/hooks/use-messages.ts @@ -1,4 +1,5 @@ -import { useCallback, useEffect, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useCallback, useEffect } from "react"; import { messagesApi, voiceApi } from "@/lib/api"; import type { AttachmentRecord, Channel, MessageRecord } from "@/lib/types"; @@ -11,262 +12,210 @@ type WsHook = { ) => () => void; }; -// ── Messages list ─────────────────────────────── +// ── Query keys factory ─────────────────────────── -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; -} +const msgKeys = { + list: (guildId: string, channelId?: string) => + ["messages", guildId, channelId ?? "__all__"] as const, + images: (guildId: string) => ["messages-images", guildId] as const, + review: (channelId?: string) => + ["messages-review", channelId ?? "__all__"] as const, + detail: (id: string) => ["message-detail", id] as const, +}; -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); +// ── Messages list (paginated, cursor-based) ────── - const fetch = useCallback(async () => { - if (!guildId) return; - setLoading(true); - setError(null); - try { +export function useMessages(guildId: string, channelId?: string) { + return useQuery({ + queryKey: msgKeys.list(guildId, channelId), + queryFn: async () => { 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]); + return result.data; + }, + enabled: !!guildId, + }); +} - const loadMore = useCallback(async () => { - if (!cursor || loadingMore) return; - setLoadingMore(true); - try { +export function useMessagesHasMore(guildId: string, channelId?: string) { + return useQuery({ + queryKey: [...msgKeys.list(guildId, channelId), "cursor"], + queryFn: async () => { + const result = await messagesApi.list( + guildId, + 50, + channelId || undefined, + ); + return { cursor: result.nextCursor, hasMore: result.nextCursor !== null }; + }, + enabled: !!guildId, + }); +} + +export function useLoadMore() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async ({ + guildId, + channelId, + cursor, + }: { + guildId: string; + channelId?: string; + cursor: string; + }) => { const result = await messagesApi.list( guildId, 50, channelId || undefined, cursor, ); - setMessages((prev) => [...prev, ...result.data]); - setCursor(result.nextCursor); - setHasMore(result.nextCursor !== null); - } catch (err) { - console.error("useMessages/loadMore:", err); - } finally { - setLoadingMore(false); - } - }, [cursor, loadingMore, guildId, channelId]); + return { data: result.data, cursor: result.nextCursor }; + }, + onSuccess: (data, vars) => { + const key = msgKeys.list(vars.guildId, vars.channelId); + qc.setQueryData(key, (old) => + old ? [...old, ...data.data] : data.data, + ); + qc.setQueryData([...key, "cursor"], { + cursor: data.cursor, + hasMore: data.cursor !== null, + }); + }, + }); +} - // Auto-fetch when guildId/channelId changes - useEffect(() => { - fetch(); - }, [fetch]); +// ── Channels list ──────────────────────────────── - const prepend = useCallback((msg: MessageRecord) => { - setMessages((prev) => [msg, ...prev]); - }, []); +export function useTextChannels(guildId: string) { + return useQuery({ + queryKey: ["text-channels", guildId], + queryFn: () => voiceApi.getTextChannels(guildId), + enabled: !!guildId, + }); +} - const update = useCallback((msg: MessageRecord) => { - setMessages((prev) => prev.map((m) => (m.id === msg.id ? msg : m))); - }, []); +// ── Search ─────────────────────────────────────── - const remove = useCallback((id: string) => { - setMessages((prev) => prev.filter((m) => m.id !== id)); - }, []); +export function useSearch() { + return useQuery({ + queryKey: ["messages-search"], + queryFn: () => Promise.resolve([]), + enabled: false, + }); +} +// ── Images ─────────────────────────────────────── + +export function useImages(guildId: string) { + return useQuery({ + queryKey: msgKeys.images(guildId), + queryFn: async () => { + const result = await messagesApi.getImages(guildId, 50); + return result.data; + }, + enabled: !!guildId, + }); +} + +// ── Review ─────────────────────────────────────── + +export function useReview(channelId?: string) { + return useQuery({ + queryKey: msgKeys.review(channelId), + queryFn: async () => { + const result = await messagesApi.getReview(50, channelId || undefined); + return result.results; + }, + }); +} + +// ── Detail ─────────────────────────────────────── + +export function useMessageDetail(id: string | null) { + const detail = useQuery({ + queryKey: msgKeys.detail(id ?? ""), + queryFn: () => messagesApi.getDetail(id!), + enabled: !!id, + }); + const attachments = useQuery({ + queryKey: [...msgKeys.detail(id ?? ""), "attachments"], + queryFn: async () => { + if (!id) return []; + const res = await messagesApi.getAttachments( + detail.data?.channel_id ?? "", + 10, + ); + return res.data; + }, + enabled: !!id && !!detail.data?.channel_id, + }); return { - messages, - loading, - loadingMore, - error, - hasMore, - refetch: fetch, - loadMore, - prepend, - update, - remove, + message: detail.data ?? null, + attachments: attachments.data ?? [], + loading: detail.isLoading || attachments.isLoading, + error: detail.error, }; } -// ── Channels list ─────────────────────────────── +// ── Mutations ──────────────────────────────────── -interface UseTextChannelsReturn { - channels: Channel[]; - loading: boolean; +export function useReanalyze() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => messagesApi.reanalyze(id), + }); } -export function useTextChannels(guildId: string): UseTextChannelsReturn { - const [channels, setChannels] = useState([]); - const [loading, setLoading] = useState(true); +export function useReanalyzeBatch() { + return useMutation({ + mutationFn: (guildId: string) => messagesApi.reanalyzeBatch(guildId), + }); +} +// ── WS sync helpers ────────────────────────────── + +export function useMessagesWsSync(ws: WsHook, guildId: string) { + const qc = useQueryClient(); useEffect(() => { if (!guildId) return; - voiceApi - .getTextChannels(guildId) - .then(setChannels) - .catch((err) => console.error("useTextChannels:", err)) - .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 (err) { - console.error("useSearch:", err); - 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 (err) { - console.error("useImages:", err); - } - }, [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 (err) { - console.error("useReview:", err); - } - }, [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((err) => console.error("useMessageDetail/attachments:", err)); - } - } catch (err) { - console.error("useMessageDetail:", err); - 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), - ); + const key = msgKeys.list(guildId); + const unsub1 = ws.on("message_created", (data) => { + qc.setQueryData(key, (old) => + old ? [data as MessageRecord, ...old] : [data as MessageRecord], + ); + }); + const unsub2 = ws.on("message_updated", (data) => { + qc.setQueryData(key, (old) => + old + ? old.map((m) => + m.id === (data as MessageRecord).id ? (data as MessageRecord) : m, + ) + : old, + ); + }); + const unsub3 = ws.on("message_deleted", (data) => { + qc.setQueryData(key, (old) => + old ? old.filter((m) => m.id !== (data as unknown as string)) : old, + ); + }); + const unsub4 = ws.on("message_analyzed", (data) => { + qc.setQueryData(key, (old) => + old + ? old.map((m) => + m.id === (data as MessageRecord).id ? (data as MessageRecord) : m, + ) + : old, + ); + }); return () => { unsub1(); unsub2(); unsub3(); unsub4(); }; - }, [ws, guildId, onCreated, onUpdated, onDeleted, onAnalyzed]); + }, [ws, guildId, qc]); } diff --git a/services/frontend/src/hooks/use-recordings.ts b/services/frontend/src/hooks/use-recordings.ts index 8c1dd47..dee073b 100644 --- a/services/frontend/src/hooks/use-recordings.ts +++ b/services/frontend/src/hooks/use-recordings.ts @@ -1,4 +1,5 @@ -import { useCallback, useEffect, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useEffect } from "react"; import { recordingsApi } from "@/lib/api"; import type { VoiceRecording } from "@/lib/types"; @@ -11,55 +12,33 @@ type WsHook = { ) => () => void; }; -interface UseRecordingsReturn { - recordings: VoiceRecording[]; - loading: boolean; - refresh: () => void; - remove: (id: string) => void; - prepend: (rec: VoiceRecording) => void; +export function useRecordings() { + return useQuery({ + queryKey: ["recordings"], + queryFn: async () => { + const res = await recordingsApi.list(50); + return res.items; + }, + }); } -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 (err) { - console.error("useRecordings/refresh:", err); - } finally { - setLoading(false); - } - }, []); +export function useDeleteRecording() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => recordingsApi.delete(id), + onSuccess: () => qc.invalidateQueries({ queryKey: ["recordings"] }), + }); +} +export function useRecordingsWsSync(ws: WsHook) { + const qc = useQueryClient(); useEffect(() => { - refresh(); - }, [refresh]); - - const remove = useCallback(async (id: string) => { - try { - await recordingsApi.delete(id); - setRecordings((prev) => prev.filter((r) => r.id !== id)); - } catch (err) { - console.error("useRecordings/remove:", err); - } - }, []); - - 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), - ); + const unsub = ws.on("voice_recording_uploaded", (data) => { + const rec = data as VoiceRecording; + qc.setQueryData(["recordings"], (old) => + old ? [rec, ...old] : [rec], + ); + }); + return unsub; + }, [ws, qc]); } diff --git a/services/frontend/src/hooks/use-voice.ts b/services/frontend/src/hooks/use-voice.ts index d43db02..285dbae 100644 --- a/services/frontend/src/hooks/use-voice.ts +++ b/services/frontend/src/hooks/use-voice.ts @@ -1,3 +1,4 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useCallback, useEffect, useState } from "react"; import { voiceApi } from "@/lib/api"; @@ -11,37 +12,15 @@ type WsHook = { ) => () => void; }; -interface UseVoiceStatusReturn { - voiceStatus: VoiceStatus | null; - refresh: () => void; +export function useVoiceStatus() { + return useQuery({ + queryKey: ["voice-status"], + queryFn: () => voiceApi.getStatus(), + retry: false, + }); } -export function useVoiceStatus(): UseVoiceStatusReturn { - const [voiceStatus, setVoiceStatus] = useState(null); - - const refresh = useCallback(async () => { - try { - const status = await voiceApi.getStatus(); - setVoiceStatus(status); - } catch (err) { - console.error("useVoiceStatus:", err); - } - }, []); - - useEffect(() => { - refresh(); - }, [refresh]); - - return { voiceStatus, refresh }; -} - -interface UseVoiceChannelsReturn { - channels: Array<{ id: string; name: string }>; - loading: boolean; - fetch: (guildId: string) => void; -} - -export function useVoiceChannels(): UseVoiceChannelsReturn { +export function useVoiceChannels() { const [channels, setChannels] = useState>( [], ); @@ -63,12 +42,7 @@ export function useVoiceChannels(): UseVoiceChannelsReturn { return { channels, loading, fetch }; } -interface UseSpeakersReturn { - speakers: ActiveSpeaker[]; - subscribe: (ws: WsHook) => () => void; -} - -export function useSpeakers(): UseSpeakersReturn { +export function useSpeakers() { const [speakers, setSpeakers] = useState([]); const subscribe = useCallback((ws: WsHook) => { @@ -92,3 +66,34 @@ export function useSpeakers(): UseSpeakersReturn { return { speakers, subscribe }; } + +export function useVoiceConnect() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ + guildId, + channelId, + }: { + guildId: string; + channelId: string; + }) => voiceApi.connect(guildId, channelId), + onSuccess: () => qc.invalidateQueries({ queryKey: ["voice-status"] }), + }); +} + +export function useVoiceDisconnect() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: () => voiceApi.disconnect(), + onSuccess: () => qc.invalidateQueries({ queryKey: ["voice-status"] }), + }); +} + +export function useMicTransmit() { + return useMutation({ + mutationFn: (active: boolean) => + voiceApi.sendCommand( + active ? "voice:transmit:start" : "voice:transmit:stop", + ), + }); +} diff --git a/services/frontend/src/lib/hooks/use-config.ts b/services/frontend/src/lib/hooks/use-config.ts deleted file mode 100644 index e9d5b03..0000000 --- a/services/frontend/src/lib/hooks/use-config.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { useEffect, useState } from "react"; -import { configApi } from "@/lib/api"; -import type { AppConfig } from "@/lib/types/guild"; - -export function useAppConfig() { - const [config, setConfig] = useState(null); - const [loading, setLoading] = useState(true); - - useEffect(() => { - configApi - .get() - .then((cfg) => { - setConfig(cfg); - }) - .catch(() => { - // silent — config fetch is not critical - }) - .finally(() => setLoading(false)); - }, []); - - return { config, loading }; -}