2026-07-26 11:27:21 +07:00
|
|
|
"use client";
|
|
|
|
|
|
2026-07-26 16:55:20 +07:00
|
|
|
import { useQuery } from "@tanstack/react-query";
|
2026-07-27 21:54:31 +07:00
|
|
|
import { Flag, Loader2, MessageSquare, RefreshCw, Search } from "lucide-react";
|
|
|
|
|
import { useCallback, useState } from "react";
|
|
|
|
|
import { ImagesGrid } from "@/components/messages/images-grid";
|
|
|
|
|
import { MessageCard } from "@/components/messages/message-card";
|
|
|
|
|
import { MessageDetailView } from "@/components/messages/message-detail-view";
|
|
|
|
|
import { ReviewList } from "@/components/messages/review-list";
|
|
|
|
|
import { ErrorState, LoadingSkeleton } from "@/components/shared";
|
2026-07-26 15:39:41 +07:00
|
|
|
import { GuildSelector } from "@/components/shared/guild-selector";
|
2026-07-26 15:13:18 +07:00
|
|
|
import { Button } from "@/components/ui/button";
|
|
|
|
|
import {
|
|
|
|
|
Dialog,
|
|
|
|
|
DialogContent,
|
|
|
|
|
DialogHeader,
|
|
|
|
|
DialogTitle,
|
|
|
|
|
} from "@/components/ui/dialog";
|
|
|
|
|
import { Input } from "@/components/ui/input";
|
|
|
|
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
|
|
|
import {
|
|
|
|
|
Select,
|
|
|
|
|
SelectContent,
|
|
|
|
|
SelectItem,
|
|
|
|
|
SelectTrigger,
|
|
|
|
|
SelectValue,
|
|
|
|
|
} from "@/components/ui/select";
|
2026-07-26 15:34:08 +07:00
|
|
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
2026-07-26 16:14:32 +07:00
|
|
|
import {
|
|
|
|
|
useImages,
|
2026-07-26 16:55:20 +07:00
|
|
|
useLoadMore,
|
2026-07-26 16:14:32 +07:00
|
|
|
useMessageDetail,
|
|
|
|
|
useMessages,
|
2026-07-26 16:55:20 +07:00
|
|
|
useMessagesHasMore,
|
|
|
|
|
useMessagesWsSync,
|
|
|
|
|
useReanalyze,
|
|
|
|
|
useReanalyzeBatch,
|
2026-07-26 16:14:32 +07:00
|
|
|
useReview,
|
|
|
|
|
useTextChannels,
|
|
|
|
|
} from "@/hooks";
|
2026-07-26 16:55:20 +07:00
|
|
|
import { messagesApi } from "@/lib/api";
|
2026-07-26 16:14:32 +07:00
|
|
|
import type { MessageRecord } from "@/lib/types";
|
2026-07-26 12:01:51 +07:00
|
|
|
import { useWebSocket } from "@/lib/ws/context";
|
2026-07-26 11:27:21 +07:00
|
|
|
|
2026-07-26 15:34:08 +07:00
|
|
|
export default function MessagesPage() {
|
|
|
|
|
const [guildId, setGuildId] = useState("");
|
2026-07-26 11:27:21 +07:00
|
|
|
const [selectedChannel, setSelectedChannel] = useState("");
|
2026-07-26 16:55:20 +07:00
|
|
|
const [viewTab, setViewTab] = useState<"all" | "images" | "review">("all");
|
|
|
|
|
const [searchQuery, setSearchQuery] = useState("");
|
|
|
|
|
const [detailId, setDetailId] = useState<string | null>(null);
|
2026-07-26 11:27:21 +07:00
|
|
|
|
|
|
|
|
const ws = useWebSocket();
|
2026-07-26 16:55:20 +07:00
|
|
|
const { data: channels = [] } = useTextChannels(guildId);
|
2026-07-26 16:14:32 +07:00
|
|
|
const {
|
2026-07-26 16:55:20 +07:00
|
|
|
data: messages,
|
|
|
|
|
isLoading,
|
2026-07-26 16:14:32 +07:00
|
|
|
error,
|
|
|
|
|
refetch,
|
|
|
|
|
} = useMessages(guildId, selectedChannel || undefined);
|
2026-07-27 21:54:31 +07:00
|
|
|
const { data: cursorData } = useMessagesHasMore(
|
2026-07-26 16:55:20 +07:00
|
|
|
guildId,
|
2026-07-26 16:14:32 +07:00
|
|
|
selectedChannel || undefined,
|
|
|
|
|
);
|
2026-07-26 16:55:20 +07:00
|
|
|
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
|
2026-07-26 16:14:32 +07:00
|
|
|
const {
|
|
|
|
|
message: detailMessage,
|
|
|
|
|
attachments: detailAttachments,
|
|
|
|
|
loading: detailLoading,
|
2026-07-26 16:55:20 +07:00
|
|
|
} = useMessageDetail(detailId);
|
2026-07-26 11:27:21 +07:00
|
|
|
|
2026-07-26 16:55:20 +07:00
|
|
|
// Search query (manual trigger)
|
|
|
|
|
const [searchEnabled, setSearchEnabled] = useState(false);
|
2026-07-27 21:54:31 +07:00
|
|
|
const { data: searchResults } = useQuery<
|
2026-07-26 16:55:20 +07:00
|
|
|
MessageRecord[]
|
|
|
|
|
>({
|
|
|
|
|
queryKey: ["messages-search", guildId, searchQuery],
|
|
|
|
|
queryFn: async () => {
|
|
|
|
|
const result = await messagesApi.search(searchQuery, 50);
|
|
|
|
|
return result.results;
|
|
|
|
|
},
|
|
|
|
|
enabled: searchEnabled && !!searchQuery && !!guildId,
|
|
|
|
|
});
|
2026-07-26 16:14:32 +07:00
|
|
|
|
2026-07-26 16:55:20 +07:00
|
|
|
const handleSearch = useCallback(() => {
|
|
|
|
|
if (!searchQuery.trim()) return;
|
|
|
|
|
setSearchEnabled(true);
|
|
|
|
|
}, [searchQuery]);
|
2026-07-26 16:14:32 +07:00
|
|
|
|
2026-07-26 16:55:20 +07:00
|
|
|
const handleLoadMore = useCallback(() => {
|
|
|
|
|
if (!cursorData?.cursor || loadMoreMut.isPending) return;
|
|
|
|
|
loadMoreMut.mutate({
|
|
|
|
|
guildId,
|
|
|
|
|
channelId: selectedChannel || undefined,
|
|
|
|
|
cursor: cursorData.cursor,
|
|
|
|
|
});
|
|
|
|
|
}, [cursorData, loadMoreMut, guildId, selectedChannel]);
|
2026-07-26 11:27:21 +07:00
|
|
|
|
2026-07-26 16:55:20 +07:00
|
|
|
const displayMessages = searchResults ?? messages ?? [];
|
|
|
|
|
const hasMore = cursorData?.hasMore ?? false;
|
|
|
|
|
const isEmpty = !isLoading && displayMessages.length === 0;
|
2026-07-26 11:27:21 +07:00
|
|
|
|
|
|
|
|
if (error) {
|
|
|
|
|
return (
|
2026-07-26 15:34:08 +07:00
|
|
|
<div className="space-y-5">
|
|
|
|
|
<GuildSelector value={guildId} onChange={setGuildId} />
|
2026-07-26 16:55:20 +07:00
|
|
|
<ErrorState message={error.message} onRetry={refetch} />
|
2026-07-26 11:27:21 +07:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
2026-07-26 15:13:18 +07:00
|
|
|
<div className="space-y-5">
|
2026-07-26 15:34:08 +07:00
|
|
|
<GuildSelector value={guildId} onChange={setGuildId} />
|
|
|
|
|
|
2026-07-26 11:27:21 +07:00
|
|
|
<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" />
|
2026-07-26 15:13:18 +07:00
|
|
|
<Input
|
2026-07-26 11:27:21 +07:00
|
|
|
placeholder="Search messages…"
|
|
|
|
|
value={searchQuery}
|
|
|
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
2026-07-26 16:55:20 +07:00
|
|
|
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
2026-07-26 15:13:18 +07:00
|
|
|
className="pl-9 h-9"
|
2026-07-26 11:27:21 +07:00
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
{channels.length > 0 && (
|
2026-07-26 15:13:18 +07:00
|
|
|
<Select
|
2026-07-26 11:27:21 +07:00
|
|
|
value={selectedChannel}
|
2026-07-27 21:54:31 +07:00
|
|
|
onValueChange={(v) => setSelectedChannel(v ?? "")}
|
2026-07-26 11:27:21 +07:00
|
|
|
>
|
2026-07-26 15:13:18 +07:00
|
|
|
<SelectTrigger className="h-9 w-full sm:w-44">
|
|
|
|
|
<SelectValue placeholder="All channels" />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
2026-07-27 21:54:31 +07:00
|
|
|
<SelectItem value="">All channels</SelectItem>
|
2026-07-26 15:13:18 +07:00
|
|
|
{channels.map((ch) => (
|
|
|
|
|
<SelectItem key={ch.id} value={ch.id}>
|
|
|
|
|
# {ch.name}
|
|
|
|
|
</SelectItem>
|
|
|
|
|
))}
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
2026-07-26 11:27:21 +07:00
|
|
|
)}
|
2026-07-26 16:55:20 +07:00
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() => reanalyzeBatchMut.mutate(guildId)}
|
|
|
|
|
>
|
2026-07-26 15:13:18 +07:00
|
|
|
<RefreshCw className="size-4 mr-1.5" />
|
2026-07-26 11:27:21 +07:00
|
|
|
Reanalyze Errors
|
2026-07-26 15:13:18 +07:00
|
|
|
</Button>
|
2026-07-26 11:27:21 +07:00
|
|
|
</div>
|
|
|
|
|
|
2026-07-26 15:13:18 +07:00
|
|
|
<Tabs
|
|
|
|
|
value={viewTab}
|
2026-07-26 16:55:20 +07:00
|
|
|
onValueChange={(v) => setViewTab(v as typeof viewTab)}
|
2026-07-26 15:13:18 +07:00
|
|
|
>
|
|
|
|
|
<TabsList>
|
2026-07-26 16:55:20 +07:00
|
|
|
<TabsTrigger value="all">
|
|
|
|
|
All ({(searchResults ?? messages)?.length ?? 0})
|
|
|
|
|
</TabsTrigger>
|
|
|
|
|
<TabsTrigger value="images">
|
|
|
|
|
Images ({images?.length ?? 0})
|
|
|
|
|
</TabsTrigger>
|
2026-07-26 16:14:32 +07:00
|
|
|
<TabsTrigger value="review">
|
2026-07-26 16:55:20 +07:00
|
|
|
<Flag className="size-3.5 mr-1" /> Review ({reviews?.length ?? 0})
|
2026-07-26 15:13:18 +07:00
|
|
|
</TabsTrigger>
|
|
|
|
|
</TabsList>
|
|
|
|
|
</Tabs>
|
2026-07-26 11:27:21 +07:00
|
|
|
|
2026-07-26 16:55:20 +07:00
|
|
|
{searchResults && (
|
2026-07-26 15:13:18 +07:00
|
|
|
<p className="text-sm text-muted-foreground animate-fade-in-up">
|
2026-07-26 11:27:21 +07:00
|
|
|
Found {searchResults.length} result
|
|
|
|
|
{searchResults.length !== 1 ? "s" : ""}
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-07-26 16:55:20 +07:00
|
|
|
{/* ── ALL tab ── */}
|
2026-07-26 16:14:32 +07:00
|
|
|
{viewTab === "all" && (
|
2026-07-26 16:55:20 +07:00
|
|
|
<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>
|
2026-07-26 11:27:21 +07:00
|
|
|
)}
|
|
|
|
|
|
2026-07-26 16:55:20 +07:00
|
|
|
{/* ── IMAGES tab ── */}
|
2026-07-26 16:14:32 +07:00
|
|
|
{viewTab === "images" && (
|
2026-07-27 21:54:31 +07:00
|
|
|
<ImagesGrid images={images ?? []} onSelect={setDetailId} />
|
2026-07-26 16:14:32 +07:00
|
|
|
)}
|
|
|
|
|
|
2026-07-26 16:55:20 +07:00
|
|
|
{/* ── REVIEW tab ── */}
|
2026-07-26 16:14:32 +07:00
|
|
|
{viewTab === "review" && (
|
2026-07-27 21:54:31 +07:00
|
|
|
<ReviewList
|
|
|
|
|
reviews={reviews ?? []}
|
|
|
|
|
onSelect={setDetailId}
|
|
|
|
|
onReanalyze={(id) => reanalyzeMut.mutate(id)}
|
|
|
|
|
/>
|
2026-07-26 16:14:32 +07:00
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Detail dialog */}
|
2026-07-26 15:13:18 +07:00
|
|
|
<Dialog
|
2026-07-26 16:55:20 +07:00
|
|
|
open={detailId !== null}
|
|
|
|
|
onOpenChange={(o) => !o && setDetailId(null)}
|
2026-07-26 15:13:18 +07:00
|
|
|
>
|
|
|
|
|
<DialogContent className="sm:max-w-2xl max-h-[85vh]">
|
|
|
|
|
<DialogHeader>
|
|
|
|
|
<DialogTitle className="flex items-center gap-2">
|
2026-07-26 16:55:20 +07:00
|
|
|
<MessageSquare className="size-4" /> Message Detail
|
2026-07-26 15:13:18 +07:00
|
|
|
</DialogTitle>
|
|
|
|
|
</DialogHeader>
|
|
|
|
|
<ScrollArea className="max-h-[70vh] pr-1">
|
2026-07-26 16:55:20 +07:00
|
|
|
{detailLoading ? (
|
|
|
|
|
<div className="flex justify-center py-12">
|
|
|
|
|
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
|
|
|
|
</div>
|
|
|
|
|
) : detailMessage ? (
|
2026-07-27 21:54:31 +07:00
|
|
|
<MessageDetailView
|
2026-07-26 16:55:20 +07:00
|
|
|
message={detailMessage}
|
|
|
|
|
attachments={detailAttachments}
|
|
|
|
|
/>
|
|
|
|
|
) : null}
|
2026-07-26 15:13:18 +07:00
|
|
|
</ScrollArea>
|
|
|
|
|
</DialogContent>
|
|
|
|
|
</Dialog>
|
2026-07-26 11:27:21 +07:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|