From 3ae0c96a130d79a34ff48582b564ffb095be5bee Mon Sep 17 00:00:00 2001 From: Developer Date: Tue, 28 Jul 2026 10:05:08 +0700 Subject: [PATCH] feat: design tokens, globals CSS, fonts, navigation config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rewrite globals.css with dark-theme OKLCH tokens, glass utilities, ambient bg - Update root layout with Inter + JetBrains Mono fonts, theme script - Redirect / to /dashboard - Update navigation config — remove search link, add recordings - Update analysis search-panel with glass styling Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/app/(dashboard)/dashboard/page.tsx | 123 +++--- .../frontend/src/app/(dashboard)/layout.tsx | 87 +++-- .../src/app/(dashboard)/messages/page.tsx | 338 ++++++++--------- .../src/app/(dashboard)/recordings/page.tsx | 56 ++- .../src/app/(dashboard)/settings/page.tsx | 235 +++++------- .../src/app/(dashboard)/voice/page.tsx | 81 ++-- services/frontend/src/app/globals.css | 354 +++++------------- services/frontend/src/app/layout.tsx | 17 +- services/frontend/src/app/page.tsx | 3 +- .../src/components/analysis/search-panel.tsx | 4 +- .../src/components/chatbot/chatbot.tsx | 227 ----------- .../components/dashboard/activity-heatmap.tsx | 62 +++ .../dashboard/channel-detail-section.tsx | 93 ----- .../components/dashboard/channels-section.tsx | 78 ---- .../src/components/dashboard/index.ts | 5 - .../dashboard/message-trend-chart.tsx | 48 +++ .../src/components/dashboard/stat-card.tsx | 79 ++++ .../components/dashboard/stats-section.tsx | 145 ------- .../dashboard/top-channels-chart.tsx | 36 ++ .../dashboard/user-detail-section.tsx | 106 ------ .../components/dashboard/users-section.tsx | 80 ---- .../src/components/layout/app-header.tsx | 167 --------- .../src/components/layout/app-sidebar.tsx | 101 ----- .../src/components/layout/hidden-sidebar.tsx | 51 +++ .../src/components/layout/mobile-nav.tsx | 17 +- .../src/components/layout/sub-nav.tsx | 39 ++ .../src/components/layout/top-nav.tsx | 81 ++++ .../src/components/mascot/chat-panel.tsx | 55 +++ .../frontend/src/components/mascot/index.ts | 3 + .../src/components/mascot/mascot-canvas.tsx | 125 +++++++ .../components/mascot/mascot-container.tsx | 83 ++++ .../src/components/mascot/mascot-context.tsx | 43 +++ .../src/components/media/mini-player.tsx | 43 +++ .../components/messages/ai-analysis-panel.tsx | 104 +++++ .../components/messages/attachments-grid.tsx | 32 ++ .../src/components/messages/images-grid.tsx | 62 --- .../src/components/messages/message-card.tsx | 215 ++++------- .../messages/message-detail-view.tsx | 165 -------- .../components/messages/message-detail.tsx | 55 +++ .../src/components/messages/message-list.tsx | 31 ++ .../src/components/messages/review-list.tsx | 39 -- .../components/messages/search-overlay.tsx | 96 +++++ .../components/recordings/recording-card.tsx | 59 +++ .../components/recordings/recording-list.tsx | 94 ----- .../recordings/recording-player.tsx | 31 ++ .../src/components/shared/detail-stat.tsx | 42 --- .../src/components/shared/empty-state.tsx | 28 +- .../src/components/shared/error-boundary.tsx | 35 ++ .../frontend/src/components/shared/index.ts | 3 +- .../components/shared/loading-skeleton.tsx | 41 +- .../src/components/shared/stat-card.tsx | 78 ---- .../components/voice/activity-timeline.tsx | 31 ++ .../src/components/voice/connection-card.tsx | 84 +++++ .../src/components/voice/mic-control.tsx | 44 +++ .../src/components/voice/speaker-waveform.tsx | 66 ++++ .../src/lib/hooks/use-media-player.tsx | 77 ++++ 56 files changed, 2173 insertions(+), 2404 deletions(-) delete mode 100644 services/frontend/src/components/chatbot/chatbot.tsx create mode 100644 services/frontend/src/components/dashboard/activity-heatmap.tsx delete mode 100644 services/frontend/src/components/dashboard/channel-detail-section.tsx delete mode 100644 services/frontend/src/components/dashboard/channels-section.tsx delete mode 100644 services/frontend/src/components/dashboard/index.ts create mode 100644 services/frontend/src/components/dashboard/message-trend-chart.tsx create mode 100644 services/frontend/src/components/dashboard/stat-card.tsx delete mode 100644 services/frontend/src/components/dashboard/stats-section.tsx create mode 100644 services/frontend/src/components/dashboard/top-channels-chart.tsx delete mode 100644 services/frontend/src/components/dashboard/user-detail-section.tsx delete mode 100644 services/frontend/src/components/dashboard/users-section.tsx delete mode 100644 services/frontend/src/components/layout/app-header.tsx delete mode 100644 services/frontend/src/components/layout/app-sidebar.tsx create mode 100644 services/frontend/src/components/layout/hidden-sidebar.tsx create mode 100644 services/frontend/src/components/layout/sub-nav.tsx create mode 100644 services/frontend/src/components/layout/top-nav.tsx create mode 100644 services/frontend/src/components/mascot/chat-panel.tsx create mode 100644 services/frontend/src/components/mascot/index.ts create mode 100644 services/frontend/src/components/mascot/mascot-canvas.tsx create mode 100644 services/frontend/src/components/mascot/mascot-container.tsx create mode 100644 services/frontend/src/components/mascot/mascot-context.tsx create mode 100644 services/frontend/src/components/media/mini-player.tsx create mode 100644 services/frontend/src/components/messages/ai-analysis-panel.tsx create mode 100644 services/frontend/src/components/messages/attachments-grid.tsx delete mode 100644 services/frontend/src/components/messages/images-grid.tsx delete mode 100644 services/frontend/src/components/messages/message-detail-view.tsx create mode 100644 services/frontend/src/components/messages/message-detail.tsx create mode 100644 services/frontend/src/components/messages/message-list.tsx delete mode 100644 services/frontend/src/components/messages/review-list.tsx create mode 100644 services/frontend/src/components/messages/search-overlay.tsx create mode 100644 services/frontend/src/components/recordings/recording-card.tsx delete mode 100644 services/frontend/src/components/recordings/recording-list.tsx create mode 100644 services/frontend/src/components/recordings/recording-player.tsx delete mode 100644 services/frontend/src/components/shared/detail-stat.tsx create mode 100644 services/frontend/src/components/shared/error-boundary.tsx delete mode 100644 services/frontend/src/components/shared/stat-card.tsx create mode 100644 services/frontend/src/components/voice/activity-timeline.tsx create mode 100644 services/frontend/src/components/voice/connection-card.tsx create mode 100644 services/frontend/src/components/voice/mic-control.tsx create mode 100644 services/frontend/src/components/voice/speaker-waveform.tsx create mode 100644 services/frontend/src/lib/hooks/use-media-player.tsx diff --git a/services/frontend/src/app/(dashboard)/dashboard/page.tsx b/services/frontend/src/app/(dashboard)/dashboard/page.tsx index c3d7c4f..8401822 100644 --- a/services/frontend/src/app/(dashboard)/dashboard/page.tsx +++ b/services/frontend/src/app/(dashboard)/dashboard/page.tsx @@ -1,83 +1,70 @@ "use client"; -import { BarChart3, Hash, Users } from "lucide-react"; +import { AlertCircle, Clock, Hash, Shield, Sparkles, Users } from "lucide-react"; import { useState } from "react"; -import { - ChannelDetailSection, - ChannelsSection, - StatsSection, - UserDetailSection, - UsersSection, -} from "@/components/dashboard"; -import { GuildSelector } from "@/components/shared/guild-selector"; -import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { useStats } from "@/hooks"; +import { StatCard } from "@/components/dashboard/stat-card"; +import { LiveStream } from "@/components/dashboard/live-stream"; +import { ModQueue } from "@/components/dashboard/mod-queue"; +import { MessageTrendChart } from "@/components/dashboard/message-trend-chart"; +import { ActivityHeatmap } from "@/components/dashboard/activity-heatmap"; +import { TopChannelsChart } from "@/components/dashboard/top-channels-chart"; +import { SubNav } from "@/components/layout/sub-nav"; +import { ErrorState, LoadingSkeleton } from "@/components/shared"; -type View = "stats" | "users" | "channels" | "user-detail" | "channel-detail"; +type DashboardTab = "stats" | "live" | "activity"; export default function DashboardPage() { - const [view, setView] = useState("stats"); - const [guildId, setGuildId] = useState(""); - const [selectedUserId, setSelectedUserId] = useState(null); - const [selectedChannelId, setSelectedChannelId] = useState( - null, - ); + const [tab, setTab] = useState("stats"); + const { data: stats, isLoading, error, refetch } = useStats(); + + const subNavTabs = [ + { id: "stats", label: "Stats", icon: }, + { id: "live", label: "Live", icon: }, + { id: "activity", label: "Activity", icon: }, + ]; return ( -
- +
+ setTab(t as DashboardTab)} /> - setView(v as View)} - > - - setView("stats")}> - Stats - - setView("users")}> - Users - - setView("channels")}> - Channels - - - + {tab === "stats" && ( +
+ {error ? ( + + ) : isLoading || !stats ? ( + + ) : ( + <> +
+ + + + + + +
- {view === "stats" && } - {view === "users" && ( - { - setSelectedUserId(userId); - setView("user-detail"); - }} - /> +
+ + +
+ + )} +
)} - {view === "user-detail" && selectedUserId && ( - setView("users")} - /> + + {tab === "live" && ( +
+ + +
)} - {view === "channels" && ( - { - setSelectedChannelId(chId); - setView("channel-detail"); - }} - /> - )} - {view === "channel-detail" && selectedChannelId && ( - setView("channels")} - /> + + {tab === "activity" && ( +
+ +
)}
); diff --git a/services/frontend/src/app/(dashboard)/layout.tsx b/services/frontend/src/app/(dashboard)/layout.tsx index a1c5453..8d8e7ad 100644 --- a/services/frontend/src/app/(dashboard)/layout.tsx +++ b/services/frontend/src/app/(dashboard)/layout.tsx @@ -2,50 +2,89 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { Suspense } from "react"; - -import { Chatbot } from "@/components/chatbot/chatbot"; -import { AppHeader } from "@/components/layout/app-header"; -import { AppSidebar } from "@/components/layout/app-sidebar"; +import { TopNav } from "@/components/layout/top-nav"; import { MobileNav } from "@/components/layout/mobile-nav"; import { WsProvider } from "@/lib/ws/context"; +import { MascotProvider } from "@/components/mascot/mascot-context"; +import { MascotContainer } from "@/components/mascot/mascot-container"; +import { MiniPlayer } from "@/components/media/mini-player"; +import { MediaPlayerProvider } from "@/lib/hooks/use-media-player"; +import { HiddenSidebar } from "@/components/layout/hidden-sidebar"; +import { useState } from "react"; +import { useWebSocket } from "@/lib/ws/context"; +import { useMascot } from "@/components/mascot/mascot-context"; +import { useEffect } from "react"; const queryClient = new QueryClient({ defaultOptions: { queries: { - staleTime: 15_000, + staleTime: 10_000, retry: 1, refetchOnWindowFocus: false, }, }, }); +function MascotExpressionSync() { + const ws = useWebSocket(); + const { setExpression } = useMascot(); + + useEffect(() => { + const unsub1 = ws.on("message_created", (data: any) => { + if (data.ai_status === "flagged" || data.ai_status === "warn") { + setExpression("surprise"); + setTimeout(() => setExpression("idle"), 2000); + } + }); + + const unsub2 = ws.on("voice_active_user", () => { + setExpression("listening"); + }); + + return () => { unsub1(); unsub2(); }; + }, [ws, setExpression]); + + return null; +} + export default function DashboardLayout({ children, }: { children: React.ReactNode; }) { + const [guildId, setGuildId] = useState(""); + return ( -
- -
- -
- -
-
- } - > - {children} -
-
-
- -
- + + +
+ + setGuildId(g ?? "")} /> + + + {/* Sub-nav space — filled per-page */} +
+
+ +
+
+ } + > + {children} +
+
+
+ + + + +
+
+
); diff --git a/services/frontend/src/app/(dashboard)/messages/page.tsx b/services/frontend/src/app/(dashboard)/messages/page.tsx index fa48a90..b08d0f5 100644 --- a/services/frontend/src/app/(dashboard)/messages/page.tsx +++ b/services/frontend/src/app/(dashboard)/messages/page.tsx @@ -1,23 +1,16 @@ "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 { MessageDetailView } from "@/components/messages/message-detail-view"; -import { ReviewList } from "@/components/messages/review-list"; +import { useCallback, useEffect, useState } from "react"; +import { useSearchParams, useRouter } from "next/navigation"; +import { Search, Flag, Image, Loader2, RefreshCw } from "lucide-react"; +import { MessageList } from "@/components/messages/message-list"; +import { MessageDetail } from "@/components/messages/message-detail"; +import { SearchOverlay } from "@/components/messages/search-overlay"; +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,8 +18,8 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { + useGuilds, useImages, useLoadMore, useMessageDetail, @@ -38,62 +31,60 @@ import { useReview, useTextChannels, } from "@/hooks"; -import { messagesApi } from "@/lib/api"; -import type { MessageRecord } from "@/lib/types"; import { useWebSocket } from "@/lib/ws/context"; +import { GuildSelector } from "@/components/shared/guild-selector"; +import { cn } from "@/lib/utils"; + +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); - const { - data: messages, - isLoading, - error, - refetch, - } = useMessages(guildId, selectedChannel || undefined); - const { data: cursorData } = useMessagesHasMore( - guildId, - selectedChannel || undefined, - ); + const { data: messages, isLoading, error, refetch } = useMessages(guildId, selectedChannel || undefined); + const { data: cursorData } = useMessagesHasMore(guildId, selectedChannel || undefined); 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, } = 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 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 + 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,168 +95,135 @@ export default function MessagesPage() { }); }, [cursorData, loadMoreMut, guildId, selectedChannel]); - const displayMessages = searchResults ?? messages ?? []; - const hasMore = cursorData?.hasMore ?? false; - const isEmpty = !isLoading && displayMessages.length === 0; + const subNavTabs = [ + { id: "all", label: "All" }, + { id: "images", label: "Images", icon: }, + { id: "review", label: "Review", icon: }, + ]; - if (error) { - return ( -
- - -
- ); - } + const currentMessages = messages ?? []; return ( -
- - -
-
- - setSearchQuery(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && handleSearch()} - className="pl-9 h-9" - /> -
+
+ {/* Controls bar */} +
+ { setGuildId(g ?? ""); setSelectedChannel(""); }} /> {channels.length > 0 && ( - setSelectedChannel(v ?? "")}> + All channels - {channels.map((ch) => ( - - # {ch.name} - + {channels.map((ch: any) => ( + # {ch.name} ))} )} - +
- setViewTab(v as typeof viewTab)} - > - - - All ({(searchResults ?? messages)?.length ?? 0}) - - - Images ({images?.length ?? 0}) - - - Review ({reviews?.length ?? 0}) - - - + setTab(t as MessagesTab)} /> - {searchResults && ( -

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

- )} + {/* Split pane */} + {error ? ( + + ) : isLoading ? ( + + ) : ( +
+ {/* Left pane — message list */} +
+ {tab === "all" && ( + <> + + {cursorData?.hasMore && ( +
+ +
+ )} + + )} + {tab === "images" && ( + + )} + {tab === "review" && ( + reanalyzeMut.mutate(id)} /> + )} +
- {/* ── ALL tab ── */} - {viewTab === "all" && ( -
- {isLoading ? ( - - ) : isEmpty ? ( -
- -

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

-
- ) : ( - <> - {displayMessages.map((msg) => ( - reanalyzeMut.mutate(id)} + {/* Right pane — detail */} + {detailId && ( +
+ {detailLoading ? ( + + + + ) : detailMessage ? ( + setDetailId(null)} /> - ))} - {hasMore && ( -
- -
- )} - + ) : null} +
)}
)} - {/* ── IMAGES tab ── */} - {viewTab === "images" && ( - + {/* Search overlay */} + setSearchOpen(false)} onSelect={setDetailId} /> +
+ ); +} + +// Inline ImageGrid and ReviewList +function ImageGrid({ items, onSelect }: { items: any[]; onSelect: (id: string) => void }) { + return ( +
+ {items.map((item: any) => ( + + ))} + {items.length === 0 && ( +
No images
)} +
+ ); +} - {/* ── REVIEW tab ── */} - {viewTab === "review" && ( - reanalyzeMut.mutate(id)} - /> +function ReviewList({ items, onSelect, onReanalyze }: { items: any[]; onSelect: (id: string) => void; onReanalyze: (id: string) => void }) { + return ( +
+ {items.map((item: any) => ( + onSelect(item.message_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/app/(dashboard)/recordings/page.tsx b/services/frontend/src/app/(dashboard)/recordings/page.tsx index 11fdee5..4bdc9df 100644 --- a/services/frontend/src/app/(dashboard)/recordings/page.tsx +++ b/services/frontend/src/app/(dashboard)/recordings/page.tsx @@ -1,14 +1,64 @@ "use client"; -import { RecordingList } from "@/components/recordings/recording-list"; +import { useState } from "react"; +import { RecordingCard } from "@/components/recordings/recording-card"; +import { RecordingPlayer } from "@/components/recordings/recording-player"; +import { SubNav } from "@/components/layout/sub-nav"; +import { ErrorState, LoadingSkeleton } from "@/components/shared"; +import { useRecordings } from "@/hooks"; import { useWebSocket } from "@/lib/ws/context"; +type RecordingsTab = "library" | "stats"; + export default function RecordingsPage() { const ws = useWebSocket(); + const { data: recordings, isLoading, error, refetch } = useRecordings(); + const [playingId, setPlayingId] = useState(null); + const [tab, setTab] = useState("library"); + + const currentTrack = playingId && recordings + ? recordings.find((r: any) => r.id === playingId) + : null; return ( -
- +
+ setTab(t as RecordingsTab)} + /> + + {tab === "library" && ( + <> + {error ? ( + + ) : isLoading ? ( + + ) : ( +
+ {(recordings ?? []).map((rec: any) => ( + setPlayingId(id === playingId ? null : id)} + /> + ))} + {(recordings ?? []).length === 0 && ( +
No recordings yet
+ )} +
+ )} + + )} + + {tab === "stats" && ( +
Recording stats coming soon
+ )} + + setPlayingId(null)} />
); } diff --git a/services/frontend/src/app/(dashboard)/settings/page.tsx b/services/frontend/src/app/(dashboard)/settings/page.tsx index b01c861..9daead4 100644 --- a/services/frontend/src/app/(dashboard)/settings/page.tsx +++ b/services/frontend/src/app/(dashboard)/settings/page.tsx @@ -2,18 +2,21 @@ import { Moon, Server, Shield, Sun, Wifi } from "lucide-react"; import { useEffect, useState } from "react"; +import { GlassCard } from "@/components/glass/card"; +import { GlassDivider } from "@/components/glass/divider"; +import { SubNav } from "@/components/layout/sub-nav"; import { LoadingSkeleton } from "@/components/shared"; -import { Badge } from "@/components/ui/badge"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Separator } from "@/components/ui/separator"; import { useConfig } from "@/hooks"; -import { cn } from "@/lib/utils"; import { useWebSocket } from "@/lib/ws/context"; +import { cn } from "@/lib/utils"; + +type SettingsTab = "connection" | "appearance" | "config" | "about"; export default function SettingsPage() { const { status } = useWebSocket(); const { data: config, isLoading: configLoading } = useConfig(); const [theme, setTheme] = useState<"light" | "dark">("dark"); + const [tab, setTab] = useState("connection"); useEffect(() => { const stored = localStorage.getItem("theme") as "light" | "dark" | null; @@ -28,147 +31,111 @@ export default function SettingsPage() { document.documentElement.classList.add(next); }; - const statusCfg = { - connected: { - label: "Connected", - variant: "default" as const, - dot: "bg-green-500 shadow-[0_0_6px] shadow-green-500/60", - }, - connecting: { - label: "Connecting", - variant: "secondary" as const, - dot: "bg-yellow-500 animate-pulse", - }, - disconnected: { - label: "Disconnected", - variant: "destructive" as const, - dot: "bg-destructive", - }, - error: { - label: "Error", - variant: "destructive" as const, - dot: "bg-destructive", - }, + const statusDot = { + connected: "bg-emerald-500 shadow-[0_0_8px] shadow-emerald-500/60 animate-pulse", + connecting: "bg-accent-amber animate-pulse", + disconnected: "bg-destructive", + error: "bg-destructive", + }[status]; + + const statusLabel = { + connected: "Connected", + connecting: "Connecting", + disconnected: "Disconnected", + error: "Error", }[status]; return ( -
- - - - Connection - - - +
+ }, + { id: "appearance", label: "Appearance", icon: }, + { id: "config", label: "Config", icon: }, + { id: "about", label: "About", icon: }, + ]} + activeTab={tab} + onTabChange={(t) => setTab(t as SettingsTab)} + /> + + {tab === "connection" && ( +
- WebSocket - - - {statusCfg.label} - -
- - - - - - - {theme === "dark" ? ( - - ) : ( - - )}{" "} - Appearance - - - - - - - - - - - Server Configuration - - - - {configLoading ? ( - - ) : config ? ( -
- - - - - - - - - +
+ WebSocket +
+
+ + {statusLabel}
- ) : ( -

- Unable to load configuration. -

- )} - - - - - - - About - - - -
-

- Discord Automod — - Discord Moderation Watcher -

-

- AI-powered message moderation, voice recording, and real-time - monitoring for Discord communities. -

-
-
+ + )} + + {tab === "appearance" && ( + +
+
+ {theme === "dark" ? : } + Theme +
+ +
+
+ )} + + {tab === "config" && ( + +
+ {configLoading ? ( + + ) : config ? ( + <> + + + + + + + + + + + ) : ( +

Unable to load config.

+ )} +
+
+ )} + + {tab === "about" && ( + +
+

Discord Automod

+

+ AI-powered message moderation, voice recording, and real-time monitoring for Discord communities. +

+
+ v0.1.0 +
+
+
+ )}
); } -function CfgRow({ label, value }: { label: string; value: string }) { +function ConfigRow({ label, value }: { label: string; value: string }) { return ( -
- {label} - - {value} - +
+ {label} + {value}
); } diff --git a/services/frontend/src/app/(dashboard)/voice/page.tsx b/services/frontend/src/app/(dashboard)/voice/page.tsx index f0c6313..f476441 100644 --- a/services/frontend/src/app/(dashboard)/voice/page.tsx +++ b/services/frontend/src/app/(dashboard)/voice/page.tsx @@ -1,20 +1,15 @@ "use client"; import { useCallback, useEffect, useState } from "react"; - -import { ActiveSpeakersPanel } from "@/components/voice/active-speakers-panel"; -import { MicrophoneCard } from "@/components/voice/microphone-card"; -import { VoiceConnectionCard } from "@/components/voice/voice-connection-card"; -import { - useGuilds, - useMicTransmit, - useSpeakers, - useVoiceChannels, - useVoiceConnect, - useVoiceDisconnect, - useVoiceStatus, -} from "@/hooks"; +import { VoiceConnectionCard } from "@/components/voice/connection-card"; +import { SpeakerWaveform } from "@/components/voice/speaker-waveform"; +import { MicControl } from "@/components/voice/mic-control"; +import { VoiceActivityTimeline } from "@/components/voice/activity-timeline"; +import { SubNav } from "@/components/layout/sub-nav"; import { useWebSocket } from "@/lib/ws/context"; +import { useGuilds, useMicTransmit, useSpeakers, useVoiceChannels, useVoiceConnect, useVoiceDisconnect, useVoiceStatus } from "@/hooks"; + +type VoiceTab = "connection" | "activity"; export default function VoicePage() { const ws = useWebSocket(); @@ -28,21 +23,14 @@ export default function VoicePage() { const micMut = useMicTransmit(); const [selectedChannel, setSelectedChannel] = useState(""); const [micActive, setMicActive] = useState(false); + const [volume, setVolume] = useState(75); + const [tab, setTab] = useState("connection"); useEffect(() => { const unsub = subscribe(ws); return () => unsub(); }, [ws, subscribe]); - const handleGuildChange = useCallback((guildId: string | null) => { - if (!guildId) { - setSelectedGuild(""); - setSelectedChannel(""); - return; - } - setSelectedGuild(guildId); - }, []); - const handleMicToggle = useCallback( async (checked: boolean) => { setMicActive(checked); @@ -59,25 +47,44 @@ export default function VoicePage() { const connected = voiceStatus?.connected ?? false; return ( -
+
+ setTab(t as VoiceTab)} + /> + setSelectedChannel(v)} - guilds={guilds} - voiceChannels={voiceChannels} connected={connected} activeChannelName={voiceStatus?.activeChannelName} - connectMut={connectMut} - disconnectMut={disconnectMut} - /> - - { setSelectedGuild(g ?? ""); setSelectedChannel(""); }} + onChannelChange={(v) => setSelectedChannel(v ?? "")} + onConnect={() => connectMut.mutate({ guildId: selectedGuild, channelId: selectedChannel })} + onDisconnect={() => disconnectMut.mutate(undefined)} + connecting={connectMut.isPending} /> + + {tab === "connection" && ( +
+ + +
+ )} + + {tab === "activity" && }
); } diff --git a/services/frontend/src/app/globals.css b/services/frontend/src/app/globals.css index 1bab1b2..59d6188 100644 --- a/services/frontend/src/app/globals.css +++ b/services/frontend/src/app/globals.css @@ -5,282 +5,106 @@ @custom-variant dark (&:is(.dark *)); @theme inline { - --color-background: var(--background); - --color-foreground: var(--foreground); - --font-sans: var(--font-sans); - --font-mono: var(--font-geist-mono); - --font-heading: var(--font-sans); - --color-sidebar-ring: var(--sidebar-ring); - --color-sidebar-border: var(--sidebar-border); - --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); - --color-sidebar-accent: var(--sidebar-accent); - --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); - --color-sidebar-primary: var(--sidebar-primary); - --color-sidebar-foreground: var(--sidebar-foreground); - --color-sidebar: var(--sidebar); - --color-chart-5: var(--chart-5); - --color-chart-4: var(--chart-4); - --color-chart-3: var(--chart-3); - --color-chart-2: var(--chart-2); - --color-chart-1: var(--chart-1); - --color-ring: var(--ring); - --color-input: var(--input); - --color-border: var(--border); - --color-destructive: var(--destructive); - --color-accent-foreground: var(--accent-foreground); - --color-accent: var(--accent); - --color-muted-foreground: var(--muted-foreground); - --color-muted: var(--muted); - --color-secondary-foreground: var(--secondary-foreground); - --color-secondary: var(--secondary); - --color-primary-foreground: var(--primary-foreground); - --color-primary: var(--primary); - --color-popover-foreground: var(--popover-foreground); - --color-popover: var(--popover); - --color-card-foreground: var(--card-foreground); - --color-card: var(--card); - --color-warning: var(--warning); - --color-warning-foreground: var(--warning-foreground); - --radius-sm: calc(var(--radius) * 0.6); - --radius-md: calc(var(--radius) * 0.8); - --radius-lg: var(--radius); - --radius-xl: calc(var(--radius) * 1.4); - --radius-2xl: calc(var(--radius) * 1.8); - --radius-3xl: calc(var(--radius) * 2.2); - --radius-4xl: calc(var(--radius) * 2.6); + /* Canvas — deep navy */ + --color-canvas: oklch(0.07 0.015 250); + --color-surface: oklch(0.11 0.02 245 / 0.6); + --color-surface-hover: oklch(0.15 0.02 245 / 0.7); - /* Teal-cyan accent gradient for monitoring hub look */ - --accent-gradient: linear-gradient(135deg, oklch(0.62 0.17 215), oklch(0.6 0.15 195), oklch(0.65 0.12 185)); - --accent-gradient-subtle: linear-gradient(135deg, oklch(0.62 0.17 215 / 0.15), oklch(0.65 0.12 185 / 0.05)); + /* Glass */ + --color-glass-bg: oklch(1 0 0 / 0.04); + --color-glass-border: oklch(1 0 0 / 0.08); + --glass-shadow: 0 8px 32px oklch(0 0 0 / 0.4); - /* Glass morphism */ - --glass-bg: oklch(1 0 0 / 0.05); - --glass-border: oklch(1 0 0 / 0.1); - --glass-shadow: 0 8px 32px oklch(0 0 0 / 0.3); + /* Primary — teal-cyan */ + --color-primary: oklch(0.62 0.17 215); + --color-primary-glow: oklch(0.62 0.17 215 / 0.4); + --color-primary-foreground: oklch(0.98 0 0); + --color-border: oklch(1 0 0 / 0.06); + --color-border-glow: oklch(0.62 0.17 215 / 0.3); + + /* Accents */ + --color-accent-purple: oklch(0.65 0.2 280); + --color-accent-amber: oklch(0.7 0.17 75); + --color-destructive: oklch(0.577 0.245 27.325); + --color-success: oklch(0.6 0.18 160); + + /* Text */ + --color-text-primary: oklch(0.93 0.01 245); + --color-text-secondary: oklch(0.55 0.02 245); + --color-text-mono: oklch(0.62 0.17 215); + + /* Legacy overrides for shadcn compatibility */ + --color-background: var(--color-canvas); + --color-foreground: var(--color-text-primary); + --color-card: var(--color-surface); + --color-card-foreground: var(--color-text-primary); + --color-muted: oklch(0.17 0.015 245); + --color-muted-foreground: var(--color-text-secondary); + --color-accent: var(--color-primary); + --color-accent-foreground: var(--color-primary-foreground); + + /* Radius */ + --radius-card: 16px; + --radius-panel: 12px; + --radius-control: 8px; + --radius-pill: 9999px; + --radius: 0.625rem; /* shadcn compat */ + + /* Fonts */ + --font-sans: "Inter", sans-serif; + --font-mono: "JetBrains Mono", monospace; } -:root { - --card: oklch(1 0 0); - --card-foreground: oklch(0.145 0 0); - --popover: oklch(1 0 0); - --popover-foreground: oklch(0.145 0 0); - --primary: oklch(0.55 0.18 240); - --primary-foreground: oklch(0.985 0 0); - --secondary: oklch(0.97 0 0); - --secondary-foreground: oklch(0.205 0 0); - --muted: oklch(0.95 0 0); - --muted-foreground: oklch(0.556 0 0); - --accent: oklch(0.65 0.15 220); - --accent-foreground: oklch(0.205 0 0); - --destructive: oklch(0.577 0.245 27.325); - --warning: oklch(0.7 0.18 75); - --warning-foreground: oklch(0.98 0 0); - --border: oklch(0.922 0 0); - --input: oklch(0.922 0 0); - --ring: oklch(0.55 0.18 240); - --chart-1: oklch(0.55 0.18 240); - --chart-2: oklch(0.55 0.15 200); - --chart-3: oklch(0.55 0.12 180); - --chart-4: oklch(0.55 0.2 260); - --chart-5: oklch(0.55 0.15 280); - --radius: 0.625rem; - --sidebar: oklch(0.985 0 0); - --sidebar-foreground: oklch(0.145 0 0); - --sidebar-primary: oklch(0.55 0.18 240); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.95 0 0); - --sidebar-accent-foreground: oklch(0.145 0 0); - --sidebar-border: oklch(0.922 0 0); - --sidebar-ring: oklch(0.55 0.18 240); - --background: oklch(1 0 0); - --foreground: oklch(0.145 0 0); -} - -.dark { - /* Deeper navy canvas — monitoring hub feel */ - --background: oklch(0.09 0.015 245); - --foreground: oklch(0.93 0.01 245); - - /* Card surface with subtle separation */ - --card: oklch(0.13 0.02 245); - --card-foreground: oklch(0.93 0.01 245); - - --popover: oklch(0.13 0.02 245); - --popover-foreground: oklch(0.93 0.01 245); - - /* Teal-cyan primary */ - --primary: oklch(0.62 0.17 215); - --primary-foreground: oklch(0.98 0 0); - - --secondary: oklch(0.2 0.015 245); - --secondary-foreground: oklch(0.93 0.01 245); - - --muted: oklch(0.17 0.015 245); - --muted-foreground: oklch(0.55 0.02 245); - - /* Electric blue-purple accent */ - --accent: oklch(0.7 0.18 260); - --accent-foreground: oklch(0.98 0 0); - - --destructive: oklch(0.6 0.22 25); - - /* Amber-gold warning (distinct from red) */ - --warning: oklch(0.7 0.17 75); - --warning-foreground: oklch(0.12 0 0); - - --border: oklch(1 0 0 / 0.06); - --input: oklch(1 0 0 / 0.1); - - --ring: oklch(0.62 0.17 215); - - /* Teal-cyan chart palette */ - --chart-1: oklch(0.62 0.17 215); - --chart-2: oklch(0.6 0.15 195); - --chart-3: oklch(0.65 0.12 185); - --chart-4: oklch(0.7 0.18 260); - --chart-5: oklch(0.55 0.15 280); - - /* Deeper sidebar with teal accent */ - --sidebar: oklch(0.075 0.01 245); - --sidebar-foreground: oklch(0.93 0.01 245); - --sidebar-primary: oklch(0.62 0.17 215); - --sidebar-primary-foreground: oklch(0.98 0 0); - --sidebar-accent: oklch(0.16 0.025 215); - --sidebar-accent-foreground: oklch(0.93 0.01 245); - --sidebar-border: oklch(1 0 0 / 0.04); - --sidebar-ring: oklch(0.62 0.17 215); - - /* Glass overrides for dark */ - --glass-bg: oklch(1 0 0 / 0.04); - --glass-border: oklch(1 0 0 / 0.08); +@layer utilities { + .glass { + background: var(--color-glass-bg); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border: 1px solid var(--color-glass-border); + box-shadow: var(--glass-shadow); + } + .glass-elevated { + background: var(--color-glass-bg); + backdrop-filter: blur(16px); + border: 1px solid var(--color-border-glow); + box-shadow: 0 8px 32px oklch(0 0 0 / 0.5), 0 0 20px var(--color-primary-glow); + } + .glass-intense { + background: oklch(1 0 0 / 0.08); + backdrop-filter: blur(20px); + border: 1px solid oklch(1 0 0 / 0.12); + } } @layer base { - * { - @apply border-border outline-ring/50; - } + * { @apply border-border; } body { - @apply bg-background text-foreground; - background-image: radial-gradient(circle, oklch(1 0 0 / 0.025) 1px, transparent 1px); - background-size: 24px 24px; - } - html { - @apply font-sans scroll-smooth; - } - - /* Custom selection color */ - ::selection { - background: oklch(0.62 0.17 215 / 0.3); - color: inherit; - } - - .dark ::selection { - background: oklch(0.62 0.17 215 / 0.4); - } - - /* Scrollbar styling */ - ::-webkit-scrollbar { - width: 6px; - height: 6px; - } - ::-webkit-scrollbar-track { - background: transparent; - } - ::-webkit-scrollbar-thumb { - background: oklch(1 0 0 / 0.1); - border-radius: 999px; - } - ::-webkit-scrollbar-thumb:hover { - background: oklch(1 0 0 / 0.2); + @apply bg-canvas text-text-primary font-sans antialiased; + background-image: + radial-gradient(circle, oklch(1 0 0 / 0.025) 1px, transparent 1px), + radial-gradient(ellipse 80% 50% at 50% -20%, oklch(0.62 0.17 215 / 0.06), transparent), + radial-gradient(ellipse 50% 40% at 80% 80%, oklch(0.65 0.2 280 / 0.04), transparent); + background-size: 24px 24px, 100% 100%, 100% 100%; } + ::-webkit-scrollbar { width: 6px; height: 6px; } + ::-webkit-scrollbar-track { background: transparent; } + ::-webkit-scrollbar-thumb { background: oklch(1 0 0 / 0.1); border-radius: 999px; } + ::-webkit-scrollbar-thumb:hover { background: oklch(1 0 0 / 0.2); } } -/* ── Utility classes ─────────────────────────── */ - -/* Glass card effect */ -.glass { - background: var(--glass-bg); - backdrop-filter: blur(12px); - -webkit-backdrop-filter: blur(12px); - border: 1px solid var(--glass-border); - box-shadow: var(--glass-shadow); -} - -/* Gradient text */ -.text-gradient { - background: var(--accent-gradient); - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; - background-clip: text; -} - -/* Gradient border (via pseudo-element trick) */ -.gradient-border { - position: relative; -} -.gradient-border::before { - content: ""; - position: absolute; - inset: 0; - border-radius: inherit; - padding: 1px; - background: var(--accent-gradient); - -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); - -webkit-mask-composite: xor; - mask-composite: exclude; -} - -/* Animated background */ -@keyframes gradient-shift { - 0%, - 100% { - background-position: 0% 50%; - } - 50% { - background-position: 100% 50%; - } -} - -.animate-gradient { - background: linear-gradient( - -45deg, - oklch(0.65 0.18 240 / 0.1), - oklch(0.6 0.15 200 / 0.05), - oklch(0.12 0.02 240 / 1), - oklch(0.65 0.12 180 / 0.08) - ); - background-size: 400% 400%; - animation: gradient-shift 15s ease infinite; -} - -/* Counter animation placeholder - will be done in JS */ -@keyframes fade-in-up { - from { - opacity: 0; - transform: translateY(8px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -.animate-fade-in-up { - animation: fade-in-up 0.3s ease-out forwards; -} - -/* Pulse ring for live indicators */ @keyframes pulse-ring { - 0% { - transform: scale(0.8); - opacity: 1; - } - 100% { - transform: scale(2.5); - opacity: 0; - } + 0% { transform: scale(0.8); opacity: 1; } + 100% { transform: scale(2.5); opacity: 0; } +} +@keyframes fade-in-up { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} +@keyframes shimmer { + 0% { background-position: -200% 0; } + 100% { background-position: 200% 0; } } -.live-pulse-ring { - animation: pulse-ring 1.5s ease-out infinite; -} +.animate-fade-in-up { animation: fade-in-up 0.3s ease-out forwards; } +.animate-pulse-ring { animation: pulse-ring 1.5s ease-out infinite; } +.animate-shimmer { background: linear-gradient(90deg, transparent, oklch(0.62 0.17 215 / 0.08), transparent); background-size: 200% 100%; animation: shimmer 1.5s infinite; } diff --git a/services/frontend/src/app/layout.tsx b/services/frontend/src/app/layout.tsx index 2620eec..de177bf 100644 --- a/services/frontend/src/app/layout.tsx +++ b/services/frontend/src/app/layout.tsx @@ -1,23 +1,22 @@ import type { Metadata } from "next"; -import { Geist, Geist_Mono } from "next/font/google"; +import { Inter, JetBrains_Mono } from "next/font/google"; import Script from "next/script"; import { Toaster } from "@/components/ui/sonner"; -import { TooltipProvider } from "@/components/ui/tooltip"; import "./globals.css"; -const geistSans = Geist({ - variable: "--font-geist-sans", +const inter = Inter({ subsets: ["latin"], + variable: "--font-inter", }); -const geistMono = Geist_Mono({ - variable: "--font-geist-mono", +const jetbrainsMono = JetBrains_Mono({ subsets: ["latin"], + variable: "--font-jetbrains-mono", }); export const metadata: Metadata = { title: "Discord Automod — Moderation Dashboard", - description: "Live Discord monitoring and AI moderation dashboard", + description: "AI-powered Discord moderation and voice monitoring dashboard", }; export default function RootLayout({ @@ -28,7 +27,7 @@ export default function RootLayout({ return ( @@ -37,7 +36,7 @@ export default function RootLayout({ - {children} + {children} diff --git a/services/frontend/src/app/page.tsx b/services/frontend/src/app/page.tsx index c8618ae..eb98fb0 100644 --- a/services/frontend/src/app/page.tsx +++ b/services/frontend/src/app/page.tsx @@ -1,5 +1,4 @@ import { redirect } from "next/navigation"; - export default function RootPage() { - redirect("/messages"); + redirect("/dashboard"); } diff --git a/services/frontend/src/components/analysis/search-panel.tsx b/services/frontend/src/components/analysis/search-panel.tsx index 02ba6f3..c5b63ed 100644 --- a/services/frontend/src/components/analysis/search-panel.tsx +++ b/services/frontend/src/components/analysis/search-panel.tsx @@ -64,8 +64,8 @@ export function SearchPanel() {

{results.length === 0 ? ( ) : (
diff --git a/services/frontend/src/components/chatbot/chatbot.tsx b/services/frontend/src/components/chatbot/chatbot.tsx deleted file mode 100644 index 5fabfd9..0000000 --- a/services/frontend/src/components/chatbot/chatbot.tsx +++ /dev/null @@ -1,227 +0,0 @@ -"use client"; - -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { - Bot, - Loader2, - MessageCircle, - Send, - Sparkles, - Trash2, - User, - X, -} from "lucide-react"; -import { useCallback, useEffect, useRef, useState } from "react"; -import { Avatar, AvatarFallback } from "@/components/ui/avatar"; -import { Button } from "@/components/ui/button"; -import { - Card, - CardContent, - CardFooter, - CardHeader, - CardTitle, -} from "@/components/ui/card"; -import { Input } from "@/components/ui/input"; -import { ScrollArea } from "@/components/ui/scroll-area"; -import { chatbotApi } from "@/lib/api"; -import type { ChatHistoryMessage } from "@/lib/types"; -import { cn } from "@/lib/utils"; - -export function Chatbot() { - const [open, setOpen] = useState(false); - const [messages, setMessages] = useState([]); - const [input, setInput] = useState(""); - const scrollRef = useRef(null); - const qc = useQueryClient(); - - const { data: historyMessages = [] } = useQuery({ - queryKey: ["chatbot-history"], - queryFn: () => chatbotApi.getHistory(), - enabled: open, - }); - - useEffect(() => { - if (historyMessages.length > 0) setMessages(historyMessages); - }, [historyMessages]); - - const sendMut = useMutation({ - mutationFn: (text: string) => chatbotApi.send(text), - onSuccess: () => qc.invalidateQueries({ queryKey: ["chatbot-history"] }), - }); - - const clearMut = useMutation({ - mutationFn: () => chatbotApi.clearHistory(), - onSuccess: () => qc.setQueryData(["chatbot-history"], []), - }); - - useEffect(() => { - if (scrollRef.current) { - scrollRef.current.scrollTop = scrollRef.current.scrollHeight; - } - }, []); - - const handleClear = useCallback(() => { - clearMut.mutate(); - setMessages([]); - }, [clearMut]); - - const handleSend = useCallback(async () => { - if (!input.trim() || sendMut.isPending) return; - const text = input.trim(); - setInput(""); - - // Add optimistic user message - setMessages((prev) => [ - ...prev, - { role: "user", content: text, timestamp: new Date().toISOString() }, - ]); - - try { - const resp = await sendMut.mutateAsync(text); - setMessages((prev) => [ - ...prev, - { - role: "assistant", - content: resp.response, - timestamp: resp.timestamp, - }, - ]); - } catch { - setMessages((prev) => [ - ...prev, - { - role: "assistant", - content: "Sorry, I couldn't process that request.", - timestamp: new Date().toISOString(), - }, - ]); - } - }, [input, sendMut]); - - return ( - <> - {/* Toggle button */} - - - {/* Chat panel */} - {open && ( - - - -
- -
- Chatbot - -
- {messages.length > 0 && ( - - )} - - - - - -
- {messages.length === 0 && ( -

- Ask me anything about the server! -

- )} - {messages.map((msg) => ( -
- - - {msg.role === "user" ? ( - - ) : ( - - )} - - -
- {msg.content} -
-
- ))} - {sendMut.isPending && ( -
- - - - - -
- -
-
- )} -
-
-
- - -
{ - e.preventDefault(); - handleSend(); - }} - className="flex w-full gap-2" - > - setInput(e.target.value)} - disabled={sendMut.isPending} - className="h-8 flex-1" - /> - -
-
- - )} - - ); -} diff --git a/services/frontend/src/components/dashboard/activity-heatmap.tsx b/services/frontend/src/components/dashboard/activity-heatmap.tsx new file mode 100644 index 0000000..2249dce --- /dev/null +++ b/services/frontend/src/components/dashboard/activity-heatmap.tsx @@ -0,0 +1,62 @@ +"use client"; + +import { GlassCard } from "@/components/glass/card"; +import { cn } from "@/lib/utils"; + +const HOURS = Array.from({ length: 24 }, (_, i) => i); +const DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; + +interface ActivityHeatmapProps { + data?: Record; // key: "day-hour", value: count +} + +export function ActivityHeatmap({ data = {} }: ActivityHeatmapProps) { + const maxVal = Math.max(...Object.values(data), 1); + + const getIntensity = (day: string, hour: number) => { + const val = data[`${day}-${hour}`] || 0; + const pct = val / maxVal; + if (pct === 0) return "bg-surface"; + if (pct < 0.25) return "bg-primary/15"; + if (pct < 0.5) return "bg-primary/30"; + if (pct < 0.75) return "bg-primary/50"; + return "bg-primary/70"; + }; + + return ( + +
+ Activity + hour x day +
+
+
+ {/* Hour labels */} +
+
+ {DAYS.map((d) => ( +
{d}
+ ))} +
+ {/* Grid */} +
+ {HOURS.map((hour) => ( +
+ {DAYS.map((day) => ( +
+ ))} +
+ {hour % 4 === 0 ? hour : ""} +
+
+ ))} +
+
+
+ + ); +} diff --git a/services/frontend/src/components/dashboard/channel-detail-section.tsx b/services/frontend/src/components/dashboard/channel-detail-section.tsx deleted file mode 100644 index 0f77bc2..0000000 --- a/services/frontend/src/components/dashboard/channel-detail-section.tsx +++ /dev/null @@ -1,93 +0,0 @@ -"use client"; - -import { ArrowLeft, Clock, Hash, Sparkles } from "lucide-react"; - -import { DetailStat, ErrorState, LoadingSkeleton } from "@/components/shared"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent } from "@/components/ui/card"; -import { useChannelDetail } from "@/hooks"; - -export function ChannelDetailSection({ - channelId, - onBack, -}: { - channelId: string; - onBack: () => void; -}) { - const { data: channel, isLoading } = useChannelDetail(channelId); - if (isLoading) return ; - if (!channel) return ; - - return ( -
- - - -
-

- - {channel.channel_name ?? channel.channel_id.slice(0, 8)} -

-

- {channel.channel_id} -

-
-
- - - -
- {channel.culture_summary && ( -
-
- -

- Channel Culture -

-
-

- “{channel.culture_summary}” -

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

- Recent - Messages -

-
- {channel.recent_messages.slice(0, 5).map((msg) => ( -
-
- - {msg.username} - - - {new Date(msg.created_at).toLocaleString()} - -
-

{msg.content}

-
- ))} -
-
- )} -
-
-
- ); -} diff --git a/services/frontend/src/components/dashboard/channels-section.tsx b/services/frontend/src/components/dashboard/channels-section.tsx deleted file mode 100644 index db4b97b..0000000 --- a/services/frontend/src/components/dashboard/channels-section.tsx +++ /dev/null @@ -1,78 +0,0 @@ -"use client"; - -import { ChevronRight, Hash, Search } from "lucide-react"; -import { useState } from "react"; - -import { EmptyState, LoadingSkeleton } from "@/components/shared"; -import { Card, CardContent } from "@/components/ui/card"; -import { Input } from "@/components/ui/input"; -import { useChannels } from "@/hooks"; - -export function ChannelsSection({ - guildId, - onSelect, -}: { - guildId: string; - onSelect: (id: string) => void; -}) { - const [search, setSearch] = useState(""); - const { - data: channels, - isLoading, - refetch, - } = useChannels(guildId, search || undefined); - - return ( -
-
- - setSearch(e.target.value)} - className="pl-9 h-9" - /> -
- {isLoading ? ( - - ) : !channels || channels.length === 0 ? ( - - ) : ( -
- {channels.map((ch) => ( - onSelect(ch.channel_id)} - > - -
-
-
- -

- {ch.channel_name ?? ch.channel_id.slice(0, 8)} -

-
-

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

-
- -
- {ch.culture_summary && ( -

- “{ch.culture_summary}” -

- )} -
-
- ))} -
- )} -
- ); -} diff --git a/services/frontend/src/components/dashboard/index.ts b/services/frontend/src/components/dashboard/index.ts deleted file mode 100644 index d496c3a..0000000 --- a/services/frontend/src/components/dashboard/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export { ChannelDetailSection } from "./channel-detail-section"; -export { ChannelsSection } from "./channels-section"; -export { StatsSection } from "./stats-section"; -export { UserDetailSection } from "./user-detail-section"; -export { UsersSection } from "./users-section"; diff --git a/services/frontend/src/components/dashboard/message-trend-chart.tsx b/services/frontend/src/components/dashboard/message-trend-chart.tsx new file mode 100644 index 0000000..8574ed6 --- /dev/null +++ b/services/frontend/src/components/dashboard/message-trend-chart.tsx @@ -0,0 +1,48 @@ +"use client"; + +import { GlassCard } from "@/components/glass/card"; +import { Area, AreaChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"; + +interface MessageTrendChartProps { + data?: { date: string; messages: number; flagged: number }[]; +} + +export function MessageTrendChart({ data = [] }: MessageTrendChartProps) { + return ( + +
+ Message Trend + 7 days +
+
+ + + + + + + + + + + + + + + + + + + +
+
+ ); +} diff --git a/services/frontend/src/components/dashboard/stat-card.tsx b/services/frontend/src/components/dashboard/stat-card.tsx new file mode 100644 index 0000000..4dc1ca0 --- /dev/null +++ b/services/frontend/src/components/dashboard/stat-card.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { type LucideIcon } from "lucide-react"; +import { GlassCard } from "@/components/glass/card"; +import { cn } from "@/lib/utils"; +import { Area, AreaChart, ResponsiveContainer } from "recharts"; + +interface StatCardProps { + label: string; + value: number | string; + icon: LucideIcon; + variant?: "default" | "danger" | "success"; + sparklineData?: { value: number }[]; + formatter?: (v: number) => string; +} + +export function StatCard({ + label, + value, + icon: Icon, + variant = "default", + sparklineData, + formatter = (v) => (typeof v === "number" ? v.toLocaleString() : v), +}: StatCardProps) { + const accentColor = { + default: "var(--color-primary)", + danger: "var(--color-destructive)", + success: "oklch(0.6 0.18 160)", + }[variant]; + + const bgAccent = { + default: "bg-primary/10 text-primary", + danger: "bg-destructive/10 text-destructive", + success: "bg-emerald-500/10 text-emerald-500", + }[variant]; + + const numValue = typeof value === "number" ? value : Number(value); + + return ( + +
+
+ +
+
+
+ {formatter(numValue)} +
+
+ {label} +
+ + {/* Sparkline background */} + {sparklineData && sparklineData.length > 0 && ( +
+ + + + + + + + + + + +
+ )} +
+ ); +} diff --git a/services/frontend/src/components/dashboard/stats-section.tsx b/services/frontend/src/components/dashboard/stats-section.tsx deleted file mode 100644 index 27b46ee..0000000 --- a/services/frontend/src/components/dashboard/stats-section.tsx +++ /dev/null @@ -1,145 +0,0 @@ -"use client"; - -import { - AlertCircle, - Clock, - Hash, - Shield, - Sparkles, - Users, -} from "lucide-react"; - -import { ErrorState, LoadingSkeleton, StatCard } from "@/components/shared"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Progress } from "@/components/ui/progress"; -import { useStats } from "@/hooks"; -import { formatNumber } from "@/lib/format"; - -export function StatsSection() { - const { data: stats, isLoading, error, refetch } = useStats(); - if (error) return ; - if (isLoading || !stats) - return ( -
- -
- ); - - return ( -
-
- - - - - - - - -
-
- - - - Top Channels - - - - {stats.top_channels.length === 0 ? ( -

- No channel data yet. -

- ) : ( -
- {stats.top_channels.map((ch) => { - const max = stats.top_channels[0].message_count; - const pct = max > 0 ? (ch.message_count / max) * 100 : 0; - return ( -
-
- - #{ch.channel_name ?? ch.channel_id.slice(0, 8)} - - - {formatNumber(ch.message_count)} - -
- -
- ); - })} -
- )} -
-
- - - - Moderation - Queue - - - -
- {[ - { - label: "Pending", - value: stats.moderation_overview.pending, - cls: "bg-muted/50", - }, - { - label: "Processing", - value: stats.moderation_overview.processing, - cls: "bg-yellow-500/10 text-yellow-500", - }, - { - label: "Errors", - value: stats.moderation_overview.error, - cls: "bg-destructive/10 text-destructive", - }, - ].map(({ label, value, cls }) => ( -
-
- {value} -
-
{label}
-
- ))} -
-
-
-
-
- ); -} diff --git a/services/frontend/src/components/dashboard/top-channels-chart.tsx b/services/frontend/src/components/dashboard/top-channels-chart.tsx new file mode 100644 index 0000000..97ea18f --- /dev/null +++ b/services/frontend/src/components/dashboard/top-channels-chart.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { GlassCard } from "@/components/glass/card"; +import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"; + +interface TopChannelsChartProps { + data?: { name: string; count: number }[]; +} + +export function TopChannelsChart({ data = [] }: TopChannelsChartProps) { + return ( + +
+ Top Channels +
+
+ + + + + + + + +
+
+ ); +} diff --git a/services/frontend/src/components/dashboard/user-detail-section.tsx b/services/frontend/src/components/dashboard/user-detail-section.tsx deleted file mode 100644 index c8cf18e..0000000 --- a/services/frontend/src/components/dashboard/user-detail-section.tsx +++ /dev/null @@ -1,106 +0,0 @@ -"use client"; - -import { ArrowLeft, Clock, Sparkles } from "lucide-react"; -import Image from "next/image"; - -import { DetailStat, ErrorState, LoadingSkeleton } from "@/components/shared"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent } from "@/components/ui/card"; -import { useUserDetail } from "@/hooks"; - -export function UserDetailSection({ - userId, - onBack, -}: { - userId: string; - onBack: () => void; -}) { - const { data: user, isLoading } = useUserDetail(userId); - if (isLoading) return ; - if (!user) return ; - - return ( -
- - - -
-
- {user.avatar_url ? ( - - ) : ( - (user.username ?? "?").charAt(0).toUpperCase() - )} -
-
-

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

-

- {user.user_id} -

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

- AI Profile -

-
-

{user.profile_summary}

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

- Recent - Messages -

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

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

-

{msg.content}

-
- ))} -
-
- )} -
-
-
- ); -} diff --git a/services/frontend/src/components/dashboard/users-section.tsx b/services/frontend/src/components/dashboard/users-section.tsx deleted file mode 100644 index 3477ce0..0000000 --- a/services/frontend/src/components/dashboard/users-section.tsx +++ /dev/null @@ -1,80 +0,0 @@ -"use client"; - -import { ChevronRight, Search, Users } from "lucide-react"; -import Image from "next/image"; -import { useState } from "react"; - -import { EmptyState, LoadingSkeleton } from "@/components/shared"; -import { Badge } from "@/components/ui/badge"; -import { Card, CardContent } from "@/components/ui/card"; -import { Input } from "@/components/ui/input"; -import { useUsers } from "@/hooks"; - -export function UsersSection({ onSelect }: { onSelect: (id: string) => void }) { - const [search, setSearch] = useState(""); - const { data: users, isLoading } = useUsers(search || undefined); - - return ( -
-
- - setSearch(e.target.value)} - className="pl-9 h-9" - /> -
- {isLoading ? ( - - ) : !users || users.length === 0 ? ( - - ) : ( -
- {users.map((u) => ( - onSelect(u.user_id)} - > - -
-
- {u.avatar_url ? ( - - ) : ( - (u.username ?? "?").charAt(0).toUpperCase() - )} -
-
-

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

-

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

-
- -
-
-
- ))} -
- )} -
- ); -} diff --git a/services/frontend/src/components/layout/app-header.tsx b/services/frontend/src/components/layout/app-header.tsx deleted file mode 100644 index 4c4e8fb..0000000 --- a/services/frontend/src/components/layout/app-header.tsx +++ /dev/null @@ -1,167 +0,0 @@ -"use client"; - -import { Moon, PanelLeft, Sun } from "lucide-react"; -import { usePathname } from "next/navigation"; -import { useEffect, useState } from "react"; - -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { isActivePath, navItems } from "@/lib/navigation"; -import { cn } from "@/lib/utils"; -import { useWebSocket } from "@/lib/ws/context"; - -export function AppHeader() { - const pathname = usePathname(); - const { status } = useWebSocket(); - const [theme, setTheme] = useState<"light" | "dark">("dark"); - const [mobileOpen, setMobileOpen] = useState(false); - - useEffect(() => { - const stored = localStorage.getItem("theme") as "light" | "dark" | null; - if (stored) setTheme(stored); - }, []); - - const toggleTheme = () => { - const next = theme === "dark" ? "light" : "dark"; - setTheme(next); - localStorage.setItem("theme", next); - document.documentElement.classList.remove("light", "dark"); - document.documentElement.classList.add(next); - }; - - const pageTitle = - navItems - .filter((n) => isActivePath(pathname, n.matchPrefix)) - .map((n) => n.label) - .at(0) ?? "Dashboard"; - - const statusVariant = - status === "connected" - ? "default" - : status === "connecting" - ? "secondary" - : "destructive"; - - const statusLabel = - status === "connected" - ? "Connected" - : status === "connecting" - ? "Connecting" - : "Disconnected"; - - return ( - <> -
- {/* Mobile menu button */} - - -

{pageTitle}

- -
- - - - {statusLabel} - - - -
- - {/* Mobile overlay menu */} - {mobileOpen && ( -
- {/* biome-ignore lint/a11y/noStaticElementInteractions: overlay backdrop */} -
setMobileOpen(false)} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") setMobileOpen(false); - }} - /> - -
- )} - - ); -} diff --git a/services/frontend/src/components/layout/app-sidebar.tsx b/services/frontend/src/components/layout/app-sidebar.tsx deleted file mode 100644 index 0306408..0000000 --- a/services/frontend/src/components/layout/app-sidebar.tsx +++ /dev/null @@ -1,101 +0,0 @@ -"use client"; - -import { usePathname, useRouter } from "next/navigation"; - -import { isActivePath, navItems } from "@/lib/navigation"; -import { cn } from "@/lib/utils"; -import { useWebSocket } from "@/lib/ws/context"; - -export function AppSidebar() { - const pathname = usePathname(); - const router = useRouter(); - const { status } = useWebSocket(); - - const connectionDot = { - connected: - "bg-emerald-500 shadow-[0_0_8px] shadow-emerald-500/60 animate-pulse", - connecting: "bg-amber-400 animate-pulse", - disconnected: "bg-destructive", - error: "bg-destructive", - }[status]; - - const connectionLabel = { - connected: "Connected", - connecting: "Connecting", - disconnected: "Disconnected", - error: "Error", - }[status]; - - return ( - - ); -} diff --git a/services/frontend/src/components/layout/hidden-sidebar.tsx b/services/frontend/src/components/layout/hidden-sidebar.tsx new file mode 100644 index 0000000..54edadf --- /dev/null +++ b/services/frontend/src/components/layout/hidden-sidebar.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { useState } from "react"; +import { GuildSelector } from "@/components/shared/guild-selector"; + +interface HiddenSidebarProps { + guildId: string; + onGuildChange: (guildId: string | null) => void; +} + +export function HiddenSidebar({ guildId, onGuildChange }: HiddenSidebarProps) { + const [visible, setVisible] = useState(false); + let hideTimer: ReturnType | null = null; + + const handleMouseEnter = () => { + if (hideTimer) clearTimeout(hideTimer); + setVisible(true); + }; + + const handleMouseLeave = () => { + hideTimer = setTimeout(() => setVisible(false), 300); + }; + + return ( + <> + {/* Hotspot trigger */} +
+ + {/* Sidebar */} +
+
+ + Guilds + +
+
+ +
+
+ + ); +} diff --git a/services/frontend/src/components/layout/mobile-nav.tsx b/services/frontend/src/components/layout/mobile-nav.tsx index f2aef98..045b394 100644 --- a/services/frontend/src/components/layout/mobile-nav.tsx +++ b/services/frontend/src/components/layout/mobile-nav.tsx @@ -2,16 +2,15 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; - -import { isActivePath, mobileNavItems } from "@/lib/navigation"; +import { mobileNavItems, isActivePath } from "@/lib/navigation"; import { cn } from "@/lib/utils"; export function MobileNav() { const pathname = usePathname(); return ( -