feat: design tokens, globals CSS, fonts, navigation config
- 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) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
59bce79bcd
commit
3ae0c96a13
@@ -1,83 +1,70 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { BarChart3, Hash, Users } from "lucide-react";
|
import { AlertCircle, Clock, Hash, Shield, Sparkles, Users } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import {
|
import { useStats } from "@/hooks";
|
||||||
ChannelDetailSection,
|
import { StatCard } from "@/components/dashboard/stat-card";
|
||||||
ChannelsSection,
|
import { LiveStream } from "@/components/dashboard/live-stream";
|
||||||
StatsSection,
|
import { ModQueue } from "@/components/dashboard/mod-queue";
|
||||||
UserDetailSection,
|
import { MessageTrendChart } from "@/components/dashboard/message-trend-chart";
|
||||||
UsersSection,
|
import { ActivityHeatmap } from "@/components/dashboard/activity-heatmap";
|
||||||
} from "@/components/dashboard";
|
import { TopChannelsChart } from "@/components/dashboard/top-channels-chart";
|
||||||
import { GuildSelector } from "@/components/shared/guild-selector";
|
import { SubNav } from "@/components/layout/sub-nav";
|
||||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { ErrorState, LoadingSkeleton } from "@/components/shared";
|
||||||
|
|
||||||
type View = "stats" | "users" | "channels" | "user-detail" | "channel-detail";
|
type DashboardTab = "stats" | "live" | "activity";
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const [view, setView] = useState<View>("stats");
|
const [tab, setTab] = useState<DashboardTab>("stats");
|
||||||
const [guildId, setGuildId] = useState("");
|
const { data: stats, isLoading, error, refetch } = useStats();
|
||||||
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
|
|
||||||
const [selectedChannelId, setSelectedChannelId] = useState<string | null>(
|
const subNavTabs = [
|
||||||
null,
|
{ id: "stats", label: "Stats", icon: <Hash className="size-3" /> },
|
||||||
);
|
{ id: "live", label: "Live", icon: <Sparkles className="size-3" /> },
|
||||||
|
{ id: "activity", label: "Activity", icon: <Clock className="size-3" /> },
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-4 animate-fade-in-up">
|
||||||
<GuildSelector value={guildId} onChange={setGuildId} />
|
<SubNav tabs={subNavTabs} activeTab={tab} onTabChange={(t) => setTab(t as DashboardTab)} />
|
||||||
|
|
||||||
<Tabs
|
{tab === "stats" && (
|
||||||
value={
|
<div className="space-y-4">
|
||||||
view === "user-detail"
|
{error ? (
|
||||||
? "users"
|
<ErrorState message={error.message} onRetry={refetch} />
|
||||||
: view === "channel-detail"
|
) : isLoading || !stats ? (
|
||||||
? "channels"
|
<LoadingSkeleton count={6} height="h-28" columns={3} />
|
||||||
: view
|
) : (
|
||||||
}
|
<>
|
||||||
onValueChange={(v) => setView(v as View)}
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3">
|
||||||
>
|
<StatCard label="Total Messages" value={stats.total_messages} icon={Hash} />
|
||||||
<TabsList>
|
<StatCard label="Today" value={stats.today_messages} icon={Clock} />
|
||||||
<TabsTrigger value="stats" onClick={() => setView("stats")}>
|
<StatCard label="Users" value={stats.total_users} icon={Users} />
|
||||||
<BarChart3 className="size-4" /> Stats
|
<StatCard label="Active 24h" value={stats.active_users_24h} icon={Sparkles} />
|
||||||
</TabsTrigger>
|
<StatCard label="Flagged" value={stats.total_flagged} icon={AlertCircle} variant="danger" />
|
||||||
<TabsTrigger value="users" onClick={() => setView("users")}>
|
<StatCard label="Clean" value={stats.total_clean} icon={Shield} variant="success" />
|
||||||
<Users className="size-4" /> Users
|
</div>
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="channels" onClick={() => setView("channels")}>
|
|
||||||
<Hash className="size-4" /> Channels
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
</Tabs>
|
|
||||||
|
|
||||||
{view === "stats" && <StatsSection />}
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
{view === "users" && (
|
<MessageTrendChart />
|
||||||
<UsersSection
|
<TopChannelsChart />
|
||||||
onSelect={(userId) => {
|
</div>
|
||||||
setSelectedUserId(userId);
|
</>
|
||||||
setView("user-detail");
|
)}
|
||||||
}}
|
</div>
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
{view === "user-detail" && selectedUserId && (
|
|
||||||
<UserDetailSection
|
{tab === "live" && (
|
||||||
userId={selectedUserId}
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
onBack={() => setView("users")}
|
<LiveStream />
|
||||||
/>
|
<ModQueue />
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{view === "channels" && (
|
|
||||||
<ChannelsSection
|
{tab === "activity" && (
|
||||||
guildId={guildId}
|
<div className="grid grid-cols-1 gap-4">
|
||||||
onSelect={(chId) => {
|
<ActivityHeatmap />
|
||||||
setSelectedChannelId(chId);
|
</div>
|
||||||
setView("channel-detail");
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{view === "channel-detail" && selectedChannelId && (
|
|
||||||
<ChannelDetailSection
|
|
||||||
channelId={selectedChannelId}
|
|
||||||
onBack={() => setView("channels")}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,50 +2,89 @@
|
|||||||
|
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
|
import { TopNav } from "@/components/layout/top-nav";
|
||||||
import { Chatbot } from "@/components/chatbot/chatbot";
|
|
||||||
import { AppHeader } from "@/components/layout/app-header";
|
|
||||||
import { AppSidebar } from "@/components/layout/app-sidebar";
|
|
||||||
import { MobileNav } from "@/components/layout/mobile-nav";
|
import { MobileNav } from "@/components/layout/mobile-nav";
|
||||||
import { WsProvider } from "@/lib/ws/context";
|
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({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: {
|
queries: {
|
||||||
staleTime: 15_000,
|
staleTime: 10_000,
|
||||||
retry: 1,
|
retry: 1,
|
||||||
refetchOnWindowFocus: false,
|
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({
|
export default function DashboardLayout({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
|
const [guildId, setGuildId] = useState("");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<WsProvider>
|
<WsProvider>
|
||||||
<div className="flex h-screen overflow-hidden bg-background">
|
<MediaPlayerProvider>
|
||||||
<AppSidebar />
|
<MascotProvider>
|
||||||
<div className="flex flex-1 flex-col min-w-0">
|
<div className="min-h-screen bg-canvas">
|
||||||
<AppHeader />
|
<TopNav />
|
||||||
<main className="flex-1 overflow-y-auto p-4 md:p-6 pb-20 md:pb-6">
|
<HiddenSidebar guildId={guildId} onGuildChange={(g) => setGuildId(g ?? "")} />
|
||||||
<Suspense
|
<MascotExpressionSync />
|
||||||
fallback={
|
|
||||||
<div className="flex h-full items-center justify-center">
|
{/* Sub-nav space — filled per-page */}
|
||||||
<div className="size-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
<div className="pt-11">
|
||||||
</div>
|
<main className="p-4 md:p-6 pb-24 md:pb-6 max-w-[1600px] mx-auto">
|
||||||
}
|
<Suspense
|
||||||
>
|
fallback={
|
||||||
{children}
|
<div className="flex h-[60vh] items-center justify-center">
|
||||||
</Suspense>
|
<div className="size-8 rounded-full border-2 border-primary border-t-transparent animate-spin" />
|
||||||
</main>
|
</div>
|
||||||
</div>
|
}
|
||||||
<MobileNav />
|
>
|
||||||
</div>
|
{children}
|
||||||
<Chatbot />
|
</Suspense>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<MobileNav />
|
||||||
|
<MiniPlayer />
|
||||||
|
<MascotContainer />
|
||||||
|
</div>
|
||||||
|
</MascotProvider>
|
||||||
|
</MediaPlayerProvider>
|
||||||
</WsProvider>
|
</WsProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,23 +1,16 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { Flag, Loader2, MessageSquare, RefreshCw, Search } from "lucide-react";
|
import { useSearchParams, useRouter } from "next/navigation";
|
||||||
import { useCallback, useState } from "react";
|
import { Search, Flag, Image, Loader2, RefreshCw } from "lucide-react";
|
||||||
import { ImagesGrid } from "@/components/messages/images-grid";
|
import { MessageList } from "@/components/messages/message-list";
|
||||||
import { MessageCard } from "@/components/messages/message-card";
|
import { MessageDetail } from "@/components/messages/message-detail";
|
||||||
import { MessageDetailView } from "@/components/messages/message-detail-view";
|
import { SearchOverlay } from "@/components/messages/search-overlay";
|
||||||
import { ReviewList } from "@/components/messages/review-list";
|
import { SubNav } from "@/components/layout/sub-nav";
|
||||||
import { ErrorState, LoadingSkeleton } from "@/components/shared";
|
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 { 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 {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -25,8 +18,8 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
||||||
import {
|
import {
|
||||||
|
useGuilds,
|
||||||
useImages,
|
useImages,
|
||||||
useLoadMore,
|
useLoadMore,
|
||||||
useMessageDetail,
|
useMessageDetail,
|
||||||
@@ -38,62 +31,60 @@ import {
|
|||||||
useReview,
|
useReview,
|
||||||
useTextChannels,
|
useTextChannels,
|
||||||
} from "@/hooks";
|
} from "@/hooks";
|
||||||
import { messagesApi } from "@/lib/api";
|
|
||||||
import type { MessageRecord } from "@/lib/types";
|
|
||||||
import { useWebSocket } from "@/lib/ws/context";
|
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() {
|
export default function MessagesPage() {
|
||||||
const [guildId, setGuildId] = useState("");
|
const router = useRouter();
|
||||||
const [selectedChannel, setSelectedChannel] = useState("");
|
const searchParams = useSearchParams();
|
||||||
const [viewTab, setViewTab] = useState<"all" | "images" | "review">("all");
|
const [guildId, setGuildId] = useState(searchParams.get("guild") || "");
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [selectedChannel, setSelectedChannel] = useState(searchParams.get("channel") || "");
|
||||||
const [detailId, setDetailId] = useState<string | null>(null);
|
const [detailId, setDetailId] = useState<string | null>(searchParams.get("selected"));
|
||||||
|
const [tab, setTab] = useState<MessagesTab>((searchParams.get("tab") as MessagesTab) || "all");
|
||||||
|
const [searchOpen, setSearchOpen] = useState(false);
|
||||||
|
|
||||||
const ws = useWebSocket();
|
const ws = useWebSocket();
|
||||||
const { data: channels = [] } = useTextChannels(guildId);
|
const { data: channels = [] } = useTextChannels(guildId);
|
||||||
const {
|
const { data: messages, isLoading, error, refetch } = useMessages(guildId, selectedChannel || undefined);
|
||||||
data: messages,
|
const { data: cursorData } = useMessagesHasMore(guildId, selectedChannel || undefined);
|
||||||
isLoading,
|
|
||||||
error,
|
|
||||||
refetch,
|
|
||||||
} = useMessages(guildId, selectedChannel || undefined);
|
|
||||||
const { data: cursorData } = useMessagesHasMore(
|
|
||||||
guildId,
|
|
||||||
selectedChannel || undefined,
|
|
||||||
);
|
|
||||||
const loadMoreMut = useLoadMore();
|
const loadMoreMut = useLoadMore();
|
||||||
const { data: images } = useImages(guildId);
|
const { data: images } = useImages(guildId);
|
||||||
const { data: reviews } = useReview(selectedChannel || undefined);
|
const { data: reviews } = useReview(selectedChannel || undefined);
|
||||||
const reanalyzeMut = useReanalyze();
|
const reanalyzeMut = useReanalyze();
|
||||||
const reanalyzeBatchMut = useReanalyzeBatch();
|
const reanalyzeBatchMut = useReanalyzeBatch();
|
||||||
|
|
||||||
// Sync WS events into the TanStack Query cache
|
|
||||||
useMessagesWsSync(ws, guildId);
|
|
||||||
|
|
||||||
// Detail dialog
|
|
||||||
const {
|
const {
|
||||||
message: detailMessage,
|
message: detailMessage,
|
||||||
attachments: detailAttachments,
|
attachments: detailAttachments,
|
||||||
loading: detailLoading,
|
loading: detailLoading,
|
||||||
} = useMessageDetail(detailId);
|
} = useMessageDetail(detailId);
|
||||||
|
|
||||||
// Search query (manual trigger)
|
useMessagesWsSync(ws, guildId);
|
||||||
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,
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSearch = useCallback(() => {
|
// Sync to URL
|
||||||
if (!searchQuery.trim()) return;
|
useEffect(() => {
|
||||||
setSearchEnabled(true);
|
const params = new URLSearchParams();
|
||||||
}, [searchQuery]);
|
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(() => {
|
const handleLoadMore = useCallback(() => {
|
||||||
if (!cursorData?.cursor || loadMoreMut.isPending) return;
|
if (!cursorData?.cursor || loadMoreMut.isPending) return;
|
||||||
@@ -104,168 +95,135 @@ export default function MessagesPage() {
|
|||||||
});
|
});
|
||||||
}, [cursorData, loadMoreMut, guildId, selectedChannel]);
|
}, [cursorData, loadMoreMut, guildId, selectedChannel]);
|
||||||
|
|
||||||
const displayMessages = searchResults ?? messages ?? [];
|
const subNavTabs = [
|
||||||
const hasMore = cursorData?.hasMore ?? false;
|
{ id: "all", label: "All" },
|
||||||
const isEmpty = !isLoading && displayMessages.length === 0;
|
{ id: "images", label: "Images", icon: <Image className="size-3" /> },
|
||||||
|
{ id: "review", label: "Review", icon: <Flag className="size-3" /> },
|
||||||
|
];
|
||||||
|
|
||||||
if (error) {
|
const currentMessages = messages ?? [];
|
||||||
return (
|
|
||||||
<div className="space-y-5">
|
|
||||||
<GuildSelector value={guildId} onChange={setGuildId} />
|
|
||||||
<ErrorState message={error.message} onRetry={refetch} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="animate-fade-in-up space-y-4">
|
||||||
<GuildSelector value={guildId} onChange={setGuildId} />
|
{/* Controls bar */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
<GuildSelector value={guildId} onChange={(g) => { setGuildId(g ?? ""); setSelectedChannel(""); }} />
|
||||||
<div className="relative flex-1">
|
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
|
|
||||||
<Input
|
|
||||||
placeholder="Search messages…"
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
|
||||||
className="pl-9 h-9"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{channels.length > 0 && (
|
{channels.length > 0 && (
|
||||||
<Select
|
<Select value={selectedChannel} onValueChange={(v) => setSelectedChannel(v ?? "")}>
|
||||||
value={selectedChannel}
|
<SelectTrigger className="h-8 w-40 glass border-glass-border text-xs">
|
||||||
onValueChange={(v) => setSelectedChannel(v ?? "")}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-9 w-full sm:w-44">
|
|
||||||
<SelectValue placeholder="All channels" />
|
<SelectValue placeholder="All channels" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="">All channels</SelectItem>
|
<SelectItem value="">All channels</SelectItem>
|
||||||
{channels.map((ch) => (
|
{channels.map((ch: any) => (
|
||||||
<SelectItem key={ch.id} value={ch.id}>
|
<SelectItem key={ch.id} value={ch.id}># {ch.name}</SelectItem>
|
||||||
# {ch.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
)}
|
)}
|
||||||
<Button
|
<button
|
||||||
variant="outline"
|
type="button"
|
||||||
size="sm"
|
onClick={() => setSearchOpen(true)}
|
||||||
onClick={() => reanalyzeBatchMut.mutate(guildId)}
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs text-text-secondary/60 hover:text-text-primary glass hover:glass-elevated transition-all ml-auto"
|
||||||
>
|
>
|
||||||
<RefreshCw className="size-4 mr-1.5" />
|
<Search className="size-3.5" />
|
||||||
Reanalyze Errors
|
Search
|
||||||
|
<span className="text-[10px] text-text-secondary/30 font-mono hidden sm:inline">⌘K</span>
|
||||||
|
</button>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => reanalyzeBatchMut.mutate(guildId)} className="h-8 text-xs">
|
||||||
|
<RefreshCw className="size-3 mr-1" /> Reanalyze
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Tabs
|
<SubNav tabs={subNavTabs} activeTab={tab} onTabChange={(t) => setTab(t as MessagesTab)} />
|
||||||
value={viewTab}
|
|
||||||
onValueChange={(v) => setViewTab(v as typeof viewTab)}
|
|
||||||
>
|
|
||||||
<TabsList>
|
|
||||||
<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 ?? 0})
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
</Tabs>
|
|
||||||
|
|
||||||
{searchResults && (
|
{/* Split pane */}
|
||||||
<p className="text-sm text-muted-foreground animate-fade-in-up">
|
{error ? (
|
||||||
Found {searchResults.length} result
|
<ErrorState message={error.message} onRetry={refetch} />
|
||||||
{searchResults.length !== 1 ? "s" : ""}
|
) : isLoading ? (
|
||||||
</p>
|
<LoadingSkeleton count={6} height="h-20" />
|
||||||
)}
|
) : (
|
||||||
|
<div className="flex gap-4">
|
||||||
|
{/* Left pane — message list */}
|
||||||
|
<div className={cn("space-y-2", detailId ? "w-1/2 lg:w-2/5" : "w-full")}>
|
||||||
|
{tab === "all" && (
|
||||||
|
<>
|
||||||
|
<MessageList messages={currentMessages} selectedId={detailId} onSelect={setDetailId} />
|
||||||
|
{cursorData?.hasMore && (
|
||||||
|
<div className="flex justify-center py-4">
|
||||||
|
<Button variant="outline" size="sm" onClick={handleLoadMore} disabled={loadMoreMut.isPending} className="text-xs glass">
|
||||||
|
{loadMoreMut.isPending && <Loader2 className="size-3 animate-spin mr-1" />}
|
||||||
|
Load more
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{tab === "images" && (
|
||||||
|
<ImageGrid items={images ?? []} onSelect={setDetailId} />
|
||||||
|
)}
|
||||||
|
{tab === "review" && (
|
||||||
|
<ReviewList items={reviews ?? []} onSelect={setDetailId} onReanalyze={(id: string) => reanalyzeMut.mutate(id)} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* ── ALL tab ── */}
|
{/* Right pane — detail */}
|
||||||
{viewTab === "all" && (
|
{detailId && (
|
||||||
<div className="space-y-2 animate-fade-in-up">
|
<div className="hidden md:block w-1/2 lg:w-3/5 sticky top-16 self-start">
|
||||||
{isLoading ? (
|
{detailLoading ? (
|
||||||
<LoadingSkeleton count={8} height="h-28" />
|
<GlassPanel dense className="flex items-center justify-center py-12">
|
||||||
) : isEmpty ? (
|
<Loader2 className="size-5 animate-spin text-text-secondary/60" />
|
||||||
<div className="flex flex-col items-center justify-center py-20 text-center">
|
</GlassPanel>
|
||||||
<Search className="size-10 text-muted-foreground/40 mb-3" />
|
) : detailMessage ? (
|
||||||
<p className="text-sm text-muted-foreground">
|
<MessageDetail
|
||||||
{searchResults
|
message={detailMessage}
|
||||||
? "No messages found matching your search."
|
attachments={detailAttachments}
|
||||||
: "No captures yet."}
|
onBack={() => setDetailId(null)}
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{displayMessages.map((msg) => (
|
|
||||||
<MessageCard
|
|
||||||
key={msg.id}
|
|
||||||
message={msg}
|
|
||||||
onClick={setDetailId}
|
|
||||||
onReanalyze={(id) => reanalyzeMut.mutate(id)}
|
|
||||||
/>
|
/>
|
||||||
))}
|
) : null}
|
||||||
{hasMore && (
|
</div>
|
||||||
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── IMAGES tab ── */}
|
{/* Search overlay */}
|
||||||
{viewTab === "images" && (
|
<SearchOverlay open={searchOpen} onClose={() => setSearchOpen(false)} onSelect={setDetailId} />
|
||||||
<ImagesGrid images={images ?? []} onSelect={setDetailId} />
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inline ImageGrid and ReviewList
|
||||||
|
function ImageGrid({ items, onSelect }: { items: any[]; onSelect: (id: string) => void }) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{items.map((item: any) => (
|
||||||
|
<button key={item.id} type="button" onClick={() => onSelect(item.message_id)} className="glass rounded-lg overflow-hidden hover:scale-[1.02] transition-transform">
|
||||||
|
<img src={item.uploaded_url || item.discord_url} alt="" className="w-full h-24 object-cover" loading="lazy" />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{items.length === 0 && (
|
||||||
|
<div className="col-span-3 py-12 text-center text-xs text-text-secondary/40">No images</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
{/* ── REVIEW tab ── */}
|
function ReviewList({ items, onSelect, onReanalyze }: { items: any[]; onSelect: (id: string) => void; onReanalyze: (id: string) => void }) {
|
||||||
{viewTab === "review" && (
|
return (
|
||||||
<ReviewList
|
<div className="space-y-2">
|
||||||
reviews={reviews ?? []}
|
{items.map((item: any) => (
|
||||||
onSelect={setDetailId}
|
<GlassCard key={item.id} variant="danger" className="p-3 cursor-pointer" onClick={() => onSelect(item.message_id)}>
|
||||||
onReanalyze={(id) => reanalyzeMut.mutate(id)}
|
<div className="flex items-start gap-2">
|
||||||
/>
|
<Flag className="size-3.5 text-accent-purple mt-0.5 shrink-0" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-xs text-text-secondary line-clamp-2">{item.content || item.id}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</GlassCard>
|
||||||
|
))}
|
||||||
|
{items.length === 0 && (
|
||||||
|
<div className="py-12 text-center text-xs text-text-secondary/40">No flagged messages</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Detail dialog */}
|
|
||||||
<Dialog
|
|
||||||
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
|
|
||||||
</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
<ScrollArea className="max-h-[70vh] pr-1">
|
|
||||||
{detailLoading ? (
|
|
||||||
<div className="flex justify-center py-12">
|
|
||||||
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
) : detailMessage ? (
|
|
||||||
<MessageDetailView
|
|
||||||
message={detailMessage}
|
|
||||||
attachments={detailAttachments}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</ScrollArea>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,64 @@
|
|||||||
"use client";
|
"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";
|
import { useWebSocket } from "@/lib/ws/context";
|
||||||
|
|
||||||
|
type RecordingsTab = "library" | "stats";
|
||||||
|
|
||||||
export default function RecordingsPage() {
|
export default function RecordingsPage() {
|
||||||
const ws = useWebSocket();
|
const ws = useWebSocket();
|
||||||
|
const { data: recordings, isLoading, error, refetch } = useRecordings();
|
||||||
|
const [playingId, setPlayingId] = useState<string | null>(null);
|
||||||
|
const [tab, setTab] = useState<RecordingsTab>("library");
|
||||||
|
|
||||||
|
const currentTrack = playingId && recordings
|
||||||
|
? recordings.find((r: any) => r.id === playingId)
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5 animate-fade-in-up">
|
<div className="space-y-4 animate-fade-in-up">
|
||||||
<RecordingList ws={ws} />
|
<SubNav
|
||||||
|
tabs={[
|
||||||
|
{ id: "library", label: "Library" },
|
||||||
|
{ id: "stats", label: "Stats" },
|
||||||
|
]}
|
||||||
|
activeTab={tab}
|
||||||
|
onTabChange={(t) => setTab(t as RecordingsTab)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{tab === "library" && (
|
||||||
|
<>
|
||||||
|
{error ? (
|
||||||
|
<ErrorState message={error.message} onRetry={refetch} />
|
||||||
|
) : isLoading ? (
|
||||||
|
<LoadingSkeleton count={4} height="h-28" />
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{(recordings ?? []).map((rec: any) => (
|
||||||
|
<RecordingCard
|
||||||
|
key={rec.id}
|
||||||
|
recording={rec}
|
||||||
|
onPlay={(id) => setPlayingId(id === playingId ? null : id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{(recordings ?? []).length === 0 && (
|
||||||
|
<div className="py-12 text-center text-sm text-text-secondary/40">No recordings yet</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === "stats" && (
|
||||||
|
<div className="py-12 text-center text-sm text-text-secondary/40">Recording stats coming soon</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<RecordingPlayer url={currentTrack?.download_url} onClose={() => setPlayingId(null)} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,18 +2,21 @@
|
|||||||
|
|
||||||
import { Moon, Server, Shield, Sun, Wifi } from "lucide-react";
|
import { Moon, Server, Shield, Sun, Wifi } from "lucide-react";
|
||||||
import { useEffect, useState } from "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 { 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 { useConfig } from "@/hooks";
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { useWebSocket } from "@/lib/ws/context";
|
import { useWebSocket } from "@/lib/ws/context";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type SettingsTab = "connection" | "appearance" | "config" | "about";
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const { status } = useWebSocket();
|
const { status } = useWebSocket();
|
||||||
const { data: config, isLoading: configLoading } = useConfig();
|
const { data: config, isLoading: configLoading } = useConfig();
|
||||||
const [theme, setTheme] = useState<"light" | "dark">("dark");
|
const [theme, setTheme] = useState<"light" | "dark">("dark");
|
||||||
|
const [tab, setTab] = useState<SettingsTab>("connection");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const stored = localStorage.getItem("theme") as "light" | "dark" | null;
|
const stored = localStorage.getItem("theme") as "light" | "dark" | null;
|
||||||
@@ -28,147 +31,111 @@ export default function SettingsPage() {
|
|||||||
document.documentElement.classList.add(next);
|
document.documentElement.classList.add(next);
|
||||||
};
|
};
|
||||||
|
|
||||||
const statusCfg = {
|
const statusDot = {
|
||||||
connected: {
|
connected: "bg-emerald-500 shadow-[0_0_8px] shadow-emerald-500/60 animate-pulse",
|
||||||
label: "Connected",
|
connecting: "bg-accent-amber animate-pulse",
|
||||||
variant: "default" as const,
|
disconnected: "bg-destructive",
|
||||||
dot: "bg-green-500 shadow-[0_0_6px] shadow-green-500/60",
|
error: "bg-destructive",
|
||||||
},
|
}[status];
|
||||||
connecting: {
|
|
||||||
label: "Connecting",
|
const statusLabel = {
|
||||||
variant: "secondary" as const,
|
connected: "Connected",
|
||||||
dot: "bg-yellow-500 animate-pulse",
|
connecting: "Connecting",
|
||||||
},
|
disconnected: "Disconnected",
|
||||||
disconnected: {
|
error: "Error",
|
||||||
label: "Disconnected",
|
|
||||||
variant: "destructive" as const,
|
|
||||||
dot: "bg-destructive",
|
|
||||||
},
|
|
||||||
error: {
|
|
||||||
label: "Error",
|
|
||||||
variant: "destructive" as const,
|
|
||||||
dot: "bg-destructive",
|
|
||||||
},
|
|
||||||
}[status];
|
}[status];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5 animate-fade-in-up max-w-2xl">
|
<div className="space-y-4 animate-fade-in-up max-w-2xl">
|
||||||
<Card>
|
<SubNav
|
||||||
<CardHeader>
|
tabs={[
|
||||||
<CardTitle className="flex items-center gap-2 text-sm font-semibold">
|
{ id: "connection", label: "Connection", icon: <Wifi className="size-3" /> },
|
||||||
<Wifi className="size-4 text-primary" /> Connection
|
{ id: "appearance", label: "Appearance", icon: <Sun className="size-3" /> },
|
||||||
</CardTitle>
|
{ id: "config", label: "Config", icon: <Server className="size-3" /> },
|
||||||
</CardHeader>
|
{ id: "about", label: "About", icon: <Shield className="size-3" /> },
|
||||||
<CardContent className="space-y-3">
|
]}
|
||||||
|
activeTab={tab}
|
||||||
|
onTabChange={(t) => setTab(t as SettingsTab)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{tab === "connection" && (
|
||||||
|
<GlassCard variant="base">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm">WebSocket</span>
|
<div>
|
||||||
<Badge variant={statusCfg.variant} className="gap-1.5 px-2.5 py-1">
|
<span className="text-sm font-semibold text-text-primary">WebSocket</span>
|
||||||
<span className={cn("size-1.5 rounded-full", statusCfg.dot)} />
|
</div>
|
||||||
{statusCfg.label}
|
<div className="flex items-center gap-2">
|
||||||
</Badge>
|
<span className={cn("size-2 rounded-full", statusDot)} />
|
||||||
</div>
|
<span className="text-xs font-mono text-text-secondary">{statusLabel}</span>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2 text-sm font-semibold">
|
|
||||||
{theme === "dark" ? (
|
|
||||||
<Moon className="size-4 text-primary" />
|
|
||||||
) : (
|
|
||||||
<Sun className="size-4 text-primary" />
|
|
||||||
)}{" "}
|
|
||||||
Appearance
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={toggleTheme}
|
|
||||||
className="flex items-center justify-between w-full text-sm cursor-pointer"
|
|
||||||
>
|
|
||||||
<span>Theme</span>
|
|
||||||
<Badge variant="outline" className="capitalize">
|
|
||||||
{theme}
|
|
||||||
</Badge>
|
|
||||||
</button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2 text-sm font-semibold">
|
|
||||||
<Server className="size-4 text-primary" /> Server Configuration
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{configLoading ? (
|
|
||||||
<LoadingSkeleton count={6} height="h-6" />
|
|
||||||
) : config ? (
|
|
||||||
<div className="space-y-2 text-sm">
|
|
||||||
<CfgRow
|
|
||||||
label="Monitor Guild"
|
|
||||||
value={config.monitorGuildId ?? "Not configured"}
|
|
||||||
/>
|
|
||||||
<Separator />
|
|
||||||
<CfgRow
|
|
||||||
label="Voice Guild"
|
|
||||||
value={config.voiceGuildId ?? "Not configured"}
|
|
||||||
/>
|
|
||||||
<Separator />
|
|
||||||
<CfgRow
|
|
||||||
label="Voice Channel"
|
|
||||||
value={config.voiceChannelId ?? "Not configured"}
|
|
||||||
/>
|
|
||||||
<Separator />
|
|
||||||
<CfgRow
|
|
||||||
label="AI Analysis"
|
|
||||||
value={config.aiAnalysisEnabled ? "Enabled" : "Disabled"}
|
|
||||||
/>
|
|
||||||
<Separator />
|
|
||||||
<CfgRow
|
|
||||||
label="Auto-Delete Flagged"
|
|
||||||
value={config.autoDeleteFlaggedEnabled ? "Enabled" : "Disabled"}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Unable to load configuration.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2 text-sm font-semibold">
|
|
||||||
<Shield className="size-4 text-primary" /> About
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-sm space-y-1">
|
|
||||||
<p>
|
|
||||||
<span className="text-gradient font-bold">Discord Automod</span> —
|
|
||||||
Discord Moderation Watcher
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
AI-powered message moderation, voice recording, and real-time
|
|
||||||
monitoring for Discord communities.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</GlassCard>
|
||||||
</Card>
|
)}
|
||||||
|
|
||||||
|
{tab === "appearance" && (
|
||||||
|
<GlassCard variant="base">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{theme === "dark" ? <Moon className="size-4 text-primary" /> : <Sun className="size-4 text-primary" />}
|
||||||
|
<span className="text-sm font-semibold text-text-primary">Theme</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggleTheme}
|
||||||
|
className="flex items-center gap-2 px-3 py-1.5 rounded-md glass hover:glass-elevated transition-all text-xs"
|
||||||
|
>
|
||||||
|
<span className="font-mono">{theme}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</GlassCard>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === "config" && (
|
||||||
|
<GlassCard variant="base">
|
||||||
|
<div className="space-y-3">
|
||||||
|
{configLoading ? (
|
||||||
|
<LoadingSkeleton count={6} height="h-6" />
|
||||||
|
) : config ? (
|
||||||
|
<>
|
||||||
|
<ConfigRow label="Monitor Guild" value={config.monitorGuildId || "Not configured"} />
|
||||||
|
<GlassDivider />
|
||||||
|
<ConfigRow label="Voice Guild" value={config.voiceGuildId || "Not configured"} />
|
||||||
|
<GlassDivider />
|
||||||
|
<ConfigRow label="Voice Channel" value={config.voiceChannelId || "Not configured"} />
|
||||||
|
<GlassDivider />
|
||||||
|
<ConfigRow label="AI Analysis" value={config.aiAnalysisEnabled ? "Enabled" : "Disabled"} />
|
||||||
|
<GlassDivider />
|
||||||
|
<ConfigRow label="Auto-Delete Flagged" value={config.autoDeleteFlaggedEnabled ? "Enabled" : "Disabled"} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-text-secondary/60">Unable to load config.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</GlassCard>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === "about" && (
|
||||||
|
<GlassCard variant="base">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h2 className="text-base font-bold text-primary">Discord Automod</h2>
|
||||||
|
<p className="text-xs text-text-secondary/80 leading-relaxed">
|
||||||
|
AI-powered message moderation, voice recording, and real-time monitoring for Discord communities.
|
||||||
|
</p>
|
||||||
|
<div className="text-[10px] font-mono text-text-secondary/40 mt-4">
|
||||||
|
v0.1.0
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</GlassCard>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CfgRow({ label, value }: { label: string; value: string }) {
|
function ConfigRow({ label, value }: { label: string; value: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between py-1">
|
<div className="flex items-center justify-between py-0.5">
|
||||||
<span className="text-muted-foreground">{label}</span>
|
<span className="text-xs text-text-secondary">{label}</span>
|
||||||
<span className="font-mono text-xs max-w-[280px] truncate text-right">
|
<span className="text-[11px] font-mono text-text-primary/80 max-w-[240px] truncate text-right">{value}</span>
|
||||||
{value}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,15 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { VoiceConnectionCard } from "@/components/voice/connection-card";
|
||||||
import { ActiveSpeakersPanel } from "@/components/voice/active-speakers-panel";
|
import { SpeakerWaveform } from "@/components/voice/speaker-waveform";
|
||||||
import { MicrophoneCard } from "@/components/voice/microphone-card";
|
import { MicControl } from "@/components/voice/mic-control";
|
||||||
import { VoiceConnectionCard } from "@/components/voice/voice-connection-card";
|
import { VoiceActivityTimeline } from "@/components/voice/activity-timeline";
|
||||||
import {
|
import { SubNav } from "@/components/layout/sub-nav";
|
||||||
useGuilds,
|
|
||||||
useMicTransmit,
|
|
||||||
useSpeakers,
|
|
||||||
useVoiceChannels,
|
|
||||||
useVoiceConnect,
|
|
||||||
useVoiceDisconnect,
|
|
||||||
useVoiceStatus,
|
|
||||||
} from "@/hooks";
|
|
||||||
import { useWebSocket } from "@/lib/ws/context";
|
import { useWebSocket } from "@/lib/ws/context";
|
||||||
|
import { useGuilds, useMicTransmit, useSpeakers, useVoiceChannels, useVoiceConnect, useVoiceDisconnect, useVoiceStatus } from "@/hooks";
|
||||||
|
|
||||||
|
type VoiceTab = "connection" | "activity";
|
||||||
|
|
||||||
export default function VoicePage() {
|
export default function VoicePage() {
|
||||||
const ws = useWebSocket();
|
const ws = useWebSocket();
|
||||||
@@ -28,21 +23,14 @@ export default function VoicePage() {
|
|||||||
const micMut = useMicTransmit();
|
const micMut = useMicTransmit();
|
||||||
const [selectedChannel, setSelectedChannel] = useState("");
|
const [selectedChannel, setSelectedChannel] = useState("");
|
||||||
const [micActive, setMicActive] = useState(false);
|
const [micActive, setMicActive] = useState(false);
|
||||||
|
const [volume, setVolume] = useState(75);
|
||||||
|
const [tab, setTab] = useState<VoiceTab>("connection");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsub = subscribe(ws);
|
const unsub = subscribe(ws);
|
||||||
return () => unsub();
|
return () => unsub();
|
||||||
}, [ws, subscribe]);
|
}, [ws, subscribe]);
|
||||||
|
|
||||||
const handleGuildChange = useCallback((guildId: string | null) => {
|
|
||||||
if (!guildId) {
|
|
||||||
setSelectedGuild("");
|
|
||||||
setSelectedChannel("");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSelectedGuild(guildId);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleMicToggle = useCallback(
|
const handleMicToggle = useCallback(
|
||||||
async (checked: boolean) => {
|
async (checked: boolean) => {
|
||||||
setMicActive(checked);
|
setMicActive(checked);
|
||||||
@@ -59,25 +47,44 @@ export default function VoicePage() {
|
|||||||
const connected = voiceStatus?.connected ?? false;
|
const connected = voiceStatus?.connected ?? false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5 animate-fade-in-up">
|
<div className="space-y-4 animate-fade-in-up">
|
||||||
|
<SubNav
|
||||||
|
tabs={[
|
||||||
|
{ id: "connection", label: "Connection" },
|
||||||
|
{ id: "activity", label: "Activity" },
|
||||||
|
]}
|
||||||
|
activeTab={tab}
|
||||||
|
onTabChange={(t) => setTab(t as VoiceTab)}
|
||||||
|
/>
|
||||||
|
|
||||||
<VoiceConnectionCard
|
<VoiceConnectionCard
|
||||||
selectedGuild={selectedGuild}
|
|
||||||
onGuildChange={handleGuildChange}
|
|
||||||
selectedChannel={selectedChannel}
|
|
||||||
onChannelChange={(v) => setSelectedChannel(v)}
|
|
||||||
guilds={guilds}
|
|
||||||
voiceChannels={voiceChannels}
|
|
||||||
connected={connected}
|
connected={connected}
|
||||||
activeChannelName={voiceStatus?.activeChannelName}
|
activeChannelName={voiceStatus?.activeChannelName}
|
||||||
connectMut={connectMut}
|
guilds={guilds}
|
||||||
disconnectMut={disconnectMut}
|
voiceChannels={voiceChannels}
|
||||||
/>
|
selectedGuild={selectedGuild}
|
||||||
<ActiveSpeakersPanel activeSpeakers={activeSpeakers} />
|
selectedChannel={selectedChannel}
|
||||||
<MicrophoneCard
|
onGuildChange={(g) => { setSelectedGuild(g ?? ""); setSelectedChannel(""); }}
|
||||||
connected={connected}
|
onChannelChange={(v) => setSelectedChannel(v ?? "")}
|
||||||
micActive={micActive}
|
onConnect={() => connectMut.mutate({ guildId: selectedGuild, channelId: selectedChannel })}
|
||||||
onMicToggle={handleMicToggle}
|
onDisconnect={() => disconnectMut.mutate(undefined)}
|
||||||
|
connecting={connectMut.isPending}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{tab === "connection" && (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
|
<SpeakerWaveform speakers={activeSpeakers} />
|
||||||
|
<MicControl
|
||||||
|
connected={connected}
|
||||||
|
active={micActive}
|
||||||
|
onToggle={handleMicToggle}
|
||||||
|
volume={volume}
|
||||||
|
onVolumeChange={setVolume}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === "activity" && <VoiceActivityTimeline />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,282 +5,106 @@
|
|||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
--color-background: var(--background);
|
/* Canvas — deep navy */
|
||||||
--color-foreground: var(--foreground);
|
--color-canvas: oklch(0.07 0.015 250);
|
||||||
--font-sans: var(--font-sans);
|
--color-surface: oklch(0.11 0.02 245 / 0.6);
|
||||||
--font-mono: var(--font-geist-mono);
|
--color-surface-hover: oklch(0.15 0.02 245 / 0.7);
|
||||||
--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);
|
|
||||||
|
|
||||||
/* Teal-cyan accent gradient for monitoring hub look */
|
/* Glass */
|
||||||
--accent-gradient: linear-gradient(135deg, oklch(0.62 0.17 215), oklch(0.6 0.15 195), oklch(0.65 0.12 185));
|
--color-glass-bg: oklch(1 0 0 / 0.04);
|
||||||
--accent-gradient-subtle: linear-gradient(135deg, oklch(0.62 0.17 215 / 0.15), oklch(0.65 0.12 185 / 0.05));
|
--color-glass-border: oklch(1 0 0 / 0.08);
|
||||||
|
--glass-shadow: 0 8px 32px oklch(0 0 0 / 0.4);
|
||||||
|
|
||||||
/* Glass morphism */
|
/* Primary — teal-cyan */
|
||||||
--glass-bg: oklch(1 0 0 / 0.05);
|
--color-primary: oklch(0.62 0.17 215);
|
||||||
--glass-border: oklch(1 0 0 / 0.1);
|
--color-primary-glow: oklch(0.62 0.17 215 / 0.4);
|
||||||
--glass-shadow: 0 8px 32px oklch(0 0 0 / 0.3);
|
--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 {
|
@layer utilities {
|
||||||
--card: oklch(1 0 0);
|
.glass {
|
||||||
--card-foreground: oklch(0.145 0 0);
|
background: var(--color-glass-bg);
|
||||||
--popover: oklch(1 0 0);
|
backdrop-filter: blur(12px);
|
||||||
--popover-foreground: oklch(0.145 0 0);
|
-webkit-backdrop-filter: blur(12px);
|
||||||
--primary: oklch(0.55 0.18 240);
|
border: 1px solid var(--color-glass-border);
|
||||||
--primary-foreground: oklch(0.985 0 0);
|
box-shadow: var(--glass-shadow);
|
||||||
--secondary: oklch(0.97 0 0);
|
}
|
||||||
--secondary-foreground: oklch(0.205 0 0);
|
.glass-elevated {
|
||||||
--muted: oklch(0.95 0 0);
|
background: var(--color-glass-bg);
|
||||||
--muted-foreground: oklch(0.556 0 0);
|
backdrop-filter: blur(16px);
|
||||||
--accent: oklch(0.65 0.15 220);
|
border: 1px solid var(--color-border-glow);
|
||||||
--accent-foreground: oklch(0.205 0 0);
|
box-shadow: 0 8px 32px oklch(0 0 0 / 0.5), 0 0 20px var(--color-primary-glow);
|
||||||
--destructive: oklch(0.577 0.245 27.325);
|
}
|
||||||
--warning: oklch(0.7 0.18 75);
|
.glass-intense {
|
||||||
--warning-foreground: oklch(0.98 0 0);
|
background: oklch(1 0 0 / 0.08);
|
||||||
--border: oklch(0.922 0 0);
|
backdrop-filter: blur(20px);
|
||||||
--input: oklch(0.922 0 0);
|
border: 1px solid oklch(1 0 0 / 0.12);
|
||||||
--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 base {
|
@layer base {
|
||||||
* {
|
* { @apply border-border; }
|
||||||
@apply border-border outline-ring/50;
|
|
||||||
}
|
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-canvas text-text-primary font-sans antialiased;
|
||||||
background-image: radial-gradient(circle, oklch(1 0 0 / 0.025) 1px, transparent 1px);
|
background-image:
|
||||||
background-size: 24px 24px;
|
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),
|
||||||
html {
|
radial-gradient(ellipse 50% 40% at 80% 80%, oklch(0.65 0.2 280 / 0.04), transparent);
|
||||||
@apply font-sans scroll-smooth;
|
background-size: 24px 24px, 100% 100%, 100% 100%;
|
||||||
}
|
|
||||||
|
|
||||||
/* 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);
|
|
||||||
}
|
}
|
||||||
|
::-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 {
|
@keyframes pulse-ring {
|
||||||
0% {
|
0% { transform: scale(0.8); opacity: 1; }
|
||||||
transform: scale(0.8);
|
100% { transform: scale(2.5); opacity: 0; }
|
||||||
opacity: 1;
|
}
|
||||||
}
|
@keyframes fade-in-up {
|
||||||
100% {
|
from { opacity: 0; transform: translateY(8px); }
|
||||||
transform: scale(2.5);
|
to { opacity: 1; transform: translateY(0); }
|
||||||
opacity: 0;
|
}
|
||||||
}
|
@keyframes shimmer {
|
||||||
|
0% { background-position: -200% 0; }
|
||||||
|
100% { background-position: 200% 0; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.live-pulse-ring {
|
.animate-fade-in-up { animation: fade-in-up 0.3s ease-out forwards; }
|
||||||
animation: pulse-ring 1.5s ease-out infinite;
|
.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; }
|
||||||
|
|||||||
@@ -1,23 +1,22 @@
|
|||||||
import type { Metadata } from "next";
|
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 Script from "next/script";
|
||||||
import { Toaster } from "@/components/ui/sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
const geistSans = Geist({
|
const inter = Inter({
|
||||||
variable: "--font-geist-sans",
|
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
|
variable: "--font-inter",
|
||||||
});
|
});
|
||||||
|
|
||||||
const geistMono = Geist_Mono({
|
const jetbrainsMono = JetBrains_Mono({
|
||||||
variable: "--font-geist-mono",
|
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
|
variable: "--font-jetbrains-mono",
|
||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Discord Automod — Moderation Dashboard",
|
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({
|
export default function RootLayout({
|
||||||
@@ -28,7 +27,7 @@ export default function RootLayout({
|
|||||||
return (
|
return (
|
||||||
<html
|
<html
|
||||||
lang="en"
|
lang="en"
|
||||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
className={`${inter.variable} ${jetbrainsMono.variable} dark h-full antialiased`}
|
||||||
suppressHydrationWarning
|
suppressHydrationWarning
|
||||||
>
|
>
|
||||||
<head>
|
<head>
|
||||||
@@ -37,7 +36,7 @@ export default function RootLayout({
|
|||||||
</Script>
|
</Script>
|
||||||
</head>
|
</head>
|
||||||
<body className="min-h-full flex flex-col">
|
<body className="min-h-full flex flex-col">
|
||||||
<TooltipProvider delay={500}>{children}</TooltipProvider>
|
{children}
|
||||||
<Toaster position="bottom-right" richColors closeButton />
|
<Toaster position="bottom-right" richColors closeButton />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
export default function RootPage() {
|
export default function RootPage() {
|
||||||
redirect("/messages");
|
redirect("/dashboard");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,8 +64,8 @@ export function SearchPanel() {
|
|||||||
</p>
|
</p>
|
||||||
{results.length === 0 ? (
|
{results.length === 0 ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={Search}
|
title="No messages found"
|
||||||
title="No messages found matching your query."
|
description="Try a different search query."
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|||||||
@@ -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<ChatHistoryMessage[]>([]);
|
|
||||||
const [input, setInput] = useState("");
|
|
||||||
const scrollRef = useRef<HTMLDivElement>(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 */}
|
|
||||||
<Button
|
|
||||||
onClick={() => setOpen(!open)}
|
|
||||||
size="icon"
|
|
||||||
aria-label={open ? "Close chat" : "Open chat"}
|
|
||||||
className={cn(
|
|
||||||
"fixed bottom-4 right-4 z-50 size-12 rounded-full shadow-lg transition-all duration-200",
|
|
||||||
open && "scale-90 opacity-80 hover:scale-100 hover:opacity-100",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{open ? <X className="size-5" /> : <MessageCircle className="size-5" />}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{/* Chat panel */}
|
|
||||||
{open && (
|
|
||||||
<Card className="fixed bottom-20 right-4 z-50 w-80 sm:w-96 shadow-xl border-border/50 animate-fade-in-up">
|
|
||||||
<CardHeader className="border-b border-border/50 bg-gradient-to-r from-primary/5 to-primary/[0.02]">
|
|
||||||
<CardTitle className="flex items-center gap-2 text-sm font-semibold">
|
|
||||||
<div className="flex size-6 items-center justify-center rounded-full bg-primary/10">
|
|
||||||
<Bot className="size-3.5 text-primary" />
|
|
||||||
</div>
|
|
||||||
Chatbot
|
|
||||||
<Sparkles className="size-3 text-primary/60 ml-0.5" />
|
|
||||||
<div className="flex-1" />
|
|
||||||
{messages.length > 0 && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-xs"
|
|
||||||
onClick={handleClear}
|
|
||||||
title="Clear history"
|
|
||||||
className="text-muted-foreground hover:text-foreground"
|
|
||||||
>
|
|
||||||
<Trash2 className="size-3.5" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent className="p-0">
|
|
||||||
<ScrollArea className="h-80">
|
|
||||||
<div ref={scrollRef} className="space-y-3 p-3">
|
|
||||||
{messages.length === 0 && (
|
|
||||||
<p className="text-center text-xs text-muted-foreground py-12">
|
|
||||||
Ask me anything about the server!
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{messages.map((msg) => (
|
|
||||||
<div
|
|
||||||
key={msg.timestamp + msg.role}
|
|
||||||
className={cn(
|
|
||||||
"flex items-start gap-2",
|
|
||||||
msg.role === "user" && "flex-row-reverse",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Avatar className="size-6 shrink-0">
|
|
||||||
<AvatarFallback className="text-[10px] bg-muted">
|
|
||||||
{msg.role === "user" ? (
|
|
||||||
<User className="size-3" />
|
|
||||||
) : (
|
|
||||||
<Bot className="size-3" />
|
|
||||||
)}
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"rounded-xl px-3 py-2 text-sm max-w-[80%] leading-relaxed",
|
|
||||||
msg.role === "user"
|
|
||||||
? "bg-primary text-primary-foreground"
|
|
||||||
: "bg-muted/70",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{msg.content}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{sendMut.isPending && (
|
|
||||||
<div className="flex items-start gap-2">
|
|
||||||
<Avatar className="size-6 shrink-0">
|
|
||||||
<AvatarFallback className="text-[10px] bg-muted">
|
|
||||||
<Bot className="size-3" />
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
<div className="rounded-xl bg-muted/70 px-3 py-2">
|
|
||||||
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
</CardContent>
|
|
||||||
|
|
||||||
<CardFooter className="border-t border-border/50 p-3">
|
|
||||||
<form
|
|
||||||
onSubmit={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
handleSend();
|
|
||||||
}}
|
|
||||||
className="flex w-full gap-2"
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
placeholder="Ask the mascot…"
|
|
||||||
value={input}
|
|
||||||
onChange={(e) => setInput(e.target.value)}
|
|
||||||
disabled={sendMut.isPending}
|
|
||||||
className="h-8 flex-1"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
size="icon-sm"
|
|
||||||
disabled={!input.trim() || sendMut.isPending}
|
|
||||||
>
|
|
||||||
{sendMut.isPending ? (
|
|
||||||
<Loader2 className="size-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Send className="size-4" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</CardFooter>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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<string, number>; // 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 (
|
||||||
|
<GlassCard variant="base">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<span className="text-xs font-semibold tracking-wide uppercase text-text-secondary">Activity</span>
|
||||||
|
<span className="text-[10px] text-text-secondary/40">hour x day</span>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<div className="flex gap-0.5 min-w-[400px]">
|
||||||
|
{/* Hour labels */}
|
||||||
|
<div className="flex flex-col gap-0.5 mr-1">
|
||||||
|
<div className="h-4" />
|
||||||
|
{DAYS.map((d) => (
|
||||||
|
<div key={d} className="h-3 flex items-center text-[8px] text-text-secondary/40 font-mono">{d}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{/* Grid */}
|
||||||
|
<div className="flex gap-0.5">
|
||||||
|
{HOURS.map((hour) => (
|
||||||
|
<div key={hour} className="flex flex-col gap-0.5">
|
||||||
|
{DAYS.map((day) => (
|
||||||
|
<div
|
||||||
|
key={`${day}-${hour}`}
|
||||||
|
className={cn("size-3 rounded-sm transition-colors", getIntensity(day, hour))}
|
||||||
|
title={`${day} ${hour}:00 - ${data[`${day}-${hour}`] || 0}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<div className="h-3 flex items-center justify-center text-[8px] text-text-secondary/30 font-mono">
|
||||||
|
{hour % 4 === 0 ? hour : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</GlassCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 <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
|
|
||||||
</Button>
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-6 space-y-5">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-semibold flex items-center gap-2">
|
|
||||||
<Hash className="size-5 text-muted-foreground" />
|
|
||||||
{channel.channel_name ?? channel.channel_id.slice(0, 8)}
|
|
||||||
</h2>
|
|
||||||
<p className="text-xs text-muted-foreground font-mono">
|
|
||||||
{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
|
|
||||||
label="Flagged"
|
|
||||||
value={channel.flagged_count}
|
|
||||||
variant="danger"
|
|
||||||
/>
|
|
||||||
<DetailStat
|
|
||||||
label="Clean"
|
|
||||||
value={channel.clean_count}
|
|
||||||
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">
|
|
||||||
<Sparkles className="size-4 text-primary" />
|
|
||||||
<p className="text-xs text-muted-foreground font-medium uppercase tracking-wider">
|
|
||||||
Channel Culture
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm leading-relaxed italic">
|
|
||||||
“{channel.culture_summary}”
|
|
||||||
</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
|
|
||||||
</h3>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{channel.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"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2 mb-1">
|
|
||||||
<span className="text-sm font-medium">
|
|
||||||
{msg.username}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{new Date(msg.created_at).toLocaleString()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm">{msg.content}</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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 (
|
|
||||||
<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 channels…"
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
|
||||||
className="pl-9 h-9"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{isLoading ? (
|
|
||||||
<LoadingSkeleton count={6} height="h-20" />
|
|
||||||
) : !channels || channels.length === 0 ? (
|
|
||||||
<EmptyState icon={Hash} title="No channels found." />
|
|
||||||
) : (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{channels.map((ch) => (
|
|
||||||
<Card
|
|
||||||
key={ch.channel_id}
|
|
||||||
className="cursor-pointer hover:bg-accent/5 transition-colors"
|
|
||||||
onClick={() => onSelect(ch.channel_id)}
|
|
||||||
>
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Hash className="size-3.5 text-muted-foreground shrink-0" />
|
|
||||||
<p className="text-sm font-medium truncate">
|
|
||||||
{ch.channel_name ?? ch.channel_id.slice(0, 8)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<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" />
|
|
||||||
</div>
|
|
||||||
{ch.culture_summary && (
|
|
||||||
<p className="text-xs text-muted-foreground/70 mt-2 italic line-clamp-2 border-t border-border/50 pt-2">
|
|
||||||
“{ch.culture_summary}”
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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";
|
|
||||||
@@ -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 (
|
||||||
|
<GlassCard variant="base">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<span className="text-xs font-semibold tracking-wide uppercase text-text-secondary">Message Trend</span>
|
||||||
|
<span className="text-[10px] text-text-secondary/40">7 days</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-48">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<AreaChart data={data}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="trend-msg" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor="var(--color-primary)" stopOpacity={0.3} />
|
||||||
|
<stop offset="100%" stopColor="var(--color-primary)" stopOpacity={0} />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="trend-flag" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor="var(--color-destructive)" stopOpacity={0.3} />
|
||||||
|
<stop offset="100%" stopColor="var(--color-destructive)" stopOpacity={0} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<XAxis dataKey="date" axisLine={false} tickLine={false} tick={{ fill: "oklch(0.55 0.02 245)", fontSize: 10 }} />
|
||||||
|
<YAxis axisLine={false} tickLine={false} tick={{ fill: "oklch(0.55 0.02 245)", fontSize: 10 }} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
background: "oklch(0.11 0.02 245 / 0.9)",
|
||||||
|
border: "1px solid oklch(1 0 0 / 0.08)",
|
||||||
|
borderRadius: 8,
|
||||||
|
fontSize: 12,
|
||||||
|
color: "oklch(0.93 0.01 245)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Area type="monotone" dataKey="messages" stroke="var(--color-primary)" strokeWidth={2} fill="url(#trend-msg)" />
|
||||||
|
<Area type="monotone" dataKey="flagged" stroke="var(--color-destructive)" strokeWidth={1.5} fill="url(#trend-flag)" />
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</GlassCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<GlassCard variant="base" className="relative overflow-hidden p-4">
|
||||||
|
<div className="flex items-start justify-between mb-2">
|
||||||
|
<div className={cn("p-1.5 rounded-md", bgAccent)}>
|
||||||
|
<Icon className="size-4" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-mono font-semibold tracking-tight" style={{ color: accentColor }}>
|
||||||
|
{formatter(numValue)}
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-text-secondary font-medium mt-0.5 tracking-wide uppercase">
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sparkline background */}
|
||||||
|
{sparklineData && sparklineData.length > 0 && (
|
||||||
|
<div className="absolute bottom-0 left-0 right-0 h-12 opacity-20">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<AreaChart data={sparklineData}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id={`spark-grad-${label}`} x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor={accentColor} stopOpacity={0.5} />
|
||||||
|
<stop offset="100%" stopColor={accentColor} stopOpacity={0} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="value"
|
||||||
|
stroke={accentColor}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
fill={`url(#spark-grad-${label})`}
|
||||||
|
dot={false}
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</GlassCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 <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">
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
|
||||||
<StatCard
|
|
||||||
label="Total Messages"
|
|
||||||
value={stats.total_messages}
|
|
||||||
icon={Hash}
|
|
||||||
/>
|
|
||||||
<StatCard label="Today" value={stats.today_messages} icon={Clock} />
|
|
||||||
<StatCard label="Users" value={stats.total_users} icon={Users} />
|
|
||||||
<StatCard
|
|
||||||
label="Active 24h"
|
|
||||||
value={stats.active_users_24h}
|
|
||||||
icon={Sparkles}
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
label="Flagged"
|
|
||||||
value={stats.total_flagged}
|
|
||||||
icon={AlertCircle}
|
|
||||||
variant="danger"
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
label="Clean"
|
|
||||||
value={stats.total_clean}
|
|
||||||
icon={Shield}
|
|
||||||
variant="success"
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
label="Voice Recordings"
|
|
||||||
value={stats.total_voice_recordings}
|
|
||||||
icon={Hash}
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
label="AI Profiles"
|
|
||||||
value={stats.total_profiles}
|
|
||||||
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
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{stats.top_channels.length === 0 ? (
|
|
||||||
<p className="text-sm text-muted-foreground py-6 text-center">
|
|
||||||
No channel data yet.
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{stats.top_channels.map((ch) => {
|
|
||||||
const max = stats.top_channels[0].message_count;
|
|
||||||
const pct = max > 0 ? (ch.message_count / max) * 100 : 0;
|
|
||||||
return (
|
|
||||||
<div key={ch.channel_id} className="space-y-1">
|
|
||||||
<div className="flex items-center justify-between text-sm">
|
|
||||||
<span className="truncate font-medium">
|
|
||||||
#{ch.channel_name ?? ch.channel_id.slice(0, 8)}
|
|
||||||
</span>
|
|
||||||
<span className="text-muted-foreground tabular-nums">
|
|
||||||
{formatNumber(ch.message_count)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Progress value={pct} className="h-1.5" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2 text-sm font-semibold">
|
|
||||||
<Shield className="size-4 text-muted-foreground" /> Moderation
|
|
||||||
Queue
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="grid grid-cols-3 gap-3">
|
|
||||||
{[
|
|
||||||
{
|
|
||||||
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>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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 (
|
||||||
|
<GlassCard variant="base">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<span className="text-xs font-semibold tracking-wide uppercase text-text-secondary">Top Channels</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-48">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={data} layout="vertical">
|
||||||
|
<XAxis type="number" axisLine={false} tickLine={false} tick={{ fill: "oklch(0.55 0.02 245)", fontSize: 10 }} />
|
||||||
|
<YAxis type="category" dataKey="name" axisLine={false} tickLine={false} tick={{ fill: "oklch(0.55 0.02 245)", fontSize: 10 }} width={80} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
background: "oklch(0.11 0.02 245 / 0.9)",
|
||||||
|
border: "1px solid oklch(1 0 0 / 0.08)",
|
||||||
|
borderRadius: 8,
|
||||||
|
fontSize: 12,
|
||||||
|
color: "oklch(0.93 0.01 245)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="count" fill="var(--color-primary)" radius={[0, 4, 4, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</GlassCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 <LoadingSkeleton count={1} height="h-64" />;
|
|
||||||
if (!user) return <ErrorState message="User 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
|
|
||||||
</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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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 (
|
|
||||||
<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>
|
|
||||||
{isLoading ? (
|
|
||||||
<LoadingSkeleton count={6} height="h-20" columns={2} />
|
|
||||||
) : !users || users.length === 0 ? (
|
|
||||||
<EmptyState icon={Users} title="No users found." />
|
|
||||||
) : (
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
||||||
{users.map((u) => (
|
|
||||||
<Card
|
|
||||||
key={u.user_id}
|
|
||||||
className="cursor-pointer hover:bg-accent/5 transition-colors"
|
|
||||||
onClick={() => onSelect(u.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">
|
|
||||||
{u.avatar_url ? (
|
|
||||||
<Image
|
|
||||||
src={u.avatar_url}
|
|
||||||
alt=""
|
|
||||||
width={40}
|
|
||||||
height={40}
|
|
||||||
className="size-full object-cover"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
(u.username ?? "?").charAt(0).toUpperCase()
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<p className="text-sm font-medium truncate">
|
|
||||||
{u.username ?? "Unknown"}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-muted-foreground flex items-center gap-2">
|
|
||||||
<span>{u.total_messages} messages</span>
|
|
||||||
{u.flagged_count > 0 && (
|
|
||||||
<Badge
|
|
||||||
variant="destructive"
|
|
||||||
className="text-[10px] px-1.5 py-0 h-4"
|
|
||||||
>
|
|
||||||
{u.flagged_count} flagged
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<ChevronRight className="size-4 text-muted-foreground shrink-0" />
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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 (
|
|
||||||
<>
|
|
||||||
<header className="flex h-14 items-center gap-3 border-b border-border/50 bg-background/70 backdrop-blur-xl px-4 shrink-0 shadow-[0_1px_0_0_oklch(0.62_0.17_215_/_0.06)]">
|
|
||||||
{/* Mobile menu button */}
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="md:hidden size-8 -ml-1 text-muted-foreground"
|
|
||||||
onClick={() => setMobileOpen(!mobileOpen)}
|
|
||||||
aria-label="Toggle menu"
|
|
||||||
>
|
|
||||||
<PanelLeft className="size-4" />
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<h1 className="text-base font-semibold tracking-tight">{pageTitle}</h1>
|
|
||||||
|
|
||||||
<div className="flex-1" />
|
|
||||||
|
|
||||||
<Badge
|
|
||||||
variant={statusVariant}
|
|
||||||
className="gap-1.5 px-2.5 py-1 cursor-default select-none text-xs"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"size-1.5 rounded-full",
|
|
||||||
status === "connected" &&
|
|
||||||
"bg-emerald-500 shadow-[0_0_6px] shadow-emerald-500/60",
|
|
||||||
status === "connecting" && "bg-amber-400 animate-pulse",
|
|
||||||
status === "disconnected" && "bg-destructive",
|
|
||||||
status === "error" && "bg-destructive",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<span className="hidden sm:inline">{statusLabel}</span>
|
|
||||||
</Badge>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={toggleTheme}
|
|
||||||
aria-label="Toggle theme"
|
|
||||||
className="size-8 text-muted-foreground hover:text-foreground"
|
|
||||||
>
|
|
||||||
<Sun
|
|
||||||
className={cn(
|
|
||||||
"size-4 transition-all absolute",
|
|
||||||
theme === "dark"
|
|
||||||
? "opacity-0 rotate-90 scale-75"
|
|
||||||
: "opacity-100 rotate-0 scale-100",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<Moon
|
|
||||||
className={cn(
|
|
||||||
"size-4 transition-all absolute",
|
|
||||||
theme === "dark"
|
|
||||||
? "opacity-100 rotate-0 scale-100"
|
|
||||||
: "opacity-0 -rotate-90 scale-75",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* Mobile overlay menu */}
|
|
||||||
{mobileOpen && (
|
|
||||||
<div className="fixed inset-0 z-50 md:hidden">
|
|
||||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: overlay backdrop */}
|
|
||||||
<div
|
|
||||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
onClick={() => setMobileOpen(false)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter" || e.key === " ") setMobileOpen(false);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<aside className="absolute left-0 top-0 bottom-0 w-64 bg-sidebar border-r border-sidebar-border p-2 space-y-0.5">
|
|
||||||
<div className="flex h-14 items-center gap-3 px-3 mb-1 border-b border-sidebar-border/50">
|
|
||||||
<div className="flex size-7 items-center justify-center rounded-lg bg-gradient-to-br from-cyan-500 to-teal-400 text-white text-xs font-bold">
|
|
||||||
D
|
|
||||||
</div>
|
|
||||||
<span className="text-sm font-bold text-gradient">
|
|
||||||
Discord Automod
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{navItems.map(({ href, label, icon: Icon, matchPrefix }) => {
|
|
||||||
const active = isActivePath(pathname, matchPrefix);
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={href}
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
window.location.href = href;
|
|
||||||
setMobileOpen(false);
|
|
||||||
}}
|
|
||||||
className={cn(
|
|
||||||
"flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-sm transition-all text-left",
|
|
||||||
active
|
|
||||||
? "bg-sidebar-accent/80 text-sidebar-accent-foreground font-medium"
|
|
||||||
: "text-sidebar-foreground/60 hover:bg-sidebar-accent/40 hover:text-sidebar-foreground/90",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
className={cn("size-4 shrink-0", active && "text-cyan-400")}
|
|
||||||
/>
|
|
||||||
<span>{label}</span>
|
|
||||||
{active && (
|
|
||||||
<div className="ml-auto w-1 h-5 rounded-full bg-gradient-to-b from-cyan-400 to-teal-500" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</aside>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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 (
|
|
||||||
<aside className="hidden md:flex md:w-64 flex-col border-r border-border/50 bg-sidebar shrink-0">
|
|
||||||
{/* Brand */}
|
|
||||||
<div className="flex h-14 items-center gap-3 border-b border-sidebar-border/50 px-4 shrink-0">
|
|
||||||
<div className="flex size-8 items-center justify-center rounded-lg bg-gradient-to-br from-cyan-500 to-teal-400 text-white text-xs font-bold shadow-lg shadow-cyan-500/20">
|
|
||||||
D
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-bold tracking-tight">
|
|
||||||
<span className="text-gradient">Discord Automod</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-[10px] text-muted-foreground/60 tracking-widest uppercase leading-none">
|
|
||||||
Monitor
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Nav */}
|
|
||||||
<nav className="flex-1 overflow-y-auto p-2 space-y-0.5">
|
|
||||||
{navItems.map(({ href, label, icon: Icon, matchPrefix }) => {
|
|
||||||
const active = isActivePath(pathname, matchPrefix);
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={href}
|
|
||||||
type="button"
|
|
||||||
onClick={() => router.push(href)}
|
|
||||||
className={cn(
|
|
||||||
"flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm transition-all duration-150 text-left group relative",
|
|
||||||
active
|
|
||||||
? "bg-sidebar-accent/80 text-sidebar-accent-foreground font-medium"
|
|
||||||
: "text-sidebar-foreground/60 hover:bg-sidebar-accent/40 hover:text-sidebar-foreground/90",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
className={cn(
|
|
||||||
"size-4 shrink-0 transition-all",
|
|
||||||
active && "text-cyan-400",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<span className="truncate">{label}</span>
|
|
||||||
{active && (
|
|
||||||
<div className="ml-auto w-1 h-5 rounded-full bg-gradient-to-b from-cyan-400 to-teal-500 shadow-[0_0_8px] shadow-cyan-400/60" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
{/* Connection status */}
|
|
||||||
<div className="border-t border-sidebar-border/50 p-3 shrink-0">
|
|
||||||
<div className="flex items-center gap-2.5">
|
|
||||||
<span className="relative flex size-2 shrink-0">
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"absolute inline-flex size-full rounded-full opacity-75",
|
|
||||||
connectionDot,
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"relative inline-flex size-2 rounded-full",
|
|
||||||
status === "connected" ? "bg-emerald-500" : connectionDot,
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-muted-foreground/70 truncate font-medium tracking-wide">
|
|
||||||
{connectionLabel}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
const handleMouseEnter = () => {
|
||||||
|
if (hideTimer) clearTimeout(hideTimer);
|
||||||
|
setVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseLeave = () => {
|
||||||
|
hideTimer = setTimeout(() => setVisible(false), 300);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Hotspot trigger */}
|
||||||
|
<div
|
||||||
|
className="fixed left-0 top-0 bottom-0 w-1 z-50"
|
||||||
|
onMouseEnter={handleMouseEnter}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Sidebar */}
|
||||||
|
<div
|
||||||
|
className={`fixed left-0 top-0 bottom-0 z-40 w-56 glass-intense border-r border-glass-border transition-transform duration-150 ease-out ${
|
||||||
|
visible ? "translate-x-0" : "-translate-x-full"
|
||||||
|
}`}
|
||||||
|
onMouseEnter={handleMouseEnter}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
|
>
|
||||||
|
<div className="flex h-11 items-center gap-2 px-4 border-b border-glass-border">
|
||||||
|
<span className="text-xs font-semibold tracking-wider uppercase text-text-secondary">
|
||||||
|
Guilds
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 space-y-4">
|
||||||
|
<GuildSelector value={guildId} onChange={onGuildChange} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,16 +2,15 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
|
import { mobileNavItems, isActivePath } from "@/lib/navigation";
|
||||||
import { isActivePath, mobileNavItems } from "@/lib/navigation";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export function MobileNav() {
|
export function MobileNav() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="md:hidden fixed bottom-0 inset-x-0 z-10 border-t border-border/50 bg-background/80 backdrop-blur-lg">
|
<nav className="md:hidden fixed bottom-0 inset-x-0 z-30 glass-intense border-t border-glass-border">
|
||||||
<div className="flex">
|
<div className="flex items-center justify-around h-14 px-2">
|
||||||
{mobileNavItems.map(({ href, label, icon: Icon, matchPrefix }) => {
|
{mobileNavItems.map(({ href, label, icon: Icon, matchPrefix }) => {
|
||||||
const active = isActivePath(pathname, matchPrefix);
|
const active = isActivePath(pathname, matchPrefix);
|
||||||
return (
|
return (
|
||||||
@@ -19,14 +18,16 @@ export function MobileNav() {
|
|||||||
key={href}
|
key={href}
|
||||||
href={href}
|
href={href}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex-1 flex flex-col items-center gap-1 py-2 text-[11px] font-medium transition-all relative",
|
"flex flex-col items-center gap-0.5 py-1 px-3 rounded-lg transition-all relative min-w-0",
|
||||||
active ? "text-cyan-400" : "text-muted-foreground/60",
|
active
|
||||||
|
? "text-primary"
|
||||||
|
: "text-text-secondary/50 hover:text-text-secondary/80",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon className="size-5" />
|
<Icon className="size-5" />
|
||||||
<span>{label}</span>
|
<span className="text-[10px] font-medium leading-tight">{label}</span>
|
||||||
{active && (
|
{active && (
|
||||||
<span className="absolute -top-px left-1/2 -translate-x-1/2 size-1 rounded-full bg-cyan-400 shadow-[0_0_6px] shadow-cyan-400/80" />
|
<span className="absolute -top-0.5 left-1/2 -translate-x-1/2 size-1 rounded-full bg-primary shadow-[0_0_6px] shadow-primary/80" />
|
||||||
)}
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface SubNavTab {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SubNavProps {
|
||||||
|
tabs: SubNavTab[];
|
||||||
|
activeTab: string;
|
||||||
|
onTabChange: (tab: string) => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SubNav({ tabs, activeTab, onTabChange, className }: SubNavProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn("flex items-center gap-1 px-1 py-1 glass rounded-[var(--radius-panel)] w-fit", className)}>
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onTabChange(tab.id)}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all duration-150",
|
||||||
|
activeTab === tab.id
|
||||||
|
? "bg-primary/20 text-text-primary shadow-[0_0_12px] shadow-primary/20"
|
||||||
|
: "text-text-secondary/60 hover:text-text-primary/80",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tab.icon}
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
|
import { Moon, Sun } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { navItems, isActivePath } from "@/lib/navigation";
|
||||||
|
|
||||||
|
export function TopNav() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const router = useRouter();
|
||||||
|
const [theme, setTheme] = useState<"light" | "dark">("dark");
|
||||||
|
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="fixed top-0 left-0 right-0 z-40 h-11 flex items-center gap-1 px-3 glass-intense border-b border-[var(--color-border-glow)]">
|
||||||
|
{/* Brand */}
|
||||||
|
<div className="flex items-center gap-2 mr-4 shrink-0">
|
||||||
|
<div className="relative flex size-6 items-center justify-center rounded-md bg-gradient-to-br from-cyan-500 to-teal-400 text-white text-[10px] font-bold">
|
||||||
|
D
|
||||||
|
<span className="absolute -top-0.5 -right-0.5 size-1.5 rounded-full bg-emerald-500 shadow-[0_0_6px] shadow-emerald-500/80 animate-pulse" />
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-semibold text-text-primary tracking-tight hidden sm:inline">
|
||||||
|
Discord Automod
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Nav links */}
|
||||||
|
<nav className="flex items-center gap-0.5 flex-1 justify-center">
|
||||||
|
{navItems.map(({ href, label, icon: Icon, matchPrefix }) => {
|
||||||
|
const active = isActivePath(pathname, matchPrefix);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={href}
|
||||||
|
type="button"
|
||||||
|
onClick={() => router.push(href)}
|
||||||
|
className={`relative flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all duration-150 ${
|
||||||
|
active
|
||||||
|
? "text-text-primary"
|
||||||
|
: "text-text-secondary/60 hover:text-text-primary/80"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon className="size-3.5" />
|
||||||
|
<span className="hidden sm:inline">{label}</span>
|
||||||
|
{active && (
|
||||||
|
<span className="absolute bottom-0 left-1/2 -translate-x-1/2 w-6 h-0.5 rounded-full bg-primary shadow-[0_0_8px] shadow-primary/60" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Right side */}
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggleTheme}
|
||||||
|
className="size-7 flex items-center justify-center rounded-md text-text-secondary/60 hover:text-text-primary hover:bg-glass-bg transition-all"
|
||||||
|
aria-label="Toggle theme"
|
||||||
|
>
|
||||||
|
{theme === "dark" ? (
|
||||||
|
<Moon className="size-3.5" />
|
||||||
|
) : (
|
||||||
|
<Sun className="size-3.5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Send } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useMascot } from "./mascot-context";
|
||||||
|
|
||||||
|
export function ChatPanel() {
|
||||||
|
const { chatHistory, addChat, setExpression } = useMascot();
|
||||||
|
const [input, setInput] = useState("");
|
||||||
|
|
||||||
|
const handleSend = () => {
|
||||||
|
if (!input.trim()) return;
|
||||||
|
addChat("user", input);
|
||||||
|
setExpression("listening");
|
||||||
|
|
||||||
|
// Simulated bot response — replace with actual mascot-chat API call
|
||||||
|
setTimeout(() => {
|
||||||
|
addChat("assistant", "I'm monitoring this server for you!");
|
||||||
|
setExpression("happy");
|
||||||
|
}, 800);
|
||||||
|
|
||||||
|
setInput("");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<div className="flex-1 overflow-y-auto px-2 py-1 space-y-1">
|
||||||
|
{chatHistory.slice(-6).map((msg, i) => (
|
||||||
|
<div key={i} className={`flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}>
|
||||||
|
<span className={`text-[10px] px-2 py-1 rounded-lg max-w-[85%] ${
|
||||||
|
msg.role === "user"
|
||||||
|
? "bg-primary/20 text-text-primary"
|
||||||
|
: "glass text-text-secondary"
|
||||||
|
}`}>
|
||||||
|
{msg.text}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 px-2 py-1 border-t border-glass-border">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && handleSend()}
|
||||||
|
placeholder="Ask mascot..."
|
||||||
|
className="flex-1 bg-transparent text-[10px] text-text-primary placeholder-text-secondary/30 outline-none"
|
||||||
|
/>
|
||||||
|
<button type="button" onClick={handleSend} className="size-5 flex items-center justify-center">
|
||||||
|
<Send className="size-3 text-primary" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { MascotProvider } from "./mascot-context";
|
||||||
|
export { MascotContainer } from "./mascot-container";
|
||||||
|
export { useMascot } from "./mascot-context";
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { useMascot } from "./mascot-context";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live2D Cubism WebGL canvas.
|
||||||
|
*
|
||||||
|
* This component renders the Live2D model via the Cubism SDK.
|
||||||
|
* Integration requires:
|
||||||
|
* 1. Live2D Cubism SDK for Web (npm: @live2d/cubism)
|
||||||
|
* 2. Model files: .model3.json, .moc3, .physics3.json, textures
|
||||||
|
* 3. Place model files in public/mascot/
|
||||||
|
*
|
||||||
|
* The current implementation shows a placeholder character.
|
||||||
|
* Replace with actual Cubism SDK integration when model files are available.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function MascotCanvas() {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const { expression } = useMascot();
|
||||||
|
|
||||||
|
// Placeholder: draw a simple avatar face that responds to expression
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
const w = canvas.width;
|
||||||
|
const h = canvas.height;
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, w, h);
|
||||||
|
|
||||||
|
// Background circle
|
||||||
|
const gradient = ctx.createRadialGradient(w / 2, h / 2 - 10, 10, w / 2, h / 2, 80);
|
||||||
|
gradient.addColorStop(0, "oklch(0.62 0.17 215 / 0.8)");
|
||||||
|
gradient.addColorStop(0.6, "oklch(0.12 0.02 245 / 0.9)");
|
||||||
|
gradient.addColorStop(1, "oklch(0.07 0.015 250 / 1)");
|
||||||
|
ctx.fillStyle = gradient;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(w / 2, h / 2, 75, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Eyes
|
||||||
|
const eyeOffsetX = 20;
|
||||||
|
const eyeY = 45;
|
||||||
|
|
||||||
|
// Expression-driven eyes
|
||||||
|
if (expression === "surprise") {
|
||||||
|
// Wide eyes
|
||||||
|
ctx.fillStyle = "oklch(0.93 0.01 245)";
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(w / 2 - eyeOffsetX, eyeY, 12, 14, 0, 0, Math.PI * 2);
|
||||||
|
ctx.ellipse(w / 2 + eyeOffsetX, eyeY, 12, 14, 0, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.fillStyle = "oklch(0.62 0.17 215)";
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(w / 2 - eyeOffsetX, eyeY, 5, 0, Math.PI * 2);
|
||||||
|
ctx.arc(w / 2 + eyeOffsetX, eyeY, 5, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
} else if (expression === "happy") {
|
||||||
|
// Happy closed crescent eyes
|
||||||
|
ctx.strokeStyle = "oklch(0.93 0.01 245)";
|
||||||
|
ctx.lineWidth = 3;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(w / 2 - eyeOffsetX, eyeY, 10, Math.PI * 0.1, Math.PI * 0.9);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(w / 2 + eyeOffsetX, eyeY, 10, Math.PI * 0.1, Math.PI * 0.9);
|
||||||
|
ctx.stroke();
|
||||||
|
} else if (expression === "sad") {
|
||||||
|
// Sad downcast eyes
|
||||||
|
ctx.fillStyle = "oklch(0.93 0.01 245)";
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(w / 2 - eyeOffsetX, eyeY, 8, 6, 0.2, 0, Math.PI * 2);
|
||||||
|
ctx.ellipse(w / 2 + eyeOffsetX, eyeY, 8, 6, -0.2, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
} else {
|
||||||
|
// Normal eyes
|
||||||
|
ctx.fillStyle = "oklch(0.93 0.01 245)";
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(w / 2 - eyeOffsetX, eyeY, 10, 8, 0, 0, Math.PI * 2);
|
||||||
|
ctx.ellipse(w / 2 + eyeOffsetX, eyeY, 10, 8, 0, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.fillStyle = "oklch(0.62 0.17 215)";
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(w / 2 - eyeOffsetX, eyeY, 4, 0, Math.PI * 2);
|
||||||
|
ctx.arc(w / 2 + eyeOffsetX, eyeY, 4, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mouth
|
||||||
|
ctx.strokeStyle = "oklch(0.93 0.01 245 / 0.7)";
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
if (expression === "talking") {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(w / 2, 70, 8, 6, 0, 0, Math.PI * 2);
|
||||||
|
ctx.stroke();
|
||||||
|
} else if (expression === "happy") {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(w / 2, 70, 10, 0.1, Math.PI - 0.1);
|
||||||
|
ctx.stroke();
|
||||||
|
} else if (expression === "surprise") {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(w / 2, 70, 6, 8, 0, 0, Math.PI * 2);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.fillStyle = "oklch(0.12 0.02 245)";
|
||||||
|
ctx.fill();
|
||||||
|
} else {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(w / 2, 75, 6, 0.1, Math.PI - 0.1);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
}, [expression]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
width={160}
|
||||||
|
height={180}
|
||||||
|
className="w-full h-full"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { MessageCircle, X, Minimize2, Maximize2 } from "lucide-react";
|
||||||
|
import { useMascot } from "./mascot-context";
|
||||||
|
import { MascotCanvas } from "./mascot-canvas";
|
||||||
|
import { ChatPanel } from "./chat-panel";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
export function MascotContainer() {
|
||||||
|
const { minimized, setMinimized, chatOpen, setChatOpen } = useMascot();
|
||||||
|
const [position, setPosition] = useState({ x: 0, y: 0 });
|
||||||
|
const [dragging, setDragging] = useState(false);
|
||||||
|
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
|
||||||
|
|
||||||
|
const handleMouseDown = (e: React.MouseEvent) => {
|
||||||
|
setDragging(true);
|
||||||
|
setDragStart({ x: e.clientX - position.x, y: e.clientY - position.y });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseMove = (e: React.MouseEvent) => {
|
||||||
|
if (!dragging) return;
|
||||||
|
setPosition({ x: e.clientX - dragStart.x, y: e.clientY - dragStart.y });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseUp = () => setDragging(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed bottom-4 right-4 z-40 select-none"
|
||||||
|
style={{ transform: `translate(${position.x}px, ${position.y}px)` }}
|
||||||
|
onMouseMove={handleMouseMove}
|
||||||
|
onMouseUp={handleMouseUp}
|
||||||
|
onMouseLeave={handleMouseUp}
|
||||||
|
>
|
||||||
|
{/* Main mascot bubble */}
|
||||||
|
<div
|
||||||
|
className={`glass-intense rounded-2xl overflow-hidden transition-all duration-200 ${
|
||||||
|
minimized ? "w-16 h-16 cursor-pointer" : "w-[200px]"
|
||||||
|
}`}
|
||||||
|
style={{ height: minimized ? 64 : 280 }}
|
||||||
|
>
|
||||||
|
{minimized ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMinimized(false)}
|
||||||
|
className="w-full h-full flex items-center justify-center"
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
>
|
||||||
|
<MessageCircle className="size-6 text-primary" />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Drag handle + controls */}
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between px-3 py-1.5 border-b border-glass-border cursor-grab active:cursor-grabbing"
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
>
|
||||||
|
<span className="text-[10px] font-semibold text-text-secondary tracking-wide uppercase">Mascot</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button type="button" onClick={() => setChatOpen(!chatOpen)}>
|
||||||
|
<MessageCircle className="size-3 text-text-secondary/60 hover:text-text-primary" />
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => setMinimized(true)}>
|
||||||
|
<Minimize2 className="size-3 text-text-secondary/60 hover:text-text-primary" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Canvas area */}
|
||||||
|
<div className="h-[140px] flex items-center justify-center">
|
||||||
|
<MascotCanvas />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Chat panel (expandable) */}
|
||||||
|
<div className={`transition-all duration-200 overflow-hidden ${chatOpen ? "h-[120px]" : "h-0"}`}>
|
||||||
|
<ChatPanel />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { createContext, useContext, useState, type ReactNode } from "react";
|
||||||
|
|
||||||
|
type MascotExpression = "idle" | "listening" | "surprise" | "happy" | "sad" | "talking";
|
||||||
|
|
||||||
|
interface MascotContextType {
|
||||||
|
expression: MascotExpression;
|
||||||
|
minimized: boolean;
|
||||||
|
chatOpen: boolean;
|
||||||
|
chatHistory: { role: "user" | "assistant"; text: string }[];
|
||||||
|
setExpression: (expr: MascotExpression) => void;
|
||||||
|
setMinimized: (v: boolean) => void;
|
||||||
|
setChatOpen: (v: boolean) => void;
|
||||||
|
addChat: (role: "user" | "assistant", text: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MascotContext = createContext<MascotContextType | null>(null);
|
||||||
|
|
||||||
|
export function MascotProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [expression, setExpression] = useState<MascotExpression>("idle");
|
||||||
|
const [minimized, setMinimized] = useState(true);
|
||||||
|
const [chatOpen, setChatOpen] = useState(false);
|
||||||
|
const [chatHistory, setChatHistory] = useState<{ role: "user" | "assistant"; text: string }[]>([]);
|
||||||
|
|
||||||
|
const addChat = (role: "user" | "assistant", text: string) => {
|
||||||
|
setChatHistory((prev) => [...prev, { role, text }]);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MascotContext.Provider
|
||||||
|
value={{ expression, minimized, chatOpen, chatHistory, setExpression, setMinimized, setChatOpen, addChat }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</MascotContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMascot() {
|
||||||
|
const ctx = useContext(MascotContext);
|
||||||
|
if (!ctx) throw new Error("useMascot must be used within MascotProvider");
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Play, SkipForward, Volume2, X } from "lucide-react";
|
||||||
|
import { useMediaPlayer } from "@/lib/hooks/use-media-player";
|
||||||
|
|
||||||
|
export function MiniPlayer() {
|
||||||
|
const { currentTrack, playing, volume, skip, stop, setVolume } = useMediaPlayer();
|
||||||
|
|
||||||
|
if (!currentTrack) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-16 md:bottom-4 left-4 z-30 glass-elevated rounded-[var(--radius-card)] p-3 w-64 shadow-2xl">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<div className="size-6 flex items-center justify-center rounded bg-primary/20">
|
||||||
|
<Play className="size-3 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-xs font-medium text-text-primary truncate">{currentTrack.title}</p>
|
||||||
|
{currentTrack.artist && (
|
||||||
|
<p className="text-[10px] text-text-secondary/50 truncate">{currentTrack.artist}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button type="button" onClick={stop} className="size-5 flex items-center justify-center hover:bg-glass-bg rounded">
|
||||||
|
<X className="size-3 text-text-secondary/60" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button type="button" onClick={skip} className="size-6 flex items-center justify-center hover:bg-glass-bg rounded">
|
||||||
|
<SkipForward className="size-3 text-text-secondary/60" />
|
||||||
|
</button>
|
||||||
|
<Volume2 className="size-3 text-text-secondary/40" />
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
value={volume}
|
||||||
|
onChange={(e) => setVolume(Number(e.target.value))}
|
||||||
|
className="flex-1 h-1 appearance-none bg-glass-border rounded-full accent-primary [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:size-2.5 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { GlassPanel } from "@/components/glass/panel";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface AiAnalysisPanelProps {
|
||||||
|
status?: string | null;
|
||||||
|
severity?: string | null;
|
||||||
|
confidence?: number | null;
|
||||||
|
flags?: string[] | string | null;
|
||||||
|
categories?: string[] | string | null;
|
||||||
|
action?: string | null;
|
||||||
|
score?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const severityColor: Record<string, string> = {
|
||||||
|
none: "text-emerald-500",
|
||||||
|
low: "text-text-secondary",
|
||||||
|
medium: "text-accent-amber",
|
||||||
|
high: "text-accent-purple",
|
||||||
|
critical: "text-destructive",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AiAnalysisPanel({
|
||||||
|
status,
|
||||||
|
severity,
|
||||||
|
confidence,
|
||||||
|
flags,
|
||||||
|
categories,
|
||||||
|
action,
|
||||||
|
score,
|
||||||
|
}: AiAnalysisPanelProps) {
|
||||||
|
if (!status || status === "pending") {
|
||||||
|
return (
|
||||||
|
<GlassPanel dense>
|
||||||
|
<span className="text-xs text-text-secondary/50">AI analysis pending</span>
|
||||||
|
</GlassPanel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const flagsArray = typeof flags === "string" ? (flags ? JSON.parse(flags) : []) : (flags || []);
|
||||||
|
const categoriesArray = typeof categories === "string" ? (categories ? JSON.parse(categories) : []) : (categories || []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GlassPanel dense className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs font-semibold tracking-wide uppercase text-text-secondary">AI Analysis</span>
|
||||||
|
<span className={cn(
|
||||||
|
"text-[10px] font-mono px-1.5 py-0.5 rounded",
|
||||||
|
status === "clean" && "bg-emerald-500/10 text-emerald-500",
|
||||||
|
status === "flagged" && "bg-accent-purple/10 text-accent-purple",
|
||||||
|
status === "warn" && "bg-accent-amber/10 text-accent-amber",
|
||||||
|
status === "error" && "bg-destructive/10 text-destructive",
|
||||||
|
)}>
|
||||||
|
{status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{severity && (
|
||||||
|
<div className="flex items-center gap-2 text-xs">
|
||||||
|
<span className="text-text-secondary/60">Severity:</span>
|
||||||
|
<span className={cn("font-mono font-medium", severityColor[severity] || "")}>{severity}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{confidence !== null && confidence !== undefined && (
|
||||||
|
<div className="flex items-center gap-2 text-xs">
|
||||||
|
<span className="text-text-secondary/60">Confidence:</span>
|
||||||
|
<span className="font-mono">{(confidence * 100).toFixed(0)}%</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{score !== null && score !== undefined && (
|
||||||
|
<div className="flex items-center gap-2 text-xs">
|
||||||
|
<span className="text-text-secondary/60">Score:</span>
|
||||||
|
<span className="font-mono">{score.toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{flagsArray.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{flagsArray.map((f: string) => (
|
||||||
|
<span key={f} className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-destructive/10 text-destructive">{f}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{categoriesArray.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{categoriesArray.map((c: string) => (
|
||||||
|
<span key={c} className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-primary/10 text-primary">{c}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{action && action !== "none" && (
|
||||||
|
<div className="text-xs">
|
||||||
|
<span className="text-text-secondary/60">Recommended: </span>
|
||||||
|
<span className="font-mono text-accent-amber">{action}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</GlassPanel>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { AttachmentRecord } from "@/lib/types";
|
||||||
|
|
||||||
|
interface AttachmentsGridProps {
|
||||||
|
attachments: AttachmentRecord[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AttachmentsGrid({ attachments }: AttachmentsGridProps) {
|
||||||
|
if (attachments.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{attachments.map((att) => (
|
||||||
|
<div key={att.id} className="glass rounded-lg overflow-hidden group relative">
|
||||||
|
{att.type?.startsWith("image/") ? (
|
||||||
|
<img
|
||||||
|
src={att.uploaded_url || att.discord_url}
|
||||||
|
alt={att.filename}
|
||||||
|
className="w-full h-32 object-cover transition-transform group-hover:scale-105"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2 p-3 text-xs text-text-secondary">
|
||||||
|
<span className="font-mono truncate">{att.filename}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { ImageIcon } from "lucide-react";
|
|
||||||
import { Card } from "@/components/ui/card";
|
|
||||||
import type { MessageRecord } from "@/lib/types";
|
|
||||||
import { extractFirstImage } from "./message-card";
|
|
||||||
|
|
||||||
export function ImagesGrid({
|
|
||||||
images,
|
|
||||||
onSelect,
|
|
||||||
}: {
|
|
||||||
images: MessageRecord[];
|
|
||||||
onSelect: (id: string) => void;
|
|
||||||
}) {
|
|
||||||
if (!images || 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"
|
|
||||||
aria-label="No images"
|
|
||||||
/>
|
|
||||||
<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) => {
|
|
||||||
const imgUrl = extractFirstImage(msg.metadata);
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
key={msg.id}
|
|
||||||
className="group relative overflow-hidden cursor-pointer"
|
|
||||||
onClick={() => onSelect(msg.id)}
|
|
||||||
>
|
|
||||||
<div className="aspect-square relative bg-muted">
|
|
||||||
{imgUrl ? (
|
|
||||||
<img
|
|
||||||
src={imgUrl}
|
|
||||||
alt={msg.content || "Image"}
|
|
||||||
className="absolute inset-0 size-full object-cover transition-transform duration-300 group-hover:scale-105"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,160 +1,87 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Hash, RefreshCw } from "lucide-react";
|
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
|
||||||
import { Progress } from "@/components/ui/progress";
|
|
||||||
import { safeParseJsonArray } from "@/lib/format";
|
|
||||||
import type { MessageRecord } from "@/lib/types";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { AiStatusBadge } from "./ai-status-badge";
|
import type { MessageRecord } from "@/lib/types";
|
||||||
|
|
||||||
export function MessageCard({
|
interface MessageCardProps {
|
||||||
message: msg,
|
|
||||||
onClick,
|
|
||||||
onReanalyze,
|
|
||||||
}: {
|
|
||||||
message: MessageRecord;
|
message: MessageRecord;
|
||||||
onClick: (id: string) => void;
|
selected?: boolean;
|
||||||
onReanalyze: (id: string) => void;
|
onClick?: (id: string) => void;
|
||||||
}) {
|
}
|
||||||
const severity = (
|
|
||||||
{
|
const severityDot: Record<string, string> = {
|
||||||
low: "border-l-cyan-500/40",
|
clean: "bg-emerald-500 shadow-[0_0_6px] shadow-emerald-500/60",
|
||||||
medium: "border-l-amber-500/60",
|
pending: "bg-text-secondary/30",
|
||||||
high: "border-l-orange-500/70",
|
warn: "bg-accent-amber shadow-[0_0_6px] shadow-accent-amber/60",
|
||||||
critical: "border-l-red-500/80",
|
flagged: "bg-accent-purple shadow-[0_0_6px] shadow-accent-purple/60",
|
||||||
} as Record<string, string>
|
error: "bg-destructive/60",
|
||||||
)[msg.ai_severity ?? ""];
|
};
|
||||||
|
|
||||||
|
function formatRelativeTime(timestamp: number): string {
|
||||||
|
const diff = Date.now() - timestamp;
|
||||||
|
const mins = Math.floor(diff / 60000);
|
||||||
|
if (mins < 1) return "just now";
|
||||||
|
if (mins < 60) return `${mins}m`;
|
||||||
|
const hours = Math.floor(mins / 60);
|
||||||
|
if (hours < 24) return `${hours}h`;
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
return `${days}d`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MessageCard({ message, selected, onClick }: MessageCardProps) {
|
||||||
|
const status = message.ai_status || "pending";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onClick?.(message.id)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"cursor-pointer transition-all duration-200 hover:shadow-[0_0_16px_oklch(0.62_0.17_215_/_0.08)] hover:border-cyan-500/20",
|
"w-full text-left px-4 py-3 rounded-[var(--radius-panel)] transition-all duration-150 border",
|
||||||
severity && "border-l-2",
|
selected
|
||||||
severity,
|
? "glass-elevated border-border-glow"
|
||||||
|
: "glass border-glass-border hover:border-border-glow/50 hover:scale-[1.002]",
|
||||||
)}
|
)}
|
||||||
onClick={() => onClick(msg.id)}
|
|
||||||
>
|
>
|
||||||
<CardContent className="p-4">
|
<div className="flex items-start gap-3">
|
||||||
<div className="flex items-start gap-3">
|
{/* Severity dot */}
|
||||||
<Avatar className="size-8 shrink-0 mt-0.5">
|
<span className={cn("mt-1.5 size-2 rounded-full shrink-0", severityDot[status] || severityDot.pending)} />
|
||||||
<AvatarImage src={msg.avatar_url ?? undefined} />
|
|
||||||
<AvatarFallback className="text-xs">
|
<div className="flex-1 min-w-0">
|
||||||
{msg.username.charAt(0).toUpperCase()}
|
{/* Header */}
|
||||||
</AvatarFallback>
|
<div className="flex items-center gap-2 mb-1">
|
||||||
</Avatar>
|
<span className="text-sm font-semibold text-text-primary truncate">{message.username}</span>
|
||||||
<div className="flex-1 min-w-0 space-y-2">
|
<span className="text-[10px] font-mono text-text-secondary/50">{message.channel_id?.slice(0, 8)}</span>
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<span className="ml-auto text-[10px] text-text-secondary/40 shrink-0">
|
||||||
<span className="text-sm font-medium">{msg.username}</span>
|
{message.created_at ? formatRelativeTime(message.created_at) : ""}
|
||||||
<span className="text-xs text-muted-foreground">
|
</span>
|
||||||
{new Date(msg.created_at).toLocaleString()}
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<p className="text-sm text-text-secondary/80 line-clamp-2 leading-relaxed">
|
||||||
|
{message.content || "(no text content)"}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* AI status badge */}
|
||||||
|
{status !== "pending" && (
|
||||||
|
<div className="flex items-center gap-2 mt-1.5">
|
||||||
|
<span className={cn(
|
||||||
|
"inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-medium font-mono",
|
||||||
|
status === "clean" && "bg-emerald-500/10 text-emerald-500",
|
||||||
|
status === "warn" && "bg-accent-amber/10 text-accent-amber",
|
||||||
|
status === "flagged" && "bg-accent-purple/10 text-accent-purple",
|
||||||
|
status === "error" && "bg-destructive/10 text-destructive",
|
||||||
|
)}>
|
||||||
|
{status}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-muted-foreground">
|
{message.ai_moderation_flags && message.ai_moderation_flags.length > 0 && (
|
||||||
<Hash className="size-3 inline mr-0.5" />
|
<span className="text-[10px] text-text-secondary/50 font-mono">
|
||||||
{msg.channel_id.slice(0, 8)}
|
{message.ai_moderation_flags}
|
||||||
</span>
|
</span>
|
||||||
<AiStatusBadge status={msg.ai_status} />
|
|
||||||
{msg.ai_severity && msg.ai_severity !== "none" && (
|
|
||||||
<Badge
|
|
||||||
variant="destructive"
|
|
||||||
className="text-[10px] px-1.5 py-0 h-4"
|
|
||||||
>
|
|
||||||
{msg.ai_severity}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{msg.type === "deleted" && (
|
|
||||||
<Badge
|
|
||||||
variant="destructive"
|
|
||||||
className="text-[10px] px-1.5 py-0 h-4"
|
|
||||||
>
|
|
||||||
deleted
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{msg.type === "edited" && (
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className="text-[10px] px-1.5 py-0 h-4"
|
|
||||||
>
|
|
||||||
edited
|
|
||||||
</Badge>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p
|
)}
|
||||||
className={cn(
|
|
||||||
"text-sm leading-relaxed",
|
|
||||||
msg.type === "deleted" &&
|
|
||||||
"italic text-muted-foreground line-through",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{msg.content}
|
|
||||||
</p>
|
|
||||||
{(() => {
|
|
||||||
const u = extractFirstImage(msg.metadata);
|
|
||||||
if (!u) return null;
|
|
||||||
return (
|
|
||||||
<img
|
|
||||||
src={u}
|
|
||||||
alt=""
|
|
||||||
className="mt-2 max-h-48 rounded-lg border border-border/50 object-cover"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
{msg.ai_moderation_flags && msg.ai_moderation_flags !== "[]" && (
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{safeParseJsonArray(msg.ai_moderation_flags).map((f) => (
|
|
||||||
<Badge
|
|
||||||
key={f}
|
|
||||||
variant="destructive"
|
|
||||||
className="text-[10px] px-1.5 py-0 h-4"
|
|
||||||
>
|
|
||||||
{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" />
|
|
||||||
<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={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onReanalyze(msg.id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<RefreshCw className="size-3 mr-1" /> Reanalyze
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function extractFirstImage(
|
|
||||||
metadata: string | null | undefined,
|
|
||||||
): string | null {
|
|
||||||
if (!metadata) return null;
|
|
||||||
try {
|
|
||||||
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,165 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { ExternalLink, Sparkles } from "lucide-react";
|
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
|
||||||
import { formatBytes, safeParseJsonArray } from "@/lib/format";
|
|
||||||
import type { MessageRecord } from "@/lib/types";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MessageDetailView({
|
|
||||||
message,
|
|
||||||
attachments,
|
|
||||||
}: {
|
|
||||||
message: MessageRecord;
|
|
||||||
attachments: {
|
|
||||||
id: string;
|
|
||||||
filename: string;
|
|
||||||
type: string;
|
|
||||||
size: number;
|
|
||||||
uploaded_url?: string | null;
|
|
||||||
discord_url?: string | null;
|
|
||||||
}[];
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-5">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<Avatar className="size-10">
|
|
||||||
<AvatarImage src={message.avatar_url ?? undefined} />
|
|
||||||
<AvatarFallback>
|
|
||||||
{message.username.charAt(0).toUpperCase()}
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
|
||||||
<span className="text-sm font-medium">{message.username}</span>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{new Date(message.created_at).toLocaleString()}
|
|
||||||
</span>
|
|
||||||
{message.type === "deleted" && (
|
|
||||||
<Badge variant="destructive" className="text-[10px]">
|
|
||||||
deleted
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{message.type === "edited" && (
|
|
||||||
<Badge variant="outline" className="text-[10px]">
|
|
||||||
edited
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="text-sm mt-2 whitespace-pre-wrap break-words leading-relaxed">
|
|
||||||
{message.content}
|
|
||||||
</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">
|
|
||||||
<Sparkles className="size-4 text-primary" />
|
|
||||||
<p className="text-xs text-muted-foreground font-medium">
|
|
||||||
AI Analysis
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<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((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 && (
|
|
||||||
<MiniStat label="Status" value={message.ai_status} capitalize />
|
|
||||||
)}
|
|
||||||
{message.ai_severity && message.ai_severity !== "none" && (
|
|
||||||
<MiniStat
|
|
||||||
label="Severity"
|
|
||||||
value={message.ai_severity}
|
|
||||||
destructive
|
|
||||||
capitalize
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{message.ai_confidence != null && (
|
|
||||||
<MiniStat
|
|
||||||
label="Confidence"
|
|
||||||
value={`${(message.ai_confidence * 100).toFixed(0)}%`}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{message.ai_recommended_action &&
|
|
||||||
message.ai_recommended_action !== "none" && (
|
|
||||||
<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((a) => (
|
|
||||||
<a
|
|
||||||
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">{a.filename}</p>
|
|
||||||
<p className="text-[11px] text-muted-foreground">
|
|
||||||
{a.type} · {formatBytes(a.size)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<ExternalLink className="size-3 shrink-0 text-muted-foreground/50 group-hover:text-muted-foreground transition-colors" />
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ArrowLeft, MessageSquare } from "lucide-react";
|
||||||
|
import { GlassCard } from "@/components/glass/card";
|
||||||
|
import { AttachmentsGrid } from "./attachments-grid";
|
||||||
|
import { AiAnalysisPanel } from "./ai-analysis-panel";
|
||||||
|
import type { AttachmentRecord, MessageRecord } from "@/lib/types";
|
||||||
|
|
||||||
|
interface MessageDetailProps {
|
||||||
|
message: MessageRecord;
|
||||||
|
attachments?: AttachmentRecord[];
|
||||||
|
onBack?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MessageDetail({ message, attachments, onBack }: MessageDetailProps) {
|
||||||
|
return (
|
||||||
|
<GlassCard variant="base" className="h-full">
|
||||||
|
{onBack && (
|
||||||
|
<button type="button" onClick={onBack} className="flex items-center gap-1 text-xs text-text-secondary/60 hover:text-text-primary mb-3 transition-colors">
|
||||||
|
<ArrowLeft className="size-3" /> Back
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Message header */}
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<MessageSquare className="size-4 text-primary" />
|
||||||
|
<span className="font-semibold text-sm text-text-primary">{message.username}</span>
|
||||||
|
<span className="text-[10px] text-text-secondary/40 font-mono">{message.channel_id?.slice(0, 8)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="text-sm text-text-primary/90 leading-relaxed mb-4 whitespace-pre-wrap">
|
||||||
|
{message.content || "(no text content)"}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Attachments */}
|
||||||
|
{attachments && attachments.length > 0 && (
|
||||||
|
<div className="mb-4">
|
||||||
|
<AttachmentsGrid attachments={attachments} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* AI Analysis */}
|
||||||
|
<AiAnalysisPanel
|
||||||
|
status={message.ai_status}
|
||||||
|
severity={message.ai_severity}
|
||||||
|
confidence={message.ai_confidence}
|
||||||
|
flags={message.ai_moderation_flags}
|
||||||
|
categories={message.ai_categories}
|
||||||
|
action={message.ai_recommended_action}
|
||||||
|
score={message.ai_moderation_score}
|
||||||
|
/>
|
||||||
|
</GlassCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { MessageCard } from "./message-card";
|
||||||
|
import type { MessageRecord } from "@/lib/types";
|
||||||
|
|
||||||
|
interface MessageListProps {
|
||||||
|
messages: MessageRecord[];
|
||||||
|
selectedId?: string | null;
|
||||||
|
onSelect: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MessageList({ messages, selectedId, onSelect }: MessageListProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5 overflow-y-auto max-h-[calc(100vh-200px)] pr-1">
|
||||||
|
{messages.length === 0 ? (
|
||||||
|
<div className="flex items-center justify-center py-12 text-text-secondary/40 text-sm">
|
||||||
|
No messages
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
messages.map((msg) => (
|
||||||
|
<MessageCard
|
||||||
|
key={msg.id}
|
||||||
|
message={msg}
|
||||||
|
selected={selectedId === msg.id}
|
||||||
|
onClick={onSelect}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { Flag } from "lucide-react";
|
|
||||||
import type { MessageRecord } from "@/lib/types";
|
|
||||||
import { MessageCard } from "./message-card";
|
|
||||||
|
|
||||||
export function ReviewList({
|
|
||||||
reviews,
|
|
||||||
onSelect,
|
|
||||||
onReanalyze,
|
|
||||||
}: {
|
|
||||||
reviews: MessageRecord[];
|
|
||||||
onSelect: (id: string) => void;
|
|
||||||
onReanalyze: (id: string) => void;
|
|
||||||
}) {
|
|
||||||
if (!reviews || reviews.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">
|
|
||||||
{reviews.map((msg) => (
|
|
||||||
<MessageCard
|
|
||||||
key={msg.id}
|
|
||||||
message={msg}
|
|
||||||
onClick={onSelect}
|
|
||||||
onReanalyze={onReanalyze}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Search, X } from "lucide-react";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { messagesApi } from "@/lib/api";
|
||||||
|
import type { MessageRecord } from "@/lib/types";
|
||||||
|
|
||||||
|
interface SearchOverlayProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSelect: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SearchOverlay({ open, onClose, onSelect }: SearchOverlayProps) {
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const { data: results } = useQuery<{ results: MessageRecord[] }>({
|
||||||
|
queryKey: ["messages-search", query],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await messagesApi.search(query, 20);
|
||||||
|
return res;
|
||||||
|
},
|
||||||
|
enabled: query.length >= 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setTimeout(() => inputRef.current?.focus(), 100);
|
||||||
|
} else {
|
||||||
|
setQuery("");
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKey = (e: KeyboardEvent) => {
|
||||||
|
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
|
||||||
|
e.preventDefault();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
if (e.key === "Escape") onClose();
|
||||||
|
};
|
||||||
|
document.addEventListener("keydown", handleKey);
|
||||||
|
return () => document.removeEventListener("keydown", handleKey);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-start justify-center pt-[15vh]">
|
||||||
|
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
|
||||||
|
<div className="relative w-full max-w-lg glass-intense rounded-[var(--radius-card)] overflow-hidden shadow-2xl">
|
||||||
|
{/* Input */}
|
||||||
|
<div className="flex items-center gap-3 px-4 py-3 border-b border-glass-border">
|
||||||
|
<Search className="size-4 text-text-secondary/60 shrink-0" />
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder="Search messages..."
|
||||||
|
className="flex-1 bg-transparent text-sm text-text-primary placeholder-text-secondary/40 outline-none"
|
||||||
|
/>
|
||||||
|
<button type="button" onClick={onClose} className="size-6 flex items-center justify-center rounded hover:bg-glass-bg">
|
||||||
|
<X className="size-3.5 text-text-secondary/60" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Results */}
|
||||||
|
<div className="max-h-80 overflow-y-auto p-2 space-y-1">
|
||||||
|
{!results || results.results.length === 0 ? (
|
||||||
|
<div className="py-8 text-center text-xs text-text-secondary/40">
|
||||||
|
{query.length < 2 ? "Type at least 2 characters" : "No results found"}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
results.results.map((msg: MessageRecord) => (
|
||||||
|
<button
|
||||||
|
key={msg.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => { onSelect(msg.id); onClose(); }}
|
||||||
|
className="w-full text-left px-3 py-2 rounded-lg hover:bg-glass-bg transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 text-xs">
|
||||||
|
<span className="font-medium text-text-primary">{msg.username}</span>
|
||||||
|
<span className="text-text-secondary/40">{msg.channel_id?.slice(0, 8)}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-text-secondary/80 line-clamp-1 mt-0.5">{msg.content}</p>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Download, Play } from "lucide-react";
|
||||||
|
import { GlassCard } from "@/components/glass/card";
|
||||||
|
import type { VoiceRecording } from "@/lib/types";
|
||||||
|
|
||||||
|
interface RecordingCardProps {
|
||||||
|
recording: VoiceRecording;
|
||||||
|
onPlay: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RecordingCard({ recording, onPlay }: RecordingCardProps) {
|
||||||
|
return (
|
||||||
|
<GlassCard variant="interactive" className="p-4" onClick={() => onPlay(recording.id)}>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => { e.stopPropagation(); onPlay(recording.id); }}
|
||||||
|
className="size-10 flex items-center justify-center rounded-full glass-elevated shrink-0 hover:scale-105 transition-transform"
|
||||||
|
>
|
||||||
|
<Play className="size-4 text-primary ml-0.5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<span className="font-semibold text-text-primary">{recording.username}</span>
|
||||||
|
<span className="text-[10px] text-text-secondary/40 font-mono">{recording.channel_name}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mini waveform bar */}
|
||||||
|
<div className="flex items-end gap-0.5 h-8 my-2">
|
||||||
|
{Array.from({ length: 40 }, (_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="flex-1 rounded-t-sm bg-primary/60"
|
||||||
|
style={{ height: `${20 + Math.sin(i * 0.5) * 15 + Math.random() * 10}%` }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-[10px] font-mono text-text-secondary/60">
|
||||||
|
{recording.duration_bytes ? `${Math.floor(recording.duration_bytes / 60)}:${String(recording.duration_bytes % 60).padStart(2, "0")}` : "--:--"}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-text-secondary/40">{new Date(recording.created_at).toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-1 shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||||
|
{recording.download_url && (
|
||||||
|
<a href={recording.download_url} target="_blank" rel="noopener noreferrer" className="size-7 flex items-center justify-center rounded glass hover:glass-elevated transition-all">
|
||||||
|
<Download className="size-3 text-text-secondary/60" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</GlassCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { Download, Headphones, Trash2 } from "lucide-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 {
|
|
||||||
useDeleteRecording,
|
|
||||||
useRecordings,
|
|
||||||
useRecordingsWsSync,
|
|
||||||
} from "@/hooks";
|
|
||||||
import { formatBytes } from "@/lib/format";
|
|
||||||
import type { WsHook } from "@/lib/ws-hook";
|
|
||||||
|
|
||||||
interface RecordingListProps {
|
|
||||||
ws: WsHook;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function RecordingList({ ws }: RecordingListProps) {
|
|
||||||
const { data: recordings, isLoading } = useRecordings();
|
|
||||||
const deleteMut = useDeleteRecording();
|
|
||||||
|
|
||||||
useRecordingsWsSync(ws);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2 text-sm font-semibold">
|
|
||||||
<Headphones className="size-4 text-primary" />
|
|
||||||
Voice Recordings
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{isLoading ? (
|
|
||||||
<LoadingSkeleton count={5} height="h-16" />
|
|
||||||
) : !recordings || recordings.length === 0 ? (
|
|
||||||
<EmptyState icon={Headphones} title="No recordings yet." />
|
|
||||||
) : (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{recordings.map((rec) => (
|
|
||||||
<div
|
|
||||||
key={rec.id}
|
|
||||||
className="flex items-center gap-3 rounded-lg border border-border/50 p-3 hover:bg-muted/30 transition-colors"
|
|
||||||
>
|
|
||||||
<Avatar className="size-8">
|
|
||||||
<AvatarImage src={rec.avatar_url ?? undefined} />
|
|
||||||
<AvatarFallback>
|
|
||||||
{(rec.username ?? "?").charAt(0).toUpperCase()}
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<p className="text-sm font-medium truncate">{rec.username}</p>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{rec.channel_name ?? rec.channel_id ?? "Unknown channel"} —{" "}
|
|
||||||
{new Date(rec.created_at).toLocaleString()}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className="text-[10px] font-mono shrink-0"
|
|
||||||
>
|
|
||||||
{formatBytes(rec.size_bytes)}
|
|
||||||
</Badge>
|
|
||||||
{rec.download_url && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => {
|
|
||||||
if (rec.download_url)
|
|
||||||
window.open(rec.download_url, "_blank");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Download className="size-4" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => deleteMut.mutate(rec.id)}
|
|
||||||
className="hover:text-destructive hover:bg-destructive/10"
|
|
||||||
>
|
|
||||||
<Trash2 className="size-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { GlassPanel } from "@/components/glass/panel";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
|
||||||
|
interface RecordingPlayerProps {
|
||||||
|
url?: string | null;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RecordingPlayer({ url, onClose }: RecordingPlayerProps) {
|
||||||
|
const audioRef = useRef<HTMLAudioElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (url && audioRef.current) {
|
||||||
|
audioRef.current?.play().catch(() => {});
|
||||||
|
}
|
||||||
|
}, [url]);
|
||||||
|
|
||||||
|
if (!url) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GlassPanel dense className="fixed bottom-20 left-4 z-30 w-72 flex items-center gap-3">
|
||||||
|
<audio ref={audioRef} src={url} controls className="flex-1 h-8 [&::-webkit-media-controls-panel]:bg-transparent" autoPlay />
|
||||||
|
<button type="button" onClick={onClose}>
|
||||||
|
<X className="size-3.5 text-text-secondary/60 hover:text-text-primary" />
|
||||||
|
</button>
|
||||||
|
</GlassPanel>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
import { Card, CardContent } from "@/components/ui/card";
|
|
||||||
import { formatNumber } from "@/lib/format";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
interface DetailStatProps {
|
|
||||||
label: string;
|
|
||||||
value: number;
|
|
||||||
variant?: "default" | "danger" | "success";
|
|
||||||
suffix?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const valueColor = {
|
|
||||||
default: "",
|
|
||||||
danger: "text-red-400",
|
|
||||||
success: "text-emerald-400",
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Small stat label used inside detail views.
|
|
||||||
*/
|
|
||||||
export function DetailStat({
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
variant = "default",
|
|
||||||
suffix,
|
|
||||||
}: DetailStatProps) {
|
|
||||||
return (
|
|
||||||
<Card className="bg-gradient-to-br from-cyan-500/5 to-transparent border-cyan-500/10">
|
|
||||||
<CardContent className="p-3">
|
|
||||||
<p className="text-xs text-muted-foreground/70 tracking-wide">
|
|
||||||
{label}
|
|
||||||
</p>
|
|
||||||
<p
|
|
||||||
className={cn("text-lg font-bold tabular-nums", valueColor[variant])}
|
|
||||||
>
|
|
||||||
{formatNumber(value)}
|
|
||||||
{suffix}
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,26 +1,22 @@
|
|||||||
import type { LucideIcon } from "lucide-react";
|
"use client";
|
||||||
|
|
||||||
|
import { Inbox } from "lucide-react";
|
||||||
|
import { GlassPanel } from "@/components/glass/panel";
|
||||||
|
|
||||||
interface EmptyStateProps {
|
interface EmptyStateProps {
|
||||||
icon: LucideIcon;
|
title?: string;
|
||||||
title: string;
|
|
||||||
description?: string;
|
description?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Consistent empty state for data-fetching pages.
|
|
||||||
*/
|
|
||||||
export function EmptyState({
|
export function EmptyState({
|
||||||
icon: Icon,
|
title = "No data yet",
|
||||||
title,
|
description = "Nothing to display here yet.",
|
||||||
description,
|
|
||||||
}: EmptyStateProps) {
|
}: EmptyStateProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center py-20 text-center">
|
<GlassPanel dense className="flex flex-col items-center gap-2 py-12">
|
||||||
<Icon className="size-10 text-muted-foreground/40 mb-3" />
|
<Inbox className="size-8 text-text-secondary/20" />
|
||||||
<p className="text-sm text-muted-foreground">{title}</p>
|
<p className="text-sm text-text-secondary/60">{title}</p>
|
||||||
{description && (
|
<p className="text-xs text-text-secondary/40">{description}</p>
|
||||||
<p className="text-xs text-muted-foreground/60 mt-1">{description}</p>
|
</GlassPanel>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Component, type ReactNode } from "react";
|
||||||
|
import { GlassCard } from "@/components/glass/card";
|
||||||
|
import { AlertCircle, RefreshCw } from "lucide-react";
|
||||||
|
|
||||||
|
interface Props { children: ReactNode; fallback?: ReactNode; }
|
||||||
|
interface State { hasError: boolean; error?: Error; }
|
||||||
|
|
||||||
|
export class ErrorBoundary extends Component<Props, State> {
|
||||||
|
state: State = { hasError: false };
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): State {
|
||||||
|
return { hasError: true, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
return this.props.fallback || (
|
||||||
|
<GlassCard variant="danger" className="flex flex-col items-center gap-2 py-8">
|
||||||
|
<AlertCircle className="size-6 text-destructive" />
|
||||||
|
<p className="text-sm text-text-secondary">{this.state.error?.message || "Something went wrong"}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => this.setState({ hasError: false })}
|
||||||
|
className="flex items-center gap-1 text-xs text-primary hover:text-primary/80 transition-colors"
|
||||||
|
>
|
||||||
|
<RefreshCw className="size-3" /> Try again
|
||||||
|
</button>
|
||||||
|
</GlassCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
export { DetailStat } from "./detail-stat";
|
|
||||||
export { EmptyState } from "./empty-state";
|
export { EmptyState } from "./empty-state";
|
||||||
|
export { ErrorBoundary } from "./error-boundary";
|
||||||
export { ErrorState } from "./error-state";
|
export { ErrorState } from "./error-state";
|
||||||
export { LoadingSkeleton } from "./loading-skeleton";
|
export { LoadingSkeleton } from "./loading-skeleton";
|
||||||
export { StatCard } from "./stat-card";
|
|
||||||
|
|||||||
@@ -1,38 +1,43 @@
|
|||||||
import { Skeleton } from "@/components/ui/skeleton";
|
"use client";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface LoadingSkeletonProps {
|
interface LoadingSkeletonProps {
|
||||||
/** Number of skeleton rows */
|
|
||||||
count?: number;
|
count?: number;
|
||||||
/** Height per skeleton row */
|
|
||||||
height?: string;
|
height?: string;
|
||||||
/** Grid layout: columns */
|
width?: string;
|
||||||
columns?: number;
|
columns?: number;
|
||||||
/** Additional classes */
|
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Consistent loading skeleton for data-fetching pages.
|
|
||||||
* Renders a grid of skeleton placeholders.
|
|
||||||
*/
|
|
||||||
export function LoadingSkeleton({
|
export function LoadingSkeleton({
|
||||||
count = 4,
|
count = 4,
|
||||||
height = "h-28",
|
height = "h-24",
|
||||||
columns = 1,
|
width,
|
||||||
|
columns,
|
||||||
className,
|
className,
|
||||||
}: LoadingSkeletonProps) {
|
}: LoadingSkeletonProps) {
|
||||||
return (
|
const items = Array.from({ length: count }, (_, i) => (
|
||||||
<div
|
<div
|
||||||
|
key={i}
|
||||||
className={cn(
|
className={cn(
|
||||||
"grid gap-3",
|
"glass rounded-[var(--radius-card)] overflow-hidden",
|
||||||
columns > 1 ? "grid-cols-1 md:grid-cols-2" : "grid-cols-1",
|
height,
|
||||||
|
width,
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{Array.from({ length: count }, (_, i) => (
|
<div className="w-full h-full animate-shimmer" />
|
||||||
<Skeleton key={i} className={cn(height, "rounded-xl")} />
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
));
|
||||||
|
|
||||||
|
if (columns) {
|
||||||
|
return (
|
||||||
|
<div className={`grid grid-cols-1 md:grid-cols-${Math.min(columns, 6)} gap-3`}>
|
||||||
|
{items}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div className="space-y-2">{items}</div>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,78 +0,0 @@
|
|||||||
import type { LucideIcon } from "lucide-react";
|
|
||||||
|
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
|
||||||
import { formatNumber } from "@/lib/format";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
interface StatCardProps {
|
|
||||||
label: string;
|
|
||||||
value: number;
|
|
||||||
icon: LucideIcon;
|
|
||||||
variant?: "default" | "danger" | "success" | "warning";
|
|
||||||
}
|
|
||||||
|
|
||||||
const variantStyles = {
|
|
||||||
default: "from-cyan-500/10 to-teal-500/5 border-cyan-500/20",
|
|
||||||
danger: "from-red-500/10 to-rose-500/5 border-red-500/20",
|
|
||||||
success: "from-emerald-500/10 to-green-500/5 border-emerald-500/20",
|
|
||||||
warning: "from-amber-500/10 to-yellow-500/5 border-amber-500/20",
|
|
||||||
};
|
|
||||||
|
|
||||||
const iconBg = {
|
|
||||||
default: "bg-cyan-500/15 text-cyan-400",
|
|
||||||
danger: "bg-red-500/15 text-red-400",
|
|
||||||
success: "bg-emerald-500/15 text-emerald-400",
|
|
||||||
warning: "bg-amber-500/15 text-amber-400",
|
|
||||||
};
|
|
||||||
|
|
||||||
const valueColor = {
|
|
||||||
default: "",
|
|
||||||
danger: "text-red-400",
|
|
||||||
success: "text-emerald-400",
|
|
||||||
warning: "text-amber-400",
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Metric card used across dashboard and landing pages.
|
|
||||||
*/
|
|
||||||
export function StatCard({
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
icon: Icon,
|
|
||||||
variant = "default",
|
|
||||||
}: StatCardProps) {
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
className={cn(
|
|
||||||
"border bg-gradient-to-br backdrop-blur-sm",
|
|
||||||
variantStyles[variant],
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<p className="text-xs text-muted-foreground/80 tracking-wide">
|
|
||||||
{label}
|
|
||||||
</p>
|
|
||||||
<p
|
|
||||||
className={cn(
|
|
||||||
"text-2xl font-bold tabular-nums tracking-tight",
|
|
||||||
valueColor[variant],
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{formatNumber(value)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex size-9 shrink-0 items-center justify-center rounded-lg",
|
|
||||||
iconBg[variant],
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Icon className="size-4" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { GlassCard } from "@/components/glass/card";
|
||||||
|
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
||||||
|
|
||||||
|
interface VoiceActivityTimelineProps {
|
||||||
|
data?: { user: string; duration: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VoiceActivityTimeline({ data = [] }: VoiceActivityTimelineProps) {
|
||||||
|
return (
|
||||||
|
<GlassCard variant="base">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<span className="text-xs font-semibold tracking-wide uppercase text-text-secondary">Voice Activity</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-40">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={data} layout="vertical">
|
||||||
|
<XAxis type="number" axisLine={false} tickLine={false} tick={{ fill: "oklch(0.55 0.02 245)", fontSize: 10 }} />
|
||||||
|
<YAxis type="category" dataKey="user" axisLine={false} tickLine={false} tick={{ fill: "oklch(0.55 0.02 245)", fontSize: 10 }} width={80} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{ background: "oklch(0.11 0.02 245 / 0.9)", border: "1px solid oklch(1 0 0 / 0.08)", borderRadius: 8, fontSize: 12, color: "oklch(0.93 0.01 245)" }}
|
||||||
|
formatter={(value) => `${Number(value) / 60}m`}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="duration" fill="var(--color-primary)" radius={[0, 4, 4, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</GlassCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { GlassCard } from "@/components/glass/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface ConnectionCardProps {
|
||||||
|
connected: boolean;
|
||||||
|
activeChannelName?: string | null;
|
||||||
|
guilds: { id: string; name: string }[];
|
||||||
|
voiceChannels: { id: string; name: string }[];
|
||||||
|
selectedGuild: string;
|
||||||
|
selectedChannel: string;
|
||||||
|
onGuildChange: (guildId: string | null) => void;
|
||||||
|
onChannelChange: (channelId: string | null) => void;
|
||||||
|
onConnect: () => void;
|
||||||
|
onDisconnect: () => void;
|
||||||
|
connecting?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VoiceConnectionCard({
|
||||||
|
connected, activeChannelName, guilds, voiceChannels,
|
||||||
|
selectedGuild, selectedChannel,
|
||||||
|
onGuildChange, onChannelChange, onConnect, onDisconnect, connecting,
|
||||||
|
}: ConnectionCardProps) {
|
||||||
|
return (
|
||||||
|
<GlassCard variant={connected ? "elevated" : "base"}>
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<span className={cn(
|
||||||
|
"relative flex size-3",
|
||||||
|
connected && "text-emerald-500",
|
||||||
|
)}>
|
||||||
|
<span className={cn(
|
||||||
|
"absolute inline-flex size-full rounded-full opacity-75",
|
||||||
|
connected ? "bg-emerald-500 animate-pulse-ring" : "bg-destructive",
|
||||||
|
)} />
|
||||||
|
<span className={cn(
|
||||||
|
"relative inline-flex size-3 rounded-full",
|
||||||
|
connected ? "bg-emerald-500" : "bg-destructive",
|
||||||
|
)} />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<span className="text-sm font-semibold text-text-primary">Voice Connection</span>
|
||||||
|
{activeChannelName && (
|
||||||
|
<span className="text-xs text-text-secondary/60 ml-2 font-mono">{activeChannelName}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
{connected ? (
|
||||||
|
<Button size="sm" variant="destructive" onClick={onDisconnect}>Disconnect</Button>
|
||||||
|
) : (
|
||||||
|
<Button size="sm" onClick={onConnect} disabled={!selectedGuild || !selectedChannel || connecting}>
|
||||||
|
{connecting ? "Connecting..." : "Connect"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Select value={selectedGuild} onValueChange={(v) => { onGuildChange(v ?? null); onChannelChange(""); }}>
|
||||||
|
<SelectTrigger className="h-8 glass border-glass-border text-xs">
|
||||||
|
<SelectValue placeholder="Select guild" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{guilds.map((g) => (
|
||||||
|
<SelectItem key={g.id} value={g.id}>{g.name}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select value={selectedChannel} onValueChange={onChannelChange} disabled={!selectedGuild}>
|
||||||
|
<SelectTrigger className="h-8 glass border-glass-border text-xs">
|
||||||
|
<SelectValue placeholder="Select channel" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{voiceChannels.map((c) => (
|
||||||
|
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</GlassCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { GlassCard } from "@/components/glass/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Mic, MicOff } from "lucide-react";
|
||||||
|
|
||||||
|
interface MicControlProps {
|
||||||
|
connected: boolean;
|
||||||
|
active: boolean;
|
||||||
|
onToggle: (active: boolean) => void;
|
||||||
|
volume: number;
|
||||||
|
onVolumeChange: (v: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MicControl({ connected, active, onToggle, volume, onVolumeChange }: MicControlProps) {
|
||||||
|
return (
|
||||||
|
<GlassCard variant="base">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button
|
||||||
|
variant={active ? "default" : "secondary"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onToggle(!active)}
|
||||||
|
disabled={!connected}
|
||||||
|
className="h-9"
|
||||||
|
>
|
||||||
|
{active ? <Mic className="size-4 mr-1" /> : <MicOff className="size-4 mr-1" />}
|
||||||
|
{active ? "Live" : "Muted"}
|
||||||
|
</Button>
|
||||||
|
<div className="flex-1 flex items-center gap-2">
|
||||||
|
<span className="text-[10px] text-text-secondary/60 font-mono">Vol</span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
value={volume}
|
||||||
|
onChange={(e) => onVolumeChange(Number(e.target.value))}
|
||||||
|
className="flex-1 h-1 appearance-none bg-glass-border rounded-full accent-primary [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:size-3 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-primary [&::-webkit-slider-thumb]:shadow-[0_0_8px] [&::-webkit-slider-thumb]:shadow-primary/60"
|
||||||
|
/>
|
||||||
|
<span className="text-[10px] font-mono text-text-secondary w-8 text-right">{volume}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</GlassCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { GlassPanel } from "@/components/glass/panel";
|
||||||
|
import type { ActiveSpeaker } from "@/lib/types";
|
||||||
|
|
||||||
|
interface SpeakerWaveformProps {
|
||||||
|
speakers: ActiveSpeaker[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SpeakerWaveform({ speakers }: SpeakerWaveformProps) {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const animRef = useRef<number>(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas || speakers.length === 0) return;
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
const draw = () => {
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
const barCount = 40;
|
||||||
|
const barWidth = canvas.width / barCount - 1;
|
||||||
|
|
||||||
|
speakers.forEach((speaker, si) => {
|
||||||
|
const yBase = si * 30 + 10;
|
||||||
|
for (let i = 0; i < barCount; i++) {
|
||||||
|
const height = speaker.speaking
|
||||||
|
? Math.random() * 20 + 4
|
||||||
|
: Math.random() * 4 + 2;
|
||||||
|
const x = i * (barWidth + 1);
|
||||||
|
const hue = 185 + si * 30;
|
||||||
|
ctx.fillStyle = `oklch(0.62 ${0.12 + si * 0.02} ${hue} / ${speaker.speaking ? 0.9 : 0.3})`;
|
||||||
|
ctx.fillRect(x, yBase + 20 - height, barWidth, height);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
animRef.current = requestAnimationFrame(draw);
|
||||||
|
};
|
||||||
|
|
||||||
|
draw();
|
||||||
|
return () => cancelAnimationFrame(animRef.current);
|
||||||
|
}, [speakers]);
|
||||||
|
|
||||||
|
if (speakers.length === 0) {
|
||||||
|
return (
|
||||||
|
<GlassPanel dense>
|
||||||
|
<span className="text-xs text-text-secondary/40">No speakers detected</span>
|
||||||
|
</GlassPanel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GlassPanel dense>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{speakers.map((s) => (
|
||||||
|
<div key={s.userId} className="flex items-center gap-2 text-xs">
|
||||||
|
<span className={s.speaking ? "text-primary font-medium" : "text-text-secondary/60"}>{s.username}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<canvas ref={canvasRef} width={400} height={speakers.length * 30} className="w-full h-auto mt-2 rounded" />
|
||||||
|
</GlassPanel>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { createContext, useContext, useState, type ReactNode } from "react";
|
||||||
|
|
||||||
|
interface Track {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
artist?: string;
|
||||||
|
duration?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MediaPlayerState {
|
||||||
|
currentTrack: Track | null;
|
||||||
|
queue: Track[];
|
||||||
|
playing: boolean;
|
||||||
|
volume: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MediaPlayerContextType extends MediaPlayerState {
|
||||||
|
play: (track: Track) => void;
|
||||||
|
skip: () => void;
|
||||||
|
stop: () => void;
|
||||||
|
setVolume: (v: number) => void;
|
||||||
|
addToQueue: (track: Track) => void;
|
||||||
|
removeFromQueue: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MediaPlayerContext = createContext<MediaPlayerContextType | null>(null);
|
||||||
|
|
||||||
|
export function MediaPlayerProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [state, setState] = useState<MediaPlayerState>({
|
||||||
|
currentTrack: null,
|
||||||
|
queue: [],
|
||||||
|
playing: false,
|
||||||
|
volume: 75,
|
||||||
|
});
|
||||||
|
|
||||||
|
const play = (track: Track) => {
|
||||||
|
setState((prev) => ({ ...prev, currentTrack: track, playing: true }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const skip = () => {
|
||||||
|
setState((prev) => {
|
||||||
|
if (prev.queue.length === 0) return { ...prev, currentTrack: null, playing: false };
|
||||||
|
const [next, ...rest] = prev.queue;
|
||||||
|
return { ...prev, currentTrack: next, queue: rest };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const stop = () => {
|
||||||
|
setState((prev) => ({ ...prev, currentTrack: null, playing: false }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const setVolume = (volume: number) => {
|
||||||
|
setState((prev) => ({ ...prev, volume }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const addToQueue = (track: Track) => {
|
||||||
|
setState((prev) => ({ ...prev, queue: [...prev.queue, track] }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeFromQueue = (id: string) => {
|
||||||
|
setState((prev) => ({ ...prev, queue: prev.queue.filter((t) => t.id !== id) }));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MediaPlayerContext.Provider value={{ ...state, play, skip, stop, setVolume, addToQueue, removeFromQueue }}>
|
||||||
|
{children}
|
||||||
|
</MediaPlayerContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMediaPlayer() {
|
||||||
|
const ctx = useContext(MediaPlayerContext);
|
||||||
|
if (!ctx) throw new Error("useMediaPlayer must be used within MediaPlayerProvider");
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user