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>