Refactor voice page and hooks to use React Query for data fetching
Deploy to VPS / deploy (push) Successful in 2m57s

- 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.
This commit is contained in:
asepharyana
2026-07-26 16:55:20 +07:00
parent 323fa207af
commit 05bbae66da
20 changed files with 980 additions and 1582 deletions
@@ -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<MessageRecord[]>({
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 (
<div className="space-y-5 animate-fade-in-up">
@@ -47,20 +48,19 @@ export default function AnalysisPage() {
className="pl-9 h-9"
/>
</div>
<Button onClick={handleSearch} disabled={!query.trim() || searching}>
{searching && <Loader2 className="size-4 animate-spin mr-1.5" />}
<Button onClick={handleSearch} disabled={!query.trim() || isFetching}>
{isFetching && <Loader2 className="size-4 animate-spin mr-1.5" />}
Search
</Button>
</div>
{searching ? (
{isFetching ? (
<LoadingSkeleton count={5} height="h-28" />
) : results !== null ? (
) : results !== undefined ? (
<>
<p className="text-sm text-muted-foreground">
Found {results.length} result{results.length !== 1 ? "s" : ""}
</p>
{results.length === 0 ? (
<EmptyState
icon={Search}
@@ -69,16 +69,87 @@ export default function AnalysisPage() {
) : (
<div className="space-y-2">
{results.map((msg) => (
<SearchResultCard
key={msg.id}
message={msg}
onReanalyze={handleReanalyze}
/>
<Card key={msg.id}>
<CardContent className="p-4">
<div className="flex items-start gap-3">
<Avatar className="size-8 shrink-0 mt-0.5">
<AvatarImage src={msg.avatar_url ?? undefined} />
<AvatarFallback className="text-xs">
{msg.username.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0 space-y-2">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium">
{msg.username}
</span>
<span className="text-xs text-muted-foreground">
{new Date(msg.created_at).toLocaleString()}
</span>
{msg.ai_status && (
<Badge
variant="outline"
className={cn(
"text-[10px] px-1.5 py-0 h-4",
msg.ai_status === "clean" && "text-green-500",
msg.ai_status === "flagged" && "text-red-500",
)}
>
{msg.ai_status}
</Badge>
)}
</div>
<p className="text-sm leading-relaxed">{msg.content}</p>
{msg.ai_moderation_flags &&
msg.ai_moderation_flags !== "[]" && (
<div className="flex flex-wrap gap-1">
{safeParseJsonArray(msg.ai_moderation_flags).map(
(flag) => (
<Badge
key={flag}
variant="destructive"
className="text-[10px] px-1.5 py-0 h-4"
>
{flag}
</Badge>
),
)}
</div>
)}
{msg.ai_analysis && (
<p className="text-xs text-muted-foreground italic line-clamp-2 leading-relaxed">
<Sparkles className="size-3 inline mr-1" />
{msg.ai_analysis}
</p>
)}
{msg.ai_confidence != null && (
<div className="flex items-center gap-2 max-w-40">
<Progress
value={msg.ai_confidence * 100}
className="h-1.5"
/>
<span className="text-[11px] text-muted-foreground tabular-nums shrink-0">
{(msg.ai_confidence * 100).toFixed(0)}%
</span>
</div>
)}
<Button
variant="ghost"
size="xs"
onClick={() => reanalyzeMut.mutate(msg.id)}
>
<RefreshCw className="size-3 mr-1" />
Reanalyze
</Button>
</div>
</div>
</CardContent>
</Card>
))}
</div>
)}
</>
) : !searched ? (
) : (
<div className="flex flex-col items-center justify-center py-24 text-center">
<Search className="size-12 text-muted-foreground/30 mb-4" />
<p className="text-sm text-muted-foreground">
@@ -88,92 +159,7 @@ export default function AnalysisPage() {
Searches message content, AI flags, and analysis text.
</p>
</div>
) : null}
)}
</div>
);
}
function SearchResultCard({
message: msg,
onReanalyze,
}: {
message: MessageRecord;
onReanalyze: (id: string) => void;
}) {
return (
<Card>
<CardContent className="p-4">
<div className="flex items-start gap-3">
<Avatar className="size-8 shrink-0 mt-0.5">
<AvatarImage src={msg.avatar_url ?? undefined} />
<AvatarFallback className="text-xs">
{msg.username.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0 space-y-2">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium">{msg.username}</span>
<span className="text-xs text-muted-foreground">
{new Date(msg.created_at).toLocaleString()}
</span>
{msg.ai_status && (
<Badge
variant="outline"
className={cn(
"text-[10px] px-1.5 py-0 h-4",
msg.ai_status === "clean" && "text-green-500",
msg.ai_status === "flagged" && "text-red-500",
)}
>
{msg.ai_status}
</Badge>
)}
</div>
<p className="text-sm leading-relaxed">{msg.content}</p>
{msg.ai_moderation_flags && msg.ai_moderation_flags !== "[]" && (
<div className="flex flex-wrap gap-1">
{safeParseJsonArray(msg.ai_moderation_flags).map((flag) => (
<Badge
key={flag}
variant="destructive"
className="text-[10px] px-1.5 py-0 h-4"
>
{flag}
</Badge>
))}
</div>
)}
{msg.ai_analysis && (
<p className="text-xs text-muted-foreground italic line-clamp-2 leading-relaxed">
<Sparkles className="size-3 inline mr-1" />
{msg.ai_analysis}
</p>
)}
{msg.ai_confidence != null && (
<div className="flex items-center gap-2 max-w-40">
<Progress value={msg.ai_confidence * 100} className="h-1.5" />
<span className="text-[11px] text-muted-foreground tabular-nums shrink-0">
{(msg.ai_confidence * 100).toFixed(0)}%
</span>
</div>
)}
<Button
variant="ghost"
size="xs"
onClick={() => onReanalyze(msg.id)}
>
<RefreshCw className="size-3 mr-1" />
Reanalyze
</Button>
</div>
</div>
</CardContent>
</Card>
);
}
@@ -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<View>("stats");
const [guildId, setGuildId] = useState("");
const [activeUser, setActiveUser] = useState<DashboardUserDetail | null>(
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
const [selectedChannelId, setSelectedChannelId] = useState<string | null>(
null,
);
const [activeChannel, setActiveChannel] =
useState<DashboardChannelDetail | null>(null);
return (
<div className="space-y-5">
@@ -59,56 +65,30 @@ export default function DashboardPage() {
>
<TabsList>
<TabsTrigger value="stats" onClick={() => setView("stats")}>
<BarChart3 className="size-4" />
Stats
<BarChart3 className="size-4" /> Stats
</TabsTrigger>
<TabsTrigger value="users" onClick={() => setView("users")}>
<Users className="size-4" />
Users
<Users className="size-4" /> Users
</TabsTrigger>
<TabsTrigger value="channels" onClick={() => setView("channels")}>
<Hash className="size-4" />
Channels
<Hash className="size-4" /> Channels
</TabsTrigger>
</TabsList>
</Tabs>
{view === "stats" && <StatsSection />}
{view === "users" && (
<UsersSection
onSelect={async (userId) => {
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" && (
<ChannelsSection
guildId={guildId}
onSelect={async (chId) => {
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 && (
<UserDetailView user={activeUser} onBack={() => setView("users")} />
)}
{view === "channel-detail" && activeChannel && (
<ChannelDetailView
channel={activeChannel}
{view === "channel-detail" && selectedChannelId && (
<ChannelDetailSection
channelId={selectedChannelId}
onBack={() => 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 <ErrorState message={error.message} onRetry={refetch} />;
if (isLoading || !stats)
return (
<div className="space-y-5 animate-fade-in-up">
<LoadingSkeleton count={8} height="h-28" columns={4} />
</div>
);
}
return (
<div className="space-y-5 animate-fade-in-up">
@@ -167,13 +146,11 @@ function StatsSection() {
icon={Sparkles}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-sm font-semibold">
<Hash className="size-4 text-muted-foreground" />
Top Channels
<Hash className="size-4 text-muted-foreground" /> Top Channels
</CardTitle>
</CardHeader>
<CardContent>
@@ -204,30 +181,44 @@ function StatsSection() {
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-sm font-semibold">
<Shield className="size-4 text-muted-foreground" />
Moderation Queue
<Shield className="size-4 text-muted-foreground" /> Moderation
Queue
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-3 gap-3">
<QueueStat
label="Pending"
value={stats.moderation_overview.pending}
/>
<QueueStat
label="Processing"
value={stats.moderation_overview.processing}
variant="warning"
/>
<QueueStat
label="Errors"
value={stats.moderation_overview.error}
variant="danger"
/>
{[
{
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 }) => (
<div
key={label}
className={`rounded-lg p-3 text-center space-y-1.5 ${cls}`}
>
<div
className={`text-2xl font-bold tabular-nums ${cls.includes("yellow") ? "text-yellow-500" : cls.includes("destructive") ? "text-destructive" : ""}`}
>
{value}
</div>
<div className="text-xs text-muted-foreground">{label}</div>
</div>
))}
</div>
</CardContent>
</Card>
@@ -236,118 +227,7 @@ function StatsSection() {
);
}
function QueueStat({
label,
value,
variant,
}: {
label: string;
value: number;
variant?: "default" | "warning" | "danger";
}) {
return (
<div
className={`rounded-lg p-3 text-center space-y-1.5 ${
variant === "danger"
? "bg-destructive/10"
: variant === "warning"
? "bg-yellow-500/10"
: "bg-muted/50"
}`}
>
<div
className={`text-2xl font-bold tabular-nums ${
variant === "danger"
? "text-destructive"
: variant === "warning"
? "text-yellow-500"
: ""
}`}
>
{value}
</div>
<div className="text-xs text-muted-foreground">{label}</div>
</div>
);
}
// ── 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 (
<div className="space-y-4 animate-fade-in-up">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
placeholder="Search users…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9 h-9"
/>
</div>
{loading ? (
<LoadingSkeleton count={6} height="h-20" columns={2} />
) : users.length === 0 ? (
<EmptyState icon={Users} title="No users found." />
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{users.map((user) => (
<Card
key={user.user_id}
className="cursor-pointer hover:bg-accent/5 transition-colors"
onClick={() => onSelect(user.user_id)}
>
<CardContent className="p-3">
<div className="flex items-center gap-3">
<div className="size-10 shrink-0 rounded-full bg-muted flex items-center justify-center text-sm font-medium overflow-hidden ring-1 ring-border">
{user.avatar_url ? (
<Image
src={user.avatar_url}
alt=""
width={40}
height={40}
className="size-full object-cover"
/>
) : (
(user.username ?? "?").charAt(0).toUpperCase()
)}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">
{user.username ?? "Unknown"}
</p>
<p className="text-xs text-muted-foreground flex items-center gap-2">
<span>{user.total_messages} messages</span>
{user.flagged_count > 0 && (
<Badge
variant="destructive"
className="text-[10px] px-1.5 py-0 h-4"
>
{user.flagged_count} flagged
</Badge>
)}
</p>
</div>
<ChevronRight className="size-4 text-muted-foreground shrink-0" />
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
);
}
// ── 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 (
<div className="space-y-4 animate-fade-in-up">
@@ -375,10 +254,9 @@ function ChannelsSection({
className="pl-9 h-9"
/>
</div>
{loading ? (
{isLoading ? (
<LoadingSkeleton count={6} height="h-20" />
) : channels.length === 0 ? (
) : !channels || channels.length === 0 ? (
<EmptyState icon={Hash} title="No channels found." />
) : (
<div className="space-y-2">
@@ -397,16 +275,11 @@ function ChannelsSection({
{ch.channel_name ?? ch.channel_id.slice(0, 8)}
</p>
</div>
<p className="text-xs text-muted-foreground mt-0.5 flex items-center gap-2">
<span>{ch.total_messages} messages</span>
{ch.flagged_count > 0 && (
<Badge
variant="destructive"
className="text-[10px] px-1.5 py-0 h-4"
>
{ch.flagged_count} flagged
</Badge>
)}
<p className="text-xs text-muted-foreground mt-0.5">
{ch.total_messages} messages
{ch.flagged_count > 0
? ` · ${ch.flagged_count} flagged`
: ""}
</p>
</div>
<ChevronRight className="size-4 text-muted-foreground shrink-0 ml-2" />
@@ -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 <LoadingSkeleton count={1} height="h-64" />;
if (!channel) return <ErrorState message="Channel not found." />;
return (
<div className="space-y-5 animate-fade-in-up">
<Button variant="ghost" size="sm" onClick={onBack}>
<ArrowLeft className="size-4 mr-1" />
Back
<ArrowLeft className="size-4 mr-1" /> Back
</Button>
<Card>
<CardContent className="p-6 space-y-5">
<div className="flex items-center gap-4">
<div className="size-14 shrink-0 rounded-full bg-muted flex items-center justify-center text-xl font-medium overflow-hidden ring-2 ring-border">
{user.avatar_url ? (
<Image
src={user.avatar_url}
alt=""
width={56}
height={56}
className="size-full object-cover"
/>
) : (
(user.username ?? "?").charAt(0).toUpperCase()
)}
</div>
<div className="min-w-0">
<h2 className="text-lg font-semibold">
{user.username ?? "Unknown"}
</h2>
<p className="text-xs text-muted-foreground font-mono">
{user.user_id}
</p>
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<DetailStat label="Messages" value={user.total_messages} />
<DetailStat
label="Flagged"
value={user.flagged_count}
variant="danger"
/>
<DetailStat
label="Clean Streak"
value={user.clean_message_streak ?? 0}
/>
<DetailStat
label="Trust Score"
value={user.trust_score ?? 0}
suffix="%"
/>
</div>
{user.profile_summary && (
<div className="rounded-lg bg-gradient-to-br from-primary/5 to-primary/[0.02] border border-primary/10 p-4">
<div className="flex items-center gap-2 mb-2">
<Sparkles className="size-4 text-primary" />
<p className="text-xs text-muted-foreground font-medium uppercase tracking-wider">
AI Profile
</p>
</div>
<p className="text-sm leading-relaxed">{user.profile_summary}</p>
</div>
)}
{user.recent_messages.length > 0 && (
<div className="space-y-3">
<h3 className="text-sm font-semibold flex items-center gap-2">
<Clock className="size-4 text-muted-foreground" />
Recent Messages
</h3>
<div className="space-y-2 max-h-80 overflow-y-auto">
{user.recent_messages.slice(0, 5).map((msg) => (
<div
key={msg.id}
className="rounded-lg border border-border/50 bg-muted/20 p-3 text-sm"
>
<p className="text-xs text-muted-foreground mb-1 flex items-center gap-2">
<Clock className="size-3" />
{new Date(msg.created_at).toLocaleString()}
</p>
<p className="text-sm">{msg.content}</p>
</div>
))}
</div>
</div>
)}
</CardContent>
</Card>
</div>
);
}
// ── Channel Detail View ─────────────────────────
function ChannelDetailView({
channel,
onBack,
}: {
channel: DashboardChannelDetail;
onBack: () => void;
}) {
return (
<div className="space-y-5 animate-fade-in-up">
<Button variant="ghost" size="sm" onClick={onBack}>
<ArrowLeft className="size-4 mr-1" />
Back
</Button>
<Card>
<CardContent className="p-6 space-y-5">
<div>
@@ -552,7 +327,6 @@ function ChannelDetailView({
{channel.channel_id}
</p>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<DetailStat label="Messages" value={channel.total_messages} />
<DetailStat
@@ -566,7 +340,6 @@ function ChannelDetailView({
variant="success"
/>
</div>
{channel.culture_summary && (
<div className="rounded-lg bg-gradient-to-br from-primary/5 to-primary/[0.02] border border-primary/10 p-4">
<div className="flex items-center gap-2 mb-2">
@@ -580,12 +353,11 @@ function ChannelDetailView({
</p>
</div>
)}
{channel.recent_messages.length > 0 && (
<div className="space-y-3">
<h3 className="text-sm font-semibold flex items-center gap-2">
<Clock className="size-4 text-muted-foreground" />
Recent Messages
<Clock className="size-4 text-muted-foreground" /> Recent
Messages
</h3>
<div className="space-y-2">
{channel.recent_messages.slice(0, 5).map((msg) => (
@@ -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 (
<WsProvider>
<div className="flex h-screen overflow-hidden bg-background">
<AppSidebar />
<div className="flex flex-1 flex-col min-w-0">
<AppHeader />
<main className="flex-1 overflow-y-auto p-4 md:p-6 pb-20 md:pb-6">
<Suspense
fallback={
<div className="flex h-full items-center justify-center">
<div className="size-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
}
>
{children}
</Suspense>
</main>
<QueryClientProvider client={queryClient}>
<WsProvider>
<div className="flex h-screen overflow-hidden bg-background">
<AppSidebar />
<div className="flex flex-1 flex-col min-w-0">
<AppHeader />
<main className="flex-1 overflow-y-auto p-4 md:p-6 pb-20 md:pb-6">
<Suspense
fallback={
<div className="flex h-full items-center justify-center">
<div className="size-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
}
>
{children}
</Suspense>
</main>
</div>
<MobileNav />
</div>
<MobileNav />
</div>
<Chatbot />
</WsProvider>
<Chatbot />
</WsProvider>
</QueryClientProvider>
);
}
@@ -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 (
<div className="space-y-5 animate-fade-in-up">
@@ -46,13 +62,16 @@ export default function MediaPage() {
onKeyDown={(e) => e.key === "Enter" && handleQueue()}
className="flex-1 h-9"
/>
<Button onClick={handleQueue} disabled={!queueUrl.trim()}>
<Button
onClick={handleQueue}
disabled={!queueUrl.trim() || queueMut.isPending}
>
<Play className="size-4 mr-1.5" />
Queue
</Button>
</div>
{mediaState?.current && (
{mediaState?.current ? (
<div className="rounded-lg bg-gradient-to-br from-primary/5 to-primary/[0.02] border border-primary/10 p-4 space-y-2">
<p className="text-xs text-muted-foreground font-medium uppercase tracking-wider flex items-center gap-1.5">
<Disc3 className="size-3" />
@@ -74,30 +93,34 @@ export default function MediaPage() {
</p>
<p className="text-xs text-muted-foreground mt-0.5">
{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"}
</p>
</div>
</div>
</div>
)}
{!mediaState?.current && !mediaState?.queue?.length && (
<p className="text-sm text-muted-foreground py-8 text-center">
No media queued. Paste a URL above to start playing.
</p>
) : (
!mediaState?.queue?.length && (
<p className="text-sm text-muted-foreground py-8 text-center">
No media queued. Paste a URL above to start playing.
</p>
)
)}
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={stop}>
<Button
variant="outline"
size="sm"
onClick={() => stopMut.mutate()}
>
<Square className="size-4 mr-1" />
Stop
</Button>
<Button variant="outline" size="sm" onClick={skip}>
<Button
variant="outline"
size="sm"
onClick={() => skipMut.mutate()}
>
<SkipForward className="size-4 mr-1" />
Skip
</Button>
@@ -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}
@@ -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<string | null>(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 (
<div className="space-y-5">
<GuildSelector value={guildId} onChange={setGuildId} />
<ErrorState message={error} onRetry={refetch} />
<ErrorState message={error.message} onRetry={refetch} />
</div>
);
}
@@ -152,7 +136,6 @@ export default function MessagesPage() {
<div className="space-y-5">
<GuildSelector value={guildId} onChange={setGuildId} />
{/* Search + toolbar */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
@@ -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"
/>
</div>
{channels.length > 0 && (
<Select
value={selectedChannel}
@@ -183,78 +165,174 @@ export default function MessagesPage() {
</SelectContent>
</Select>
)}
<Button variant="outline" size="sm" onClick={handleReanalyzeBatch}>
<Button
variant="outline"
size="sm"
onClick={() => reanalyzeBatchMut.mutate(guildId)}
>
<RefreshCw className="size-4 mr-1.5" />
Reanalyze Errors
</Button>
</div>
{/* Tabs */}
<Tabs
value={viewTab}
onValueChange={(v) => setViewTab(v as "all" | "images" | "review")}
onValueChange={(v) => setViewTab(v as typeof viewTab)}
>
<TabsList>
<TabsTrigger value="all">All ({messages.length})</TabsTrigger>
<TabsTrigger value="images">Images ({images.length})</TabsTrigger>
<TabsTrigger value="all">
All ({(searchResults ?? messages)?.length ?? 0})
</TabsTrigger>
<TabsTrigger value="images">
Images ({images?.length ?? 0})
</TabsTrigger>
<TabsTrigger value="review">
<Flag className="size-3.5 mr-1" />
Review ({reviews.length})
<Flag className="size-3.5 mr-1" /> Review ({reviews?.length ?? 0})
</TabsTrigger>
</TabsList>
</Tabs>
{searchResults !== null && (
{searchResults && (
<p className="text-sm text-muted-foreground animate-fade-in-up">
Found {searchResults.length} result
{searchResults.length !== 1 ? "s" : ""}
</p>
)}
{/* ── ALL tab ── */}
{viewTab === "all" && (
<MessageFeed
messages={displayMessages}
searchResult={searchResults !== null}
loading={loading}
hasMore={hasMore && searchResults === null}
loadingMore={loadingMore}
onLoadMore={loadMore}
onMessageClick={openDetail}
onReanalyze={handleReanalyze}
/>
<div className="space-y-2 animate-fade-in-up">
{isLoading ? (
<LoadingSkeleton count={8} height="h-28" />
) : isEmpty ? (
<div className="flex flex-col items-center justify-center py-20 text-center">
<Search className="size-10 text-muted-foreground/40 mb-3" />
<p className="text-sm text-muted-foreground">
{searchResults
? "No messages found matching your search."
: "No captures yet."}
</p>
</div>
) : (
<>
{displayMessages.map((msg) => (
<MessageCard
key={msg.id}
message={msg}
onClick={setDetailId}
onReanalyze={(id) => reanalyzeMut.mutate(id)}
/>
))}
{hasMore && (
<div className="flex justify-center py-6">
<Button
variant="outline"
onClick={handleLoadMore}
disabled={loadMoreMut.isPending}
>
{loadMoreMut.isPending && (
<Loader2 className="size-4 animate-spin mr-2" />
)}
{loadMoreMut.isPending ? "Loading…" : "Load more"}
</Button>
</div>
)}
</>
)}
</div>
)}
{/* ── IMAGES tab ── */}
{viewTab === "images" && (
<ImageGrid images={images} onImageClick={(id) => openDetail(id)} />
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3 animate-fade-in-up">
{!images || images.length === 0 ? (
<div className="col-span-full flex flex-col items-center justify-center py-20 text-center">
<ImageIcon className="size-10 text-muted-foreground/40 mb-3" />
<p className="text-sm text-muted-foreground">No images yet.</p>
</div>
) : (
images.map((msg) => {
const imgUrl = extractImage(msg.metadata);
return (
<Card
key={msg.id}
className="group relative overflow-hidden cursor-pointer"
onClick={() => setDetailId(msg.id)}
>
<div className="aspect-square relative bg-muted">
{imgUrl ? (
<Image
src={imgUrl}
alt={msg.content || "Image"}
fill
className="object-cover transition-transform duration-300 group-hover:scale-105"
sizes="(max-width: 768px) 50vw, 25vw"
/>
) : (
<div className="flex items-center justify-center size-full text-muted-foreground text-xs">
No image
</div>
)}
{msg.content && (
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-black/20 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex items-end p-3">
<p className="text-xs text-white/90 line-clamp-2">
{msg.username}: {msg.content}
</p>
</div>
)}
</div>
</Card>
);
})
)}
</div>
)}
{/* ── REVIEW tab ── */}
{viewTab === "review" && (
<ReviewFeed
messages={reviews}
onMessageClick={openDetail}
onReanalyze={handleReanalyze}
/>
<div className="space-y-2 animate-fade-in-up">
{!reviews || reviews.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-center">
<Flag className="size-10 text-muted-foreground/40 mb-3" />
<p className="text-sm text-muted-foreground">
No flagged messages to review.
</p>
</div>
) : (
reviews.map((msg) => (
<MessageCard
key={msg.id}
message={msg}
onClick={setDetailId}
onReanalyze={(id) => reanalyzeMut.mutate(id)}
/>
))
)}
</div>
)}
{/* Detail dialog */}
<Dialog
open={detailMessage !== null}
onOpenChange={(o) => !o && closeDetail()}
open={detailId !== null}
onOpenChange={(o) => !o && setDetailId(null)}
>
<DialogContent className="sm:max-w-2xl max-h-[85vh]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<MessageSquare className="size-4" />
Message Detail
<MessageSquare className="size-4" /> Message Detail
</DialogTitle>
</DialogHeader>
<ScrollArea className="max-h-[70vh] pr-1">
<MessageDetail
message={detailMessage}
attachments={detailAttachments}
loading={detailLoading}
/>
{detailLoading ? (
<div className="flex justify-center py-12">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
) : detailMessage ? (
<DetailView
message={detailMessage}
attachments={detailAttachments}
/>
) : null}
</ScrollArea>
</DialogContent>
</Dialog>
@@ -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 <LoadingSkeleton count={8} height="h-28" />;
if (messages.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-20 text-center">
<Search className="size-10 text-muted-foreground/40 mb-3" />
<p className="text-sm text-muted-foreground">
{searchResult
? "No messages found matching your search."
: "No captures yet."}
</p>
</div>
);
}
return (
<div className="space-y-2 animate-fade-in-up">
{messages.map((msg) => (
<MessageCard
key={msg.id}
message={msg}
onClick={onMessageClick}
onReanalyze={onReanalyze}
/>
))}
{hasMore && (
<div className="flex justify-center py-6">
<Button variant="outline" onClick={onLoadMore} disabled={loadingMore}>
{loadingMore && <Loader2 className="size-4 animate-spin mr-2" />}
{loadingMore ? "Loading…" : "Load more"}
</Button>
</div>
)}
</div>
);
}
function ImageGrid({
images,
onImageClick,
}: {
images: MessageRecord[];
onImageClick: (id: string) => void;
}) {
if (images.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-20 text-center">
<ImageIcon className="size-10 text-muted-foreground/40 mb-3" />
<p className="text-sm text-muted-foreground">No images yet.</p>
</div>
);
}
return (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3 animate-fade-in-up">
{images.map((msg) => (
<ImageCard key={msg.id} message={msg} onClick={onImageClick} />
))}
</div>
);
}
function ImageCard({
message: msg,
onClick,
}: {
message: MessageRecord;
onClick: (id: string) => void;
}) {
const imageUrl = useMemo(() => extractImageUrl(msg.metadata), [msg.metadata]);
return (
<Card
className="group relative overflow-hidden cursor-pointer"
onClick={() => onClick(msg.id)}
>
<div className="aspect-square relative bg-muted">
{imageUrl ? (
<Image
src={imageUrl}
alt={msg.content || "Image"}
fill
className="object-cover transition-transform duration-300 group-hover:scale-105"
sizes="(max-width: 768px) 50vw, 25vw"
/>
) : (
<div className="flex items-center justify-center size-full text-muted-foreground text-xs">
No image
</div>
)}
{msg.content && (
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-black/20 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex items-end p-3">
<p className="text-xs text-white/90 line-clamp-2">
{msg.username}: {msg.content}
</p>
</div>
)}
</div>
</Card>
);
}
function ReviewFeed({
messages,
onMessageClick,
onReanalyze,
}: {
messages: MessageRecord[];
onMessageClick: (id: string) => void;
onReanalyze: (id: string) => void;
}) {
if (messages.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-20 text-center">
<Flag className="size-10 text-muted-foreground/40 mb-3" />
<p className="text-sm text-muted-foreground">
No flagged messages to review.
</p>
</div>
);
}
return (
<div className="space-y-2 animate-fade-in-up">
{messages.map((msg) => (
<MessageCard
key={msg.id}
message={msg}
onClick={onMessageClick}
onReanalyze={onReanalyze}
/>
))}
</div>
);
}
// ── 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<string, string>
)[msg.ai_severity ?? ""];
const hasSeverity = !!severityBorder;
return (
<Card
className={cn(
"cursor-pointer transition-all duration-200 hover:bg-accent/5 hover:shadow-sm",
hasSeverity && "border-l-2",
hasSeverity && severityBorder,
severity && "border-l-2",
severity,
)}
onClick={() => onClick(msg.id)}
>
@@ -458,7 +377,6 @@ function MessageCard({
{msg.username.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0 space-y-2">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium">{msg.username}</span>
@@ -469,7 +387,7 @@ function MessageCard({
<Hash className="size-3 inline mr-0.5" />
{msg.channel_id.slice(0, 8)}
</span>
<AiBadge status={msg.ai_status} />
<AiStatusBadge status={msg.ai_status} />
{msg.ai_severity && msg.ai_severity !== "none" && (
<Badge
variant="destructive"
@@ -495,7 +413,6 @@ function MessageCard({
</Badge>
)}
</div>
<p
className={cn(
"text-sm leading-relaxed",
@@ -505,27 +422,24 @@ function MessageCard({
>
{msg.content}
</p>
{msg.ai_moderation_flags && msg.ai_moderation_flags !== "[]" && (
<div className="flex flex-wrap gap-1">
{safeParseJsonArray(msg.ai_moderation_flags).map((flag) => (
{safeParseJsonArray(msg.ai_moderation_flags).map((f) => (
<Badge
key={flag}
key={f}
variant="destructive"
className="text-[10px] px-1.5 py-0 h-4"
>
{flag}
{f}
</Badge>
))}
</div>
)}
{msg.ai_analysis && (
<p className="text-xs text-muted-foreground italic line-clamp-2 leading-relaxed">
{msg.ai_analysis}
</p>
)}
{msg.ai_confidence != null && (
<div className="flex items-center gap-2 max-w-40">
<Progress value={msg.ai_confidence * 100} className="h-1.5" />
@@ -534,7 +448,6 @@ function MessageCard({
</span>
</div>
)}
<Button
variant="ghost"
size="xs"
@@ -543,8 +456,7 @@ function MessageCard({
onReanalyze(msg.id);
}}
>
<RefreshCw className="size-3 mr-1" />
Reanalyze
<RefreshCw className="size-3 mr-1" /> Reanalyze
</Button>
</div>
</div>
@@ -553,39 +465,39 @@ function MessageCard({
);
}
function AiBadge({ status }: { status?: string | null }) {
const colors: Record<string, string> = {
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<string, string>
)[status ?? ""];
if (!c) return null;
return (
<Badge
variant="outline"
className={cn("text-[10px] px-1.5 py-0 h-4 font-medium", colors[status])}
className={cn("text-[10px] px-1.5 py-0 h-4 font-medium", c)}
>
{status}
</Badge>
);
}
// ── 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 (
<div className="flex justify-center py-12">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
);
}
if (!message) return null;
return (
<div className="space-y-5">
<div className="flex items-start gap-3">
@@ -637,7 +538,6 @@ function MessageDetail({
</p>
</div>
</div>
{message.ai_analysis && (
<div className="rounded-lg bg-gradient-to-br from-primary/5 to-primary/[0.02] border border-primary/10 p-4">
<div className="flex items-center gap-2 mb-2">
@@ -649,84 +549,65 @@ function MessageDetail({
<p className="text-sm leading-relaxed">{message.ai_analysis}</p>
</div>
)}
{message.ai_moderation_flags && message.ai_moderation_flags !== "[]" && (
<div className="space-y-2">
<p className="text-xs text-muted-foreground font-medium">
Moderation Flags
</p>
<div className="flex flex-wrap gap-1.5">
{safeParseJsonArray(message.ai_moderation_flags).map((flag) => (
<Badge key={flag} variant="destructive" className="text-[11px]">
{flag}
{safeParseJsonArray(message.ai_moderation_flags).map((f) => (
<Badge key={f} variant="destructive" className="text-[11px]">
{f}
</Badge>
))}
</div>
</div>
)}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{message.ai_status && (
<Card>
<CardContent className="p-3">
<p className="text-xs text-muted-foreground">Status</p>
<p className="text-sm font-medium mt-0.5 capitalize">
{message.ai_status}
</p>
</CardContent>
</Card>
<MiniStat label="Status" value={message.ai_status} capitalize />
)}
{message.ai_severity && message.ai_severity !== "none" && (
<Card>
<CardContent className="p-3">
<p className="text-xs text-muted-foreground">Severity</p>
<p className="text-sm font-medium mt-0.5 text-destructive capitalize">
{message.ai_severity}
</p>
</CardContent>
</Card>
<MiniStat
label="Severity"
value={message.ai_severity}
destructive
capitalize
/>
)}
{message.ai_confidence != null && (
<Card>
<CardContent className="p-3">
<p className="text-xs text-muted-foreground">Confidence</p>
<p className="text-sm font-medium mt-0.5 tabular-nums">
{(message.ai_confidence * 100).toFixed(0)}%
</p>
</CardContent>
</Card>
<MiniStat
label="Confidence"
value={`${(message.ai_confidence * 100).toFixed(0)}%`}
/>
)}
{message.ai_recommended_action &&
message.ai_recommended_action !== "none" && (
<Card>
<CardContent className="p-3">
<p className="text-xs text-muted-foreground">Action</p>
<p className="text-sm font-medium mt-0.5 capitalize">
{message.ai_recommended_action}
</p>
</CardContent>
</Card>
<MiniStat
label="Action"
value={message.ai_recommended_action}
capitalize
/>
)}
</div>
{attachments.length > 0 && (
<div className="space-y-2">
<p className="text-xs text-muted-foreground font-medium">
Attachments ({attachments.length})
</p>
<div className="grid grid-cols-2 gap-2">
{attachments.map((att) => (
{attachments.map((a) => (
<a
key={att.id}
href={att.uploaded_url ?? att.discord_url ?? "#"}
key={a.id}
href={a.uploaded_url ?? a.discord_url ?? "#"}
target="_blank"
rel="noreferrer"
className="flex items-center gap-2 rounded-lg border border-border/50 p-2 hover:bg-muted transition-colors group"
>
<div className="flex-1 min-w-0">
<p className="text-xs font-medium truncate">{att.filename}</p>
<p className="text-xs font-medium truncate">{a.filename}</p>
<p className="text-[11px] text-muted-foreground">
{att.type} · {formatBytes(att.size)}
{a.type} · {formatBytes(a.size)}
</p>
</div>
<ExternalLink className="size-3 shrink-0 text-muted-foreground/50 group-hover:text-muted-foreground transition-colors" />
@@ -739,16 +620,44 @@ function MessageDetail({
);
}
// ── Helpers ─────────────────────────────────────
function MiniStat({
label,
value,
destructive,
capitalize,
}: {
label: string;
value: string;
destructive?: boolean;
capitalize?: boolean;
}) {
return (
<Card>
<CardContent className="p-3">
<p className="text-xs text-muted-foreground">{label}</p>
<p
className={cn(
"text-sm font-medium mt-0.5",
capitalize && "capitalize",
destructive && "text-destructive",
)}
>
{value}
</p>
</CardContent>
</Card>
);
}
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;
}
@@ -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 (
<div className="space-y-5 animate-fade-in-up">
@@ -33,9 +31,9 @@ export default function RecordingsPage() {
</CardTitle>
</CardHeader>
<CardContent>
{loading ? (
{isLoading ? (
<LoadingSkeleton count={5} height="h-16" />
) : recordings.length === 0 ? (
) : !recordings || recordings.length === 0 ? (
<EmptyState icon={Headphones} title="No recordings yet." />
) : (
<div className="space-y-2">
@@ -55,9 +53,8 @@ export default function RecordingsPage() {
{rec.username}
</p>
<p className="text-xs text-muted-foreground">
{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()}
</p>
</div>
<Badge
@@ -81,7 +78,7 @@ export default function RecordingsPage() {
<Button
variant="ghost"
size="icon"
onClick={() => remove(rec.id)}
onClick={() => deleteMut.mutate(rec.id)}
className="hover:text-destructive hover:bg-destructive/10"
>
<Trash2 className="size-4" />
@@ -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() {
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-sm font-semibold">
<Wifi className="size-4 text-primary" />
Connection
<Wifi className="size-4 text-primary" /> Connection
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm">WebSocket</span>
<Badge
variant={statusConfig.variant}
className="gap-1.5 px-2.5 py-1"
>
<span className={cn("size-1.5 rounded-full", statusConfig.dot)} />
{statusConfig.label}
<Badge variant={statusCfg.variant} className="gap-1.5 px-2.5 py-1">
<span className={cn("size-1.5 rounded-full", statusCfg.dot)} />
{statusCfg.label}
</Badge>
</div>
</CardContent>
@@ -81,7 +77,7 @@ export default function SettingsPage() {
<Moon className="size-4 text-primary" />
) : (
<Sun className="size-4 text-primary" />
)}
)}{" "}
Appearance
</CardTitle>
</CardHeader>
@@ -102,8 +98,7 @@ export default function SettingsPage() {
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-sm font-semibold">
<Server className="size-4 text-primary" />
Server Configuration
<Server className="size-4 text-primary" /> Server Configuration
</CardTitle>
</CardHeader>
<CardContent>
@@ -111,27 +106,27 @@ export default function SettingsPage() {
<LoadingSkeleton count={6} height="h-6" />
) : config ? (
<div className="space-y-2 text-sm">
<ConfigRow
<CfgRow
label="Monitor Guild"
value={config.monitorGuildId ?? "Not configured"}
/>
<Separator />
<ConfigRow
<CfgRow
label="Voice Guild"
value={config.voiceGuildId ?? "Not configured"}
/>
<Separator />
<ConfigRow
<CfgRow
label="Voice Channel"
value={config.voiceChannelId ?? "Not configured"}
/>
<Separator />
<ConfigRow
<CfgRow
label="AI Analysis"
value={config.aiAnalysisEnabled ? "Enabled" : "Disabled"}
/>
<Separator />
<ConfigRow
<CfgRow
label="Auto-Delete Flagged"
value={config.autoDeleteFlaggedEnabled ? "Enabled" : "Disabled"}
/>
@@ -147,8 +142,7 @@ export default function SettingsPage() {
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-sm font-semibold">
<Shield className="size-4 text-primary" />
About
<Shield className="size-4 text-primary" /> About
</CardTitle>
</CardHeader>
<CardContent>
@@ -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 (
<div className="flex items-center justify-between py-1">
<span className="text-muted-foreground">{label}</span>
@@ -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 (
<div className="space-y-5 animate-fade-in-up">
@@ -98,26 +79,26 @@ export default function VoicePage() {
Voice Connection
</div>
<Badge
variant={voiceStatus?.connected ? "default" : "secondary"}
variant={connected ? "default" : "secondary"}
className={cn(
voiceStatus?.connected &&
connected &&
"bg-green-500/15 text-green-600 dark:text-green-400 hover:bg-green-500/20",
)}
>
<span
className={cn(
"size-1.5 rounded-full mr-1.5 inline-block",
voiceStatus?.connected
connected
? "bg-green-500 shadow-[0_0_6px] shadow-green-500/60"
: "bg-muted-foreground",
)}
/>
{voiceStatus?.connected ? "Connected" : "Disconnected"}
{connected ? "Connected" : "Disconnected"}
</Badge>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{voiceStatus?.connected && voiceStatus.activeChannelName && (
{connected && voiceStatus?.activeChannelName && (
<p className="text-sm text-muted-foreground flex items-center gap-1.5">
<Headphones className="size-4" />
Connected to{" "}
@@ -149,26 +130,20 @@ export default function VoicePage() {
<SelectValue placeholder="Select channel…" />
</SelectTrigger>
<SelectContent>
{voiceChannels.length === 0 ? (
<SelectItem value="_none" disabled>
No channels loaded
{voiceChannels.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
) : (
voiceChannels.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))
)}
))}
</SelectContent>
</Select>
{voiceStatus?.connected ? (
{connected ? (
<Button
variant="destructive"
onClick={handleDisconnect}
disabled={voiceLoading}
onClick={() => disconnectMut.mutate()}
disabled={disconnectMut.isPending}
>
{voiceLoading ? (
{disconnectMut.isPending ? (
<Loader2 className="size-4 animate-spin mr-1.5" />
) : (
<RadioOff className="size-4 mr-1.5" />
@@ -177,10 +152,17 @@ export default function VoicePage() {
</Button>
) : (
<Button
onClick={handleConnect}
disabled={voiceLoading || !selectedGuild || !selectedChannel}
onClick={() =>
connectMut.mutate({
guildId: selectedGuild,
channelId: selectedChannel,
})
}
disabled={
connectMut.isPending || !selectedGuild || !selectedChannel
}
>
{voiceLoading ? (
{connectMut.isPending ? (
<Loader2 className="size-4 animate-spin mr-1.5" />
) : (
<Radio className="size-4 mr-1.5" />
@@ -235,21 +217,18 @@ export default function VoicePage() {
onCheckedChange={async (checked) => {
setMicActive(checked);
try {
await voiceApi.sendCommand(
checked ? "voice:transmit:start" : "voice:transmit:stop",
);
} catch (err) {
console.error("voice/mic:", err);
await micMut.mutateAsync(checked);
} catch {
setMicActive(!checked);
}
}}
disabled={!voiceStatus?.connected}
disabled={!connected}
/>
</div>
</CardTitle>
</CardHeader>
<CardContent>
{!voiceStatus?.connected && (
{!connected && (
<p className="text-xs text-muted-foreground">
Connect to a voice channel first.
</p>
+26 -5
View File
@@ -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";
-58
View File
@@ -1,58 +0,0 @@
import { useCallback, useEffect, useRef, useState } from "react";
interface UseAsyncState<T> {
data: T | null;
loading: boolean;
error: string | null;
}
type UseAsyncReturn<T> = UseAsyncState<T> & { refetch: () => void };
/**
* Generic async data-fetching hook.
*
* - Cancels requests on unmount
* - Provides loading / error / data states
* - Returns a refetch trigger
*/
export function useAsync<T>(
fetcher: () => Promise<T>,
deps: unknown[] = [],
): UseAsyncReturn<T> {
const [state, setState] = useState<UseAsyncState<T>>({
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 };
}
+8 -14
View File
@@ -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<AppConfig>(
() => configApi.get(),
[],
);
return { config: data, loading, error, refetch };
export function useConfig() {
return useQuery<AppConfig>({
queryKey: ["config"],
queryFn: () => configApi.get(),
staleTime: 120_000,
});
}
+31 -156
View File
@@ -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<DashboardStats>({
queryKey: ["dashboard-stats"],
queryFn: () => dashboardApi.getStats(),
});
}
export function useStats(): UseStatsReturn {
const [stats, setStats] = useState<DashboardStats | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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<DashboardUser[]>([]);
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<DashboardUserDetail>({
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<DashboardChannel[]>([]);
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<DashboardUserDetail | null>(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<DashboardChannelDetail | null>(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<DashboardChannelDetail>({
queryKey: ["dashboard-channel", channelId],
queryFn: () => dashboardApi.getChannelDetail(channelId!),
enabled: !!channelId,
});
}
+7 -30
View File
@@ -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<Guild[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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<Guild[]>({
queryKey: ["guilds"],
queryFn: () => voiceApi.getGuilds(),
staleTime: 60_000,
});
}
+48 -62
View File
@@ -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<MediaState>({
queryKey: ["media-state"],
queryFn: () => voiceApi.getMediaStatus(),
retry: false,
refetchInterval: 10_000,
});
}
export function useMediaState(): UseMediaStateReturn {
const [mediaState, setMediaState] = useState<MediaState | null>(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<typeof useQueryClient>) {
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]);
}
+172 -223
View File
@@ -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<MessageRecord[]>([]);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState<string | null>(null);
const [cursor, setCursor] = useState<string | null>(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<MessageRecord[]>({
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<MessageRecord[]>(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<Channel[]>({
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<MessageRecord[]>({
queryKey: ["messages-search"],
queryFn: () => Promise.resolve([]),
enabled: false,
});
}
// ── Images ───────────────────────────────────────
export function useImages(guildId: string) {
return useQuery<MessageRecord[]>({
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<MessageRecord[]>({
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<MessageRecord>({
queryKey: msgKeys.detail(id ?? ""),
queryFn: () => messagesApi.getDetail(id!),
enabled: !!id,
});
const attachments = useQuery<AttachmentRecord[]>({
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<Channel[]>([]);
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<MessageRecord[] | null>(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<MessageRecord[]>([]);
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<MessageRecord[]>([]);
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<MessageRecord | null>(null);
const [attachments, setAttachments] = useState<AttachmentRecord[]>([]);
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<MessageRecord[]>(key, (old) =>
old ? [data as MessageRecord, ...old] : [data as MessageRecord],
);
});
const unsub2 = ws.on("message_updated", (data) => {
qc.setQueryData<MessageRecord[]>(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<MessageRecord[]>(key, (old) =>
old ? old.filter((m) => m.id !== (data as unknown as string)) : old,
);
});
const unsub4 = ws.on("message_analyzed", (data) => {
qc.setQueryData<MessageRecord[]>(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]);
}
+27 -48
View File
@@ -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<VoiceRecording[]>({
queryKey: ["recordings"],
queryFn: async () => {
const res = await recordingsApi.list(50);
return res.items;
},
});
}
export function useRecordings(): UseRecordingsReturn {
const [recordings, setRecordings] = useState<VoiceRecording[]>([]);
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<VoiceRecording[]>(["recordings"], (old) =>
old ? [rec, ...old] : [rec],
);
});
return unsub;
}, [ws, qc]);
}
+40 -35
View File
@@ -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<VoiceStatus>({
queryKey: ["voice-status"],
queryFn: () => voiceApi.getStatus(),
retry: false,
});
}
export function useVoiceStatus(): UseVoiceStatusReturn {
const [voiceStatus, setVoiceStatus] = useState<VoiceStatus | null>(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<Array<{ id: string; name: string }>>(
[],
);
@@ -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<ActiveSpeaker[]>([]);
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",
),
});
}
@@ -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<AppConfig | null>(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 };
}