From 5033161567201159d6abf3e3c10b5e2cca6f3213 Mon Sep 17 00:00:00 2001 From: Developer Date: Tue, 28 Jul 2026 10:42:12 +0700 Subject: [PATCH] feat: rewrite messages page with split-pane layout, sub-nav, and search overlay --- .../src/app/(dashboard)/messages/page.tsx | 392 ++++++++++-------- .../src/components/messages/message-list.tsx | 53 +++ 2 files changed, 280 insertions(+), 165 deletions(-) create mode 100644 services/frontend/src/components/messages/message-list.tsx diff --git a/services/frontend/src/app/(dashboard)/messages/page.tsx b/services/frontend/src/app/(dashboard)/messages/page.tsx index fa48a90..0f38500 100644 --- a/services/frontend/src/app/(dashboard)/messages/page.tsx +++ b/services/frontend/src/app/(dashboard)/messages/page.tsx @@ -1,23 +1,17 @@ "use client"; -import { useQuery } from "@tanstack/react-query"; -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 { useCallback, useEffect, useState } from "react"; +import { useSearchParams, useRouter } from "next/navigation"; +import { Flag, Image, Loader2, RefreshCw, Search } from "lucide-react"; +import { MessageList } from "@/components/messages/message-list"; import { MessageDetailView } from "@/components/messages/message-detail-view"; -import { ReviewList } from "@/components/messages/review-list"; +import { SearchOverlay } from "@/components/messages/search-overlay"; +import { extractFirstImage } from "@/components/messages/message-card"; +import { SubNav } from "@/components/layout/sub-nav"; import { ErrorState, LoadingSkeleton } from "@/components/shared"; -import { GuildSelector } from "@/components/shared/guild-selector"; +import { GlassCard } from "@/components/glass/card"; +import { GlassPanel } from "@/components/glass/panel"; 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, @@ -25,7 +19,6 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { useImages, useLoadMore, @@ -38,16 +31,27 @@ import { useReview, useTextChannels, } from "@/hooks"; -import { messagesApi } from "@/lib/api"; import type { MessageRecord } from "@/lib/types"; +import { cn } from "@/lib/utils"; import { useWebSocket } from "@/lib/ws/context"; +import { GuildSelector } from "@/components/shared/guild-selector"; + +type MessagesTab = "all" | "images" | "review"; export default function MessagesPage() { - const [guildId, setGuildId] = useState(""); - const [selectedChannel, setSelectedChannel] = useState(""); - const [viewTab, setViewTab] = useState<"all" | "images" | "review">("all"); - const [searchQuery, setSearchQuery] = useState(""); - const [detailId, setDetailId] = useState(null); + const router = useRouter(); + const searchParams = useSearchParams(); + const [guildId, setGuildId] = useState(searchParams.get("guild") || ""); + const [selectedChannel, setSelectedChannel] = useState( + searchParams.get("channel") || "", + ); + const [detailId, setDetailId] = useState( + searchParams.get("selected"), + ); + const [tab, setTab] = useState( + (searchParams.get("tab") as MessagesTab) || "all", + ); + const [searchOpen, setSearchOpen] = useState(false); const ws = useWebSocket(); const { data: channels = [] } = useTextChannels(guildId); @@ -67,33 +71,35 @@ export default function MessagesPage() { 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, } = useMessageDetail(detailId); - // Search query (manual trigger) - const [searchEnabled, setSearchEnabled] = useState(false); - const { data: searchResults } = useQuery< - MessageRecord[] - >({ - queryKey: ["messages-search", guildId, searchQuery], - queryFn: async () => { - const result = await messagesApi.search(searchQuery, 50); - return result.results; - }, - enabled: searchEnabled && !!searchQuery && !!guildId, - }); + useMessagesWsSync(ws, guildId); - const handleSearch = useCallback(() => { - if (!searchQuery.trim()) return; - setSearchEnabled(true); - }, [searchQuery]); + // Sync state to URL + useEffect(() => { + const params = new URLSearchParams(); + if (guildId) params.set("guild", guildId); + if (selectedChannel) params.set("channel", selectedChannel); + if (detailId) params.set("selected", detailId); + if (tab !== "all") params.set("tab", tab); + router.replace(`/messages?${params.toString()}`, { scroll: false }); + }, [guildId, selectedChannel, detailId, tab, router]); + + // Global Cmd+K search trigger + useEffect(() => { + const handleKey = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === "k") { + e.preventDefault(); + setSearchOpen(true); + } + }; + document.addEventListener("keydown", handleKey); + return () => document.removeEventListener("keydown", handleKey); + }, []); const handleLoadMore = useCallback(() => { if (!cursorData?.cursor || loadMoreMut.isPending) return; @@ -104,40 +110,31 @@ export default function MessagesPage() { }); }, [cursorData, loadMoreMut, guildId, selectedChannel]); - const displayMessages = searchResults ?? messages ?? []; - const hasMore = cursorData?.hasMore ?? false; - const isEmpty = !isLoading && displayMessages.length === 0; + const handleGuildChange = useCallback((g: string) => { + setGuildId(g); + setSelectedChannel(""); + setDetailId(null); + }, []); - if (error) { - return ( -
- - -
- ); - } + const subNavTabs = [ + { id: "all", label: "All", icon: null }, + { id: "images", label: "Images", icon: }, + { id: "review", label: "Review", icon: }, + ]; + + const currentMessages = messages ?? []; return ( -
- - -
-
- - setSearchQuery(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && handleSearch()} - className="pl-9 h-9" - /> -
+
+ {/* ── Controls bar ── */} +
+ {channels.length > 0 && ( )} +
- setViewTab(v as typeof viewTab)} - > - - - All ({(searchResults ?? messages)?.length ?? 0}) - - - Images ({images?.length ?? 0}) - - - Review ({reviews?.length ?? 0}) - - - + {/* ── Sub navigation ── */} + setTab(t as MessagesTab)} + /> - {searchResults && ( -

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

- )} + {/* ── Split pane ── */} + {error ? ( + + ) : isLoading ? ( + + ) : ( +
+ {/* Left pane */} +
+ {tab === "all" && ( + reanalyzeMut.mutate(id)} + hasMore={cursorData?.hasMore} + onLoadMore={handleLoadMore} + isLoadingMore={loadMoreMut.isPending} + /> + )} + {tab === "images" && ( + + )} + {tab === "review" && ( + + )} +
- {/* ── ALL tab ── */} - {viewTab === "all" && ( -
- {isLoading ? ( - - ) : isEmpty ? ( -
- -

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

-
- ) : ( - <> - {displayMessages.map((msg) => ( - reanalyzeMut.mutate(id)} - /> - ))} - {hasMore && ( -
- + ← Back to list + +
- )} - + ) : null} +
)}
)} - {/* ── IMAGES tab ── */} - {viewTab === "images" && ( - + {/* ── Search overlay ── */} + setSearchOpen(false)} + onSelect={(id) => { + setDetailId(id); + setTab("all"); + }} + /> +
+ ); +} + +// ── Inline ImageGrid (glass-styled) ──────────────── + +function ImageGrid({ + items, + onSelect, +}: { + items: MessageRecord[]; + onSelect: (id: string) => void; +}) { + return ( +
+ {items.map((item) => { + const imgUrl = extractFirstImage(item.metadata); + return ( + + ); + })} + {items.length === 0 && ( +
+ No images +
)} +
+ ); +} - {/* ── REVIEW tab ── */} - {viewTab === "review" && ( - reanalyzeMut.mutate(id)} - /> +// ── Inline ReviewList (glass-styled) ──────────────── + +function ReviewList({ + items, + onSelect, +}: { + items: MessageRecord[]; + onSelect: (id: string) => void; +}) { + return ( +
+ {items.map((item) => ( + onSelect(item.id)} + > +
+ +
+

+ {item.content || item.id} +

+
+
+
+ ))} + {items.length === 0 && ( +
+ No flagged messages +
)} - - {/* Detail dialog */} - !o && setDetailId(null)} - > - - - - Message Detail - - - - {detailLoading ? ( -
- -
- ) : detailMessage ? ( - - ) : null} -
-
-
); } diff --git a/services/frontend/src/components/messages/message-list.tsx b/services/frontend/src/components/messages/message-list.tsx new file mode 100644 index 0000000..a7161cb --- /dev/null +++ b/services/frontend/src/components/messages/message-list.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { Loader2 } from "lucide-react"; +import { MessageCard } from "./message-card"; +import { Button } from "@/components/ui/button"; +import type { MessageRecord } from "@/lib/types"; + +interface MessageListProps { + messages: MessageRecord[]; + selectedId: string | null; + onSelect: (id: string) => void; + onReanalyze?: (id: string) => void; + hasMore?: boolean; + onLoadMore?: () => void; + isLoadingMore?: boolean; +} + +export function MessageList({ + messages, + selectedId: _selectedId, + onSelect, + onReanalyze, + hasMore, + onLoadMore, + isLoadingMore, +}: MessageListProps) { + return ( + <> + {messages.map((msg) => ( + onReanalyze?.(id)} + /> + ))} + {hasMore && ( +
+ +
+ )} + + ); +}