feat: add analytics hooks and routes for moderation statistics
- Implemented `useAnalytics` hook for fetching and managing analytics data. - Created `analyticsStore.ts` to handle database queries for hourly stats, topic trends, user leaderboard, and moderation stats. - Added Express routes for analytics endpoints including overview, hourly stats, topic trends, user leaderboard, moderation stats, and top violators. - Introduced a utility function `filterHits` for filtering specific terms in text.
This commit is contained in:
+13
-2
@@ -7,6 +7,7 @@ import { Tabs, TabsContent } from "./components/ui/tabs";
|
|||||||
import { VoicePanel } from "./components/voice/VoicePanel";
|
import { VoicePanel } from "./components/voice/VoicePanel";
|
||||||
import { RecordingsPanel } from "./components/recordings/RecordingsPanel";
|
import { RecordingsPanel } from "./components/recordings/RecordingsPanel";
|
||||||
import { AuthOverlay } from "./components/layout/AuthOverlay";
|
import { AuthOverlay } from "./components/layout/AuthOverlay";
|
||||||
|
import { AnalyticsPanel } from "./components/analytics/AnalyticsPanel";
|
||||||
import { useDashboardSocket } from "./hooks/useDashboardSocket";
|
import { useDashboardSocket } from "./hooks/useDashboardSocket";
|
||||||
import { mergeMessages, useMessages } from "./hooks/useMessages";
|
import { mergeMessages, useMessages } from "./hooks/useMessages";
|
||||||
import { useMediaControl } from "./hooks/useMediaControl";
|
import { useMediaControl } from "./hooks/useMediaControl";
|
||||||
@@ -183,7 +184,7 @@ export default function App() {
|
|||||||
await patchUIState({ isListening: true });
|
await patchUIState({ isListening: true });
|
||||||
}, [isListening, patchUIState]);
|
}, [isListening, patchUIState]);
|
||||||
|
|
||||||
const tabs = useMemo(() => ["voice", "media", "messages", "recordings", "review"] as DashboardTab[], []);
|
const tabs = useMemo(() => ["voice", "media", "messages", "recordings", "analytics", "review"] as DashboardTab[], []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardLayout
|
<DashboardLayout
|
||||||
@@ -194,7 +195,7 @@ export default function App() {
|
|||||||
>
|
>
|
||||||
<div className="md:hidden">
|
<div className="md:hidden">
|
||||||
<Tabs value={activeTab} onValueChange={(value) => patchUIState({ activeTab: value as DashboardTab })}>
|
<Tabs value={activeTab} onValueChange={(value) => patchUIState({ activeTab: value as DashboardTab })}>
|
||||||
<div className="mb-4 grid grid-cols-5 gap-2 rounded-2xl bg-muted p-1">
|
<div className="mb-4 grid grid-cols-3 gap-1.5 rounded-2xl bg-muted p-1 sm:grid-cols-6">
|
||||||
{tabs.map((tab) => (
|
{tabs.map((tab) => (
|
||||||
<button key={tab} className={`rounded-xl px-2 py-2 text-xs font-medium ${activeTab === tab ? "bg-background text-foreground" : "text-muted-foreground"}`} onClick={() => patchUIState({ activeTab: tab })}>
|
<button key={tab} className={`rounded-xl px-2 py-2 text-xs font-medium ${activeTab === tab ? "bg-background text-foreground" : "text-muted-foreground"}`} onClick={() => patchUIState({ activeTab: tab })}>
|
||||||
{tab}
|
{tab}
|
||||||
@@ -262,6 +263,16 @@ export default function App() {
|
|||||||
<RecordingsPanel />
|
<RecordingsPanel />
|
||||||
)}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
<TabsContent value="analytics">
|
||||||
|
<AnalyticsPanel
|
||||||
|
guilds={voice.guilds}
|
||||||
|
channels={voice.textChannels}
|
||||||
|
selectedGuild={selectedTextGuild}
|
||||||
|
selectedChannel={selectedTextChannel}
|
||||||
|
onGuildChange={(guildId) => patchUIState({ selectedTextGuild: guildId, selectedTextChannel: "" })}
|
||||||
|
onChannelChange={(channelId) => patchUIState({ selectedTextChannel: channelId })}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
<TabsContent value="review">
|
<TabsContent value="review">
|
||||||
<ReviewPanel messages={messages.messages} onReanalyze={messages.reanalyze} />
|
<ReviewPanel messages={messages.messages} onReanalyze={messages.reanalyze} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import { request } from "./client";
|
||||||
|
|
||||||
|
export interface ViolatorStat {
|
||||||
|
user_id: string;
|
||||||
|
username: string;
|
||||||
|
avatar_url: string | null;
|
||||||
|
total_messages: number;
|
||||||
|
flagged_count: number;
|
||||||
|
warned_count: number;
|
||||||
|
violation_score: number;
|
||||||
|
worst_flags: string[];
|
||||||
|
last_violation: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HourlyBucket {
|
||||||
|
hour: string;
|
||||||
|
count: number;
|
||||||
|
clean: number;
|
||||||
|
warned: number;
|
||||||
|
flagged: number;
|
||||||
|
error: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TopicTrend {
|
||||||
|
topic: string;
|
||||||
|
count: number;
|
||||||
|
score: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserStat {
|
||||||
|
user_id: string;
|
||||||
|
username: string;
|
||||||
|
avatar_url: string | null;
|
||||||
|
message_count: number;
|
||||||
|
edited_count: number;
|
||||||
|
deleted_count: number;
|
||||||
|
flagged_count: number;
|
||||||
|
last_active: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModerationBreakdown {
|
||||||
|
total: number;
|
||||||
|
clean: number;
|
||||||
|
warned: number;
|
||||||
|
flagged: number;
|
||||||
|
error: number;
|
||||||
|
pending: number;
|
||||||
|
average_score: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsOverview {
|
||||||
|
period: { start: number; end: number };
|
||||||
|
messages: ModerationBreakdown;
|
||||||
|
hourly: HourlyBucket[];
|
||||||
|
topics: TopicTrend[];
|
||||||
|
top_users: UserStat[];
|
||||||
|
active_users_count: number;
|
||||||
|
total_channels: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAnalyticsOverview(params: {
|
||||||
|
guildId: string;
|
||||||
|
channelId?: string;
|
||||||
|
hours?: number;
|
||||||
|
}): Promise<AnalyticsOverview> {
|
||||||
|
const searchParams = new URLSearchParams({
|
||||||
|
guildId: params.guildId,
|
||||||
|
...(params.channelId && { channelId: params.channelId }),
|
||||||
|
...(params.hours && { hours: String(params.hours) }),
|
||||||
|
});
|
||||||
|
return request<AnalyticsOverview>(`/api/analytics/overview?${searchParams}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchHourlyStats(params: {
|
||||||
|
guildId: string;
|
||||||
|
channelId?: string;
|
||||||
|
hours?: number;
|
||||||
|
}): Promise<HourlyBucket[]> {
|
||||||
|
const searchParams = new URLSearchParams({
|
||||||
|
guildId: params.guildId,
|
||||||
|
...(params.channelId && { channelId: params.channelId }),
|
||||||
|
...(params.hours && { hours: String(params.hours) }),
|
||||||
|
});
|
||||||
|
return request<HourlyBucket[]>(`/api/analytics/hourly?${searchParams}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchTopicTrends(params: {
|
||||||
|
guildId: string;
|
||||||
|
channelId?: string;
|
||||||
|
hours?: number;
|
||||||
|
}): Promise<TopicTrend[]> {
|
||||||
|
const searchParams = new URLSearchParams({
|
||||||
|
guildId: params.guildId,
|
||||||
|
...(params.channelId && { channelId: params.channelId }),
|
||||||
|
...(params.hours && { hours: String(params.hours) }),
|
||||||
|
});
|
||||||
|
return request<TopicTrend[]>(`/api/analytics/topics?${searchParams}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchLeaderboard(params: {
|
||||||
|
guildId: string;
|
||||||
|
channelId?: string;
|
||||||
|
hours?: number;
|
||||||
|
limit?: number;
|
||||||
|
}): Promise<UserStat[]> {
|
||||||
|
const searchParams = new URLSearchParams({
|
||||||
|
guildId: params.guildId,
|
||||||
|
...(params.channelId && { channelId: params.channelId }),
|
||||||
|
...(params.hours && { hours: String(params.hours) }),
|
||||||
|
...(params.limit && { limit: String(params.limit) }),
|
||||||
|
});
|
||||||
|
return request<UserStat[]>(`/api/analytics/leaderboard?${searchParams}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchModerationStats(params: {
|
||||||
|
guildId: string;
|
||||||
|
channelId?: string;
|
||||||
|
hours?: number;
|
||||||
|
}): Promise<ModerationBreakdown> {
|
||||||
|
const searchParams = new URLSearchParams({
|
||||||
|
guildId: params.guildId,
|
||||||
|
...(params.channelId && { channelId: params.channelId }),
|
||||||
|
...(params.hours && { hours: String(params.hours) }),
|
||||||
|
});
|
||||||
|
return request<ModerationBreakdown>(`/api/analytics/stats?${searchParams}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchViolators(params: {
|
||||||
|
guildId: string;
|
||||||
|
channelId?: string;
|
||||||
|
hours?: number;
|
||||||
|
limit?: number;
|
||||||
|
}): Promise<ViolatorStat[]> {
|
||||||
|
const searchParams = new URLSearchParams({
|
||||||
|
guildId: params.guildId,
|
||||||
|
...(params.channelId && { channelId: params.channelId }),
|
||||||
|
...(params.hours && { hours: String(params.hours) }),
|
||||||
|
...(params.limit && { limit: String(params.limit) }),
|
||||||
|
});
|
||||||
|
return request<ViolatorStat[]>(`/api/analytics/violators?${searchParams}`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,998 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { motion, AnimatePresence } from "motion/react";
|
||||||
|
import {
|
||||||
|
Activity,
|
||||||
|
AlertTriangle,
|
||||||
|
BarChart3,
|
||||||
|
CheckCircle2,
|
||||||
|
Clock,
|
||||||
|
Flame,
|
||||||
|
MessageSquare,
|
||||||
|
Shield,
|
||||||
|
Siren,
|
||||||
|
TrendingDown,
|
||||||
|
TrendingUp,
|
||||||
|
Users,
|
||||||
|
XCircle,
|
||||||
|
} from "lucide-react";
|
||||||
|
import type { Channel, Guild } from "../../types/voice";
|
||||||
|
import { useAnalytics } from "../../hooks/useAnalytics";
|
||||||
|
import type { AnalyticsOverview, HourlyBucket, TopicTrend, UserStat, ViolatorStat } from "../../api/analytics";
|
||||||
|
import { fetchViolators } from "../../api/analytics";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
|
||||||
|
import { Select } from "../ui/select";
|
||||||
|
import { Button } from "../ui/button";
|
||||||
|
import { Badge } from "../ui/badge";
|
||||||
|
import { ScrollArea } from "../ui/scroll-area";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
|
const TIME_RANGES = [
|
||||||
|
{ label: "1h", value: 1 },
|
||||||
|
{ label: "3h", value: 3 },
|
||||||
|
{ label: "6h", value: 6 },
|
||||||
|
{ label: "12h", value: 12 },
|
||||||
|
{ label: "24h", value: 24 },
|
||||||
|
{ label: "48h", value: 48 },
|
||||||
|
{ label: "7d", value: 168 },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface AnalyticsPanelProps {
|
||||||
|
guilds: Guild[];
|
||||||
|
channels: Channel[];
|
||||||
|
selectedGuild: string;
|
||||||
|
selectedChannel: string;
|
||||||
|
onGuildChange: (guildId: string) => void;
|
||||||
|
onChannelChange: (channelId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Color Palette ──────────────────────────────────────────────────────
|
||||||
|
const GLOW_COLORS = {
|
||||||
|
clean: "from-emerald-500/20 via-emerald-500/5 to-transparent",
|
||||||
|
warned: "from-amber-500/20 via-amber-500/5 to-transparent",
|
||||||
|
flagged: "from-red-500/20 via-red-500/5 to-transparent",
|
||||||
|
error: "from-orange-500/20 via-orange-500/5 to-transparent",
|
||||||
|
neutral: "from-blue-500/15 via-blue-500/5 to-transparent",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AnalyticsPanel({
|
||||||
|
guilds,
|
||||||
|
channels,
|
||||||
|
selectedGuild,
|
||||||
|
selectedChannel,
|
||||||
|
onGuildChange,
|
||||||
|
onChannelChange,
|
||||||
|
}: AnalyticsPanelProps) {
|
||||||
|
const [hours, setHours] = useState(24);
|
||||||
|
const [violators, setViolators] = useState<ViolatorStat[]>([]);
|
||||||
|
const [violatorsLoading, setViolatorsLoading] = useState(false);
|
||||||
|
|
||||||
|
const { overview, loading, error, refresh } = useAnalytics({
|
||||||
|
guildId: selectedGuild,
|
||||||
|
channelId: selectedChannel || undefined,
|
||||||
|
hours,
|
||||||
|
});
|
||||||
|
|
||||||
|
const loadViolators = useCallback(async () => {
|
||||||
|
if (!selectedGuild) return;
|
||||||
|
setViolatorsLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await fetchViolators({
|
||||||
|
guildId: selectedGuild,
|
||||||
|
channelId: selectedChannel || undefined,
|
||||||
|
hours,
|
||||||
|
limit: 20,
|
||||||
|
});
|
||||||
|
setViolators(data);
|
||||||
|
} catch {
|
||||||
|
// silent
|
||||||
|
} finally {
|
||||||
|
setViolatorsLoading(false);
|
||||||
|
}
|
||||||
|
}, [selectedGuild, selectedChannel, hours]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadViolators();
|
||||||
|
}, [loadViolators]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-6">
|
||||||
|
{/* ── Control Bar ─────────────────────────────────────────────── */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: -10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
>
|
||||||
|
<Card className="overflow-hidden border-0 bg-gradient-to-r from-card via-card to-blue-950/20 shadow-lg shadow-blue-500/5">
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-r from-blue-500/5 via-transparent to-transparent pointer-events-none" />
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-2xl">
|
||||||
|
<BarChart3 className="h-6 w-6 text-blue-400" />
|
||||||
|
Analytics & Insights
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Pantau statistik moderasi, topik trending, dan aktivitas user dalam satu dasbor.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid gap-4 md:grid-cols-3 lg:grid-cols-4">
|
||||||
|
<Select
|
||||||
|
value={selectedGuild}
|
||||||
|
onChange={(e) => onGuildChange(e.target.value)}
|
||||||
|
placeholder="Select guild"
|
||||||
|
options={guilds.map((g) => ({ value: g.id, label: g.name }))}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
value={selectedChannel}
|
||||||
|
onChange={(e) => onChannelChange(e.target.value)}
|
||||||
|
placeholder="All channels"
|
||||||
|
options={[
|
||||||
|
{ value: "", label: "All channels" },
|
||||||
|
...channels.map((c) => ({ value: c.id, label: c.name })),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<div className="flex gap-1 rounded-xl bg-muted/50 p-1 backdrop-blur">
|
||||||
|
{TIME_RANGES.map((tr) => (
|
||||||
|
<button
|
||||||
|
key={tr.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setHours(tr.value)}
|
||||||
|
className={cn(
|
||||||
|
"relative flex-1 rounded-lg px-2 py-1.5 text-xs font-medium transition-all",
|
||||||
|
hours === tr.value
|
||||||
|
? "bg-background text-foreground shadow-sm"
|
||||||
|
: "text-muted-foreground hover:text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{hours === tr.value && (
|
||||||
|
<motion.div
|
||||||
|
layoutId="timeRangeActive"
|
||||||
|
className="absolute inset-0 rounded-lg bg-background shadow-sm"
|
||||||
|
transition={{ type: "spring", bounce: 0.2, duration: 0.4 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="relative z-10">{tr.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={() => { refresh(); loadViolators(); }}
|
||||||
|
disabled={loading}
|
||||||
|
className="bg-gradient-to-r from-blue-600 to-blue-500 hover:from-blue-500 hover:to-blue-400 text-white shadow-lg shadow-blue-500/25 transition-all hover:shadow-blue-500/40"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<motion.span
|
||||||
|
animate={{ rotate: 360 }}
|
||||||
|
transition={{ repeat: Number.POSITIVE_INFINITY, duration: 1, ease: "linear" }}
|
||||||
|
className="inline-block h-3.5 w-3.5 rounded-full border-2 border-white/30 border-t-white"
|
||||||
|
/>
|
||||||
|
Loading...
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<Activity className="h-4 w-4" />
|
||||||
|
Refresh
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }}>
|
||||||
|
<Card className="border-red-500/30 bg-red-500/5">
|
||||||
|
<CardContent className="flex items-center gap-3 py-4">
|
||||||
|
<XCircle className="h-5 w-5 shrink-0 text-red-400" />
|
||||||
|
<p className="text-sm text-red-300">{error}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!selectedGuild ? (
|
||||||
|
<EmptyState icon={BarChart3} text="Pilih guild untuk melihat analitik." />
|
||||||
|
) : (
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
<motion.div
|
||||||
|
key={`${selectedGuild}-${hours}`}
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="grid gap-6"
|
||||||
|
>
|
||||||
|
{/* ── KPI Stat Cards ─────────────────────────────────────── */}
|
||||||
|
<StatsGrid overview={overview} loading={loading} totalChannels={overview?.total_channels ?? 0} />
|
||||||
|
|
||||||
|
{/* ── Hourly Activity Chart ──────────────────────────────── */}
|
||||||
|
<AnimatedCard glow="neutral">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Activity className="h-5 w-5 text-blue-400" />
|
||||||
|
Aktivitas Pesan Per Jam
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Distribusi pesan per jam dengan breakdown status moderasi.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<HourlyChart hourly={overview?.hourly} loading={loading} />
|
||||||
|
</CardContent>
|
||||||
|
</AnimatedCard>
|
||||||
|
|
||||||
|
{/* ── Topics + Leaderboard row ───────────────────────────── */}
|
||||||
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
|
<AnimatedCard glow="neutral">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Flame className="h-5 w-5 text-orange-400" />
|
||||||
|
Topik Trending
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Yang paling ramai dibicarakan orang.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<TopicCloud topics={overview?.topics} loading={loading} />
|
||||||
|
</CardContent>
|
||||||
|
</AnimatedCard>
|
||||||
|
|
||||||
|
<AnimatedCard glow="neutral">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Users className="h-5 w-5 text-violet-400" />
|
||||||
|
User Paling Aktif
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Leaderboard berdasarkan jumlah pesan.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<UserLeaderboard users={overview?.top_users} loading={loading} compact />
|
||||||
|
</CardContent>
|
||||||
|
</AnimatedCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── VIOLATORS LEADERBOARD ──────────────────────────────── */}
|
||||||
|
<AnimatedCard glow="flagged">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Siren className="h-5 w-5 text-red-400" />
|
||||||
|
Pelanggar Terbanyak
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
User dengan skor pelanggaran tertinggi (flagged × 3 + warned × 1).
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Badge variant="destructive" className="animate-pulse">
|
||||||
|
{violators.length} pelanggar
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<ViolatorsLeaderboard users={violators} loading={violatorsLoading} />
|
||||||
|
</CardContent>
|
||||||
|
</AnimatedCard>
|
||||||
|
|
||||||
|
{/* ── Full User Leaderboard ──────────────────────────────── */}
|
||||||
|
<AnimatedCard glow="neutral">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Users className="h-5 w-5 text-cyan-400" />
|
||||||
|
Leaderboard Lengkap
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Detail aktivitas user: pesan, edit, hapus, flag, dan waktu aktif terakhir.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<UserLeaderboard users={overview?.top_users} loading={loading} />
|
||||||
|
</CardContent>
|
||||||
|
</AnimatedCard>
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════════════════
|
||||||
|
// SUB-COMPONENTS
|
||||||
|
// ══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ── Animated Card Wrapper ──────────────────────────────────────────────
|
||||||
|
function AnimatedCard({
|
||||||
|
children,
|
||||||
|
glow,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
glow?: keyof typeof GLOW_COLORS;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const glowClass = GLOW_COLORS[glow ?? "neutral"];
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true, margin: "-50px" }}
|
||||||
|
transition={{ duration: 0.4, ease: "easeOut" }}
|
||||||
|
className={cn("group relative", className)}
|
||||||
|
>
|
||||||
|
{/* Animated border glow on hover */}
|
||||||
|
<div className="absolute -inset-px rounded-2xl bg-gradient-to-r from-blue-500/0 via-blue-500/0 to-blue-500/0 opacity-0 transition-all duration-500 group-hover:from-blue-500/20 group-hover:via-violet-500/10 group-hover:to-blue-500/20 group-hover:opacity-100 blur-md pointer-events-none" />
|
||||||
|
<Card className="relative overflow-hidden border-muted/60 bg-card/80 backdrop-blur shadow-lg transition-shadow group-hover:shadow-xl group-hover:shadow-blue-500/5">
|
||||||
|
<div className={cn("absolute inset-0 bg-gradient-to-b pointer-events-none", glowClass)} />
|
||||||
|
{children}
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Stats Grid ─────────────────────────────────────────────────────────
|
||||||
|
function StatsGrid({
|
||||||
|
overview,
|
||||||
|
loading,
|
||||||
|
totalChannels,
|
||||||
|
}: {
|
||||||
|
overview: AnalyticsOverview | null;
|
||||||
|
loading: boolean;
|
||||||
|
totalChannels: number;
|
||||||
|
}) {
|
||||||
|
const cards = [
|
||||||
|
{
|
||||||
|
label: "Total Pesan",
|
||||||
|
value: overview?.messages.total ?? null,
|
||||||
|
icon: MessageSquare,
|
||||||
|
color: "text-blue-400",
|
||||||
|
bg: "bg-blue-500/10",
|
||||||
|
border: "border-blue-500/20",
|
||||||
|
sub: totalChannels > 0 ? `${totalChannels} channel` : "",
|
||||||
|
trend: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Clean",
|
||||||
|
value: overview?.messages.clean ?? null,
|
||||||
|
icon: CheckCircle2,
|
||||||
|
color: "text-emerald-400",
|
||||||
|
bg: "bg-emerald-500/10",
|
||||||
|
border: "border-emerald-500/20",
|
||||||
|
sub: overview ? `${pct(overview.messages.clean, overview.messages.total)}%` : "",
|
||||||
|
trend: "up",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Warned",
|
||||||
|
value: overview?.messages.warned ?? null,
|
||||||
|
icon: AlertTriangle,
|
||||||
|
color: "text-amber-400",
|
||||||
|
bg: "bg-amber-500/10",
|
||||||
|
border: "border-amber-500/20",
|
||||||
|
sub: overview ? `${pct(overview.messages.warned, overview.messages.total)}%` : "",
|
||||||
|
trend: overview && overview.messages.warned > 0 ? "down" : null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Flagged",
|
||||||
|
value: overview?.messages.flagged ?? null,
|
||||||
|
icon: Siren,
|
||||||
|
color: "text-red-400",
|
||||||
|
bg: "bg-red-500/10",
|
||||||
|
border: "border-red-500/20",
|
||||||
|
sub: overview ? `${pct(overview.messages.flagged, overview.messages.total)}%` : "",
|
||||||
|
trend: overview && overview.messages.flagged > 0 ? "down" : null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Error",
|
||||||
|
value: overview?.messages.error ?? null,
|
||||||
|
icon: XCircle,
|
||||||
|
color: "text-orange-400",
|
||||||
|
bg: "bg-orange-500/10",
|
||||||
|
border: "border-orange-500/20",
|
||||||
|
sub: null,
|
||||||
|
trend: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Pending",
|
||||||
|
value: overview?.messages.pending ?? null,
|
||||||
|
icon: Clock,
|
||||||
|
color: "text-slate-400",
|
||||||
|
bg: "bg-slate-500/10",
|
||||||
|
border: "border-slate-500/20",
|
||||||
|
sub: null,
|
||||||
|
trend: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Rata-rata Skor",
|
||||||
|
value: overview?.messages.average_score ?? null,
|
||||||
|
icon: Shield,
|
||||||
|
color: "text-cyan-400",
|
||||||
|
bg: "bg-cyan-500/10",
|
||||||
|
border: "border-cyan-500/20",
|
||||||
|
sub: null,
|
||||||
|
trend: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "User Aktif",
|
||||||
|
value: overview?.active_users_count ?? null,
|
||||||
|
icon: Users,
|
||||||
|
color: "text-violet-400",
|
||||||
|
bg: "bg-violet-500/10",
|
||||||
|
border: "border-violet-500/20",
|
||||||
|
sub: null,
|
||||||
|
trend: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-4 grid-cols-2 sm:grid-cols-3 lg:grid-cols-4">
|
||||||
|
{cards.map((card, i) => {
|
||||||
|
const Icon = card.icon;
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
key={card.label}
|
||||||
|
initial={{ opacity: 0, scale: 0.9 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
transition={{ delay: i * 0.05, duration: 0.3 }}
|
||||||
|
>
|
||||||
|
<Card className={cn("group relative overflow-hidden border transition-all hover:shadow-lg", card.border, card.bg)}>
|
||||||
|
{/* Background pulse */}
|
||||||
|
<div className={cn("absolute -right-4 -top-4 h-20 w-20 rounded-full opacity-20 blur-xl transition-opacity group-hover:opacity-30", card.color.replace("text-", "bg-"))} />
|
||||||
|
<CardContent className="relative py-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
|
||||||
|
{card.label}
|
||||||
|
</p>
|
||||||
|
<Icon className={cn("h-4 w-4 opacity-50", card.color)} />
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex items-end gap-2">
|
||||||
|
<span className={cn("text-3xl font-bold tabular-nums tracking-tight", card.color)}>
|
||||||
|
{loading ? (
|
||||||
|
<motion.span
|
||||||
|
animate={{ opacity: [0.4, 1, 0.4] }}
|
||||||
|
transition={{ repeat: Number.POSITIVE_INFINITY, duration: 1.5 }}
|
||||||
|
>
|
||||||
|
…
|
||||||
|
</motion.span>
|
||||||
|
) : (
|
||||||
|
card.value ?? "—"
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{card.trend && (
|
||||||
|
<span className="pb-1">
|
||||||
|
{card.trend === "up" ? (
|
||||||
|
<TrendingUp className="h-3.5 w-3.5 text-emerald-400" />
|
||||||
|
) : (
|
||||||
|
<TrendingDown className="h-3.5 w-3.5 text-red-400" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{card.sub && (
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">{card.sub}</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Hourly Chart ───────────────────────────────────────────────────────
|
||||||
|
function HourlyChart({ hourly, loading }: { hourly: HourlyBucket[] | undefined; loading: boolean }) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
if (loading && !hourly?.length) {
|
||||||
|
return <LoadingSkeleton />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hourly?.length) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-56 flex-col items-center justify-center gap-2 text-muted-foreground">
|
||||||
|
<BarChart3 className="h-10 w-10 opacity-20" />
|
||||||
|
<p className="text-sm">Belum ada data untuk periode ini.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxCount = Math.max(...hourly.map((b) => b.count), 1);
|
||||||
|
const labels = hourly.map((b) => b.hour.slice(11, 16));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className="space-y-3">
|
||||||
|
<div className="relative flex h-52 items-end gap-[2px]">
|
||||||
|
{/* Grid lines */}
|
||||||
|
{[0.25, 0.5, 0.75, 1].map((pct) => (
|
||||||
|
<div
|
||||||
|
key={pct}
|
||||||
|
className="absolute left-0 right-0 border-t border-white/[0.04]"
|
||||||
|
style={{ bottom: `${pct * 100}%` }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{hourly.map((bucket, i) => {
|
||||||
|
const heightPct = (bucket.count / maxCount) * 100;
|
||||||
|
const total = bucket.clean + bucket.warned + bucket.flagged + bucket.error || 1;
|
||||||
|
const cleanH = (bucket.clean / total) * heightPct;
|
||||||
|
const warnedH = (bucket.warned / total) * heightPct;
|
||||||
|
const flaggedH = (bucket.flagged / total) * heightPct;
|
||||||
|
const errorH = (bucket.error / total) * heightPct;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
key={bucket.hour}
|
||||||
|
initial={{ height: 0 }}
|
||||||
|
animate={{ height: `${heightPct}%` }}
|
||||||
|
transition={{ delay: i * 0.02, duration: 0.5, ease: "easeOut" }}
|
||||||
|
className="group relative flex flex-1 flex-col justify-end"
|
||||||
|
>
|
||||||
|
{/* Stacked segments */}
|
||||||
|
<div className="relative w-full" style={{ height: `${heightPct}%` }}>
|
||||||
|
<div
|
||||||
|
className="absolute bottom-0 w-full rounded-t-sm bg-emerald-500/80 transition-colors hover:bg-emerald-400"
|
||||||
|
style={{ height: `${cleanH}%` }}
|
||||||
|
title={`Clean: ${bucket.clean}`}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="absolute w-full bg-amber-500/80 transition-colors hover:bg-amber-400"
|
||||||
|
style={{ bottom: `${cleanH}%`, height: `${warnedH}%` }}
|
||||||
|
title={`Warned: ${bucket.warned}`}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="absolute w-full bg-red-500/80 transition-colors hover:bg-red-400"
|
||||||
|
style={{ bottom: `${cleanH + warnedH}%`, height: `${flaggedH}%` }}
|
||||||
|
title={`Flagged: ${bucket.flagged}`}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="absolute top-0 w-full rounded-t-sm bg-orange-500/60 transition-colors hover:bg-orange-400"
|
||||||
|
style={{ height: `${errorH}%` }}
|
||||||
|
title={`Error: ${bucket.error}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/* Hover tooltip */}
|
||||||
|
<div className="absolute -top-10 left-1/2 z-20 -translate-x-1/2 whitespace-nowrap rounded-lg bg-popover px-2.5 py-1.5 text-xs font-medium text-popover-foreground opacity-0 shadow-lg transition-opacity group-hover:opacity-100 pointer-events-none">
|
||||||
|
{bucket.hour.slice(11, 16)} — {bucket.count} msgs
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{/* X-axis labels */}
|
||||||
|
<div className="flex justify-between px-1">
|
||||||
|
{labels.filter((_, i) => i % Math.max(1, Math.floor(labels.length / 6)) === 0 || i === labels.length - 1).map((label, i) => (
|
||||||
|
<span key={i} className="text-[10px] text-muted-foreground tabular-nums">{label}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{/* Legend */}
|
||||||
|
<div className="flex flex-wrap items-center gap-5 text-xs text-muted-foreground">
|
||||||
|
<Legend color="bg-emerald-500/80" label="Clean" />
|
||||||
|
<Legend color="bg-amber-500/80" label="Warned" />
|
||||||
|
<Legend color="bg-red-500/80" label="Flagged" />
|
||||||
|
<Legend color="bg-orange-500/60" label="Error" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Legend({ color, label }: { color: string; label: string }) {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<span className={cn("inline-block h-2.5 w-2.5 rounded-sm", color)} />
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Topic Cloud ────────────────────────────────────────────────────────
|
||||||
|
const TOPIC_GRADIENTS = [
|
||||||
|
"from-blue-500/30 via-blue-500/15 to-blue-600/20",
|
||||||
|
"from-emerald-500/30 via-emerald-500/15 to-emerald-600/20",
|
||||||
|
"from-violet-500/30 via-violet-500/15 to-violet-600/20",
|
||||||
|
"from-amber-500/30 via-amber-500/15 to-amber-600/20",
|
||||||
|
"from-cyan-500/30 via-cyan-500/15 to-cyan-600/20",
|
||||||
|
"from-pink-500/30 via-pink-500/15 to-pink-600/20",
|
||||||
|
"from-teal-500/30 via-teal-500/15 to-teal-600/20",
|
||||||
|
"from-orange-500/30 via-orange-500/15 to-orange-600/20",
|
||||||
|
];
|
||||||
|
|
||||||
|
const TOPIC_TEXT = [
|
||||||
|
"text-blue-300",
|
||||||
|
"text-emerald-300",
|
||||||
|
"text-violet-300",
|
||||||
|
"text-amber-300",
|
||||||
|
"text-cyan-300",
|
||||||
|
"text-pink-300",
|
||||||
|
"text-teal-300",
|
||||||
|
"text-orange-300",
|
||||||
|
];
|
||||||
|
|
||||||
|
const TOPIC_BORDER = [
|
||||||
|
"border-blue-500/30",
|
||||||
|
"border-emerald-500/30",
|
||||||
|
"border-violet-500/30",
|
||||||
|
"border-amber-500/30",
|
||||||
|
"border-cyan-500/30",
|
||||||
|
"border-pink-500/30",
|
||||||
|
"border-teal-500/30",
|
||||||
|
"border-orange-500/30",
|
||||||
|
];
|
||||||
|
|
||||||
|
function TopicCloud({ topics, loading }: { topics: TopicTrend[] | undefined; loading: boolean }) {
|
||||||
|
if (loading && !topics?.length) {
|
||||||
|
return <LoadingSkeleton />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!topics?.length) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-40 flex-col items-center justify-center gap-2 text-muted-foreground">
|
||||||
|
<Flame className="h-10 w-10 opacity-20" />
|
||||||
|
<p className="text-sm">Topik akan muncul setelah AI selesai menganalisis.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxCount = Math.max(...topics.map((t) => t.count), 1);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-2.5">
|
||||||
|
{topics.map((topic, i) => {
|
||||||
|
const scale = 0.65 + (topic.count / maxCount) * 1.35;
|
||||||
|
return (
|
||||||
|
<motion.span
|
||||||
|
key={topic.topic}
|
||||||
|
initial={{ opacity: 0, scale: 0.5 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
transition={{ delay: i * 0.04, type: "spring", bounce: 0.3 }}
|
||||||
|
whileHover={{ scale: 1.08, y: -2 }}
|
||||||
|
className={cn(
|
||||||
|
"relative inline-flex items-center gap-1.5 rounded-full border px-3 py-1.5 cursor-default",
|
||||||
|
"bg-gradient-to-br backdrop-blur transition-shadow hover:shadow-lg",
|
||||||
|
TOPIC_GRADIENTS[i % TOPIC_GRADIENTS.length],
|
||||||
|
TOPIC_BORDER[i % TOPIC_BORDER.length],
|
||||||
|
TOPIC_TEXT[i % TOPIC_TEXT.length],
|
||||||
|
)}
|
||||||
|
style={{ fontSize: `${Math.round(scale * 100)}%` }}
|
||||||
|
title={`${topic.count} kali disebut${topic.score > 0 ? ` · Skor: ${topic.score}` : ""}`}
|
||||||
|
>
|
||||||
|
{/* Sparkle dot */}
|
||||||
|
<span className="absolute -right-0.5 -top-0.5 h-2 w-2 animate-pulse rounded-full bg-white/30" />
|
||||||
|
{topic.topic}
|
||||||
|
<span className="text-[0.65em] font-mono opacity-50 tabular-nums">
|
||||||
|
{topic.count}
|
||||||
|
</span>
|
||||||
|
</motion.span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── User Leaderboard ───────────────────────────────────────────────────
|
||||||
|
function UserLeaderboard({
|
||||||
|
users,
|
||||||
|
loading,
|
||||||
|
compact,
|
||||||
|
}: {
|
||||||
|
users: UserStat[] | undefined;
|
||||||
|
loading: boolean;
|
||||||
|
compact?: boolean;
|
||||||
|
}) {
|
||||||
|
if (loading && !users?.length) {
|
||||||
|
return <LoadingSkeleton />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!users?.length) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-40 flex-col items-center justify-center gap-2 text-muted-foreground">
|
||||||
|
<Users className="h-10 w-10 opacity-20" />
|
||||||
|
<p className="text-sm">Belum ada aktivitas user.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxMsgs = Math.max(...users.map((u) => u.message_count), 1);
|
||||||
|
const medals = ["🥇", "🥈", "🥉"];
|
||||||
|
|
||||||
|
const displayUsers = compact ? users.slice(0, 5) : users;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollArea className={compact ? "max-h-[300px]" : "max-h-[500px]"}>
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="sticky top-0 z-10 bg-card/95 backdrop-blur border-b border-border text-left text-[11px] uppercase tracking-wider text-muted-foreground">
|
||||||
|
<th className="py-3 pl-6 pr-3 font-semibold">#</th>
|
||||||
|
<th className="py-3 pr-3 font-semibold">User</th>
|
||||||
|
{!compact && (
|
||||||
|
<>
|
||||||
|
<th className="py-3 pr-3 font-semibold text-right">Pesan</th>
|
||||||
|
<th className="py-3 pr-3 font-semibold text-right">Edit</th>
|
||||||
|
<th className="py-3 pr-3 font-semibold text-right">Hapus</th>
|
||||||
|
<th className="py-3 pr-3 font-semibold text-right">Flag</th>
|
||||||
|
<th className="py-3 pr-6 font-semibold text-right">Aktif</th>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{compact && (
|
||||||
|
<th className="py-3 pr-6 font-semibold text-right">Pesan</th>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border/30">
|
||||||
|
{displayUsers.map((user, i) => (
|
||||||
|
<motion.tr
|
||||||
|
key={user.user_id}
|
||||||
|
initial={{ opacity: 0, x: -10 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
transition={{ delay: i * 0.03 }}
|
||||||
|
className="group transition-colors hover:bg-muted/20"
|
||||||
|
>
|
||||||
|
<td className="py-2.5 pl-6 pr-3 tabular-nums font-mono text-muted-foreground">
|
||||||
|
{medals[i] ?? i + 1}
|
||||||
|
</td>
|
||||||
|
<td className="py-2.5 pr-3">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
{user.avatar_url ? (
|
||||||
|
<img
|
||||||
|
src={user.avatar_url}
|
||||||
|
alt=""
|
||||||
|
className="h-7 w-7 rounded-full ring-1 ring-border/50"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-muted text-xs font-bold ring-1 ring-border/50">
|
||||||
|
{user.username.charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<span className="max-w-[100px] truncate font-medium">
|
||||||
|
{user.username}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
{!compact && (
|
||||||
|
<>
|
||||||
|
<td className="py-2.5 pr-3 text-right tabular-nums">
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<div className="hidden h-1.5 w-10 overflow-hidden rounded-full bg-muted sm:block">
|
||||||
|
<motion.div
|
||||||
|
className="h-full rounded-full bg-gradient-to-r from-blue-500 to-blue-400"
|
||||||
|
initial={{ width: 0 }}
|
||||||
|
animate={{ width: `${(user.message_count / maxMsgs) * 100}%` }}
|
||||||
|
transition={{ delay: i * 0.05 + 0.2, duration: 0.6 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="font-mono text-xs font-semibold">{user.message_count}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-2.5 pr-3 text-right tabular-nums text-muted-foreground text-xs">
|
||||||
|
{user.edited_count > 0 ? user.edited_count : "—"}
|
||||||
|
</td>
|
||||||
|
<td className="py-2.5 pr-3 text-right tabular-nums text-muted-foreground text-xs">
|
||||||
|
{user.deleted_count > 0 ? user.deleted_count : "—"}
|
||||||
|
</td>
|
||||||
|
<td className="py-2.5 pr-3 text-right tabular-nums text-xs">
|
||||||
|
{user.flagged_count > 0 ? (
|
||||||
|
<Badge variant="destructive" className="text-[10px] px-1.5 py-0">
|
||||||
|
{user.flagged_count}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2.5 pr-6 text-right tabular-nums text-muted-foreground text-xs">
|
||||||
|
{formatTimeAgo(user.last_active)}
|
||||||
|
</td>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{compact && (
|
||||||
|
<td className="py-2.5 pr-6 text-right">
|
||||||
|
<span className="font-mono text-sm font-bold tabular-nums">{user.message_count}</span>
|
||||||
|
<span className="ml-1 text-[10px] text-muted-foreground">msg</span>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</motion.tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{compact && users.length > 5 && (
|
||||||
|
<div className="border-t border-border px-6 py-3 text-center text-xs text-muted-foreground">
|
||||||
|
+{users.length - 5} user lainnya — lihat leaderboard lengkap di bawah
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ScrollArea>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Violators Leaderboard ──────────────────────────────────────────────
|
||||||
|
function ViolatorsLeaderboard({
|
||||||
|
users,
|
||||||
|
loading,
|
||||||
|
}: {
|
||||||
|
users: ViolatorStat[] | undefined;
|
||||||
|
loading: boolean;
|
||||||
|
}) {
|
||||||
|
if (loading && !users?.length) {
|
||||||
|
return <LoadingSkeleton />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!users?.length) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-40 flex-col items-center justify-center gap-2 text-muted-foreground">
|
||||||
|
<Shield className="h-10 w-10 opacity-20" />
|
||||||
|
<p className="text-sm">Tidak ada pelanggaran terdeteksi. 🎉</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxScore = Math.max(...users.map((u) => u.violation_score), 1);
|
||||||
|
|
||||||
|
// Danger level colors
|
||||||
|
interface DangerLevel { bg: string; border: string; text: string; label: string }
|
||||||
|
function dangerLevel(score: number): DangerLevel {
|
||||||
|
if (score >= 10) return { bg: "bg-red-500/15 border-red-500/40", border: "border-red-500/40", text: "text-red-300", label: "HIGH" };
|
||||||
|
if (score >= 5) return { bg: "bg-amber-500/10 border-amber-500/30", border: "border-amber-500/30", text: "text-amber-300", label: "MED" };
|
||||||
|
return { bg: "bg-yellow-500/10 border-yellow-500/20", border: "border-yellow-500/20", text: "text-yellow-300", label: "LOW" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollArea className="max-h-[500px]">
|
||||||
|
<div className="divide-y divide-border/20">
|
||||||
|
{users.map((user, i) => {
|
||||||
|
const danger = dangerLevel(user.violation_score);
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
key={user.user_id}
|
||||||
|
initial={{ opacity: 0, x: -10 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
transition={{ delay: i * 0.04 }}
|
||||||
|
className={cn(
|
||||||
|
"group flex items-center gap-4 px-6 py-3 transition-colors hover:bg-red-500/5",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Rank + Danger indicator */}
|
||||||
|
<div className="relative flex-shrink-0">
|
||||||
|
<div className={cn(
|
||||||
|
"flex h-10 w-10 items-center justify-center rounded-xl border text-lg font-bold",
|
||||||
|
danger.bg, danger.border, danger.text,
|
||||||
|
i < 3 && "shadow-lg",
|
||||||
|
)}>
|
||||||
|
{i + 1}
|
||||||
|
</div>
|
||||||
|
{i === 0 && (
|
||||||
|
<span className="absolute -right-1 -top-1 text-sm">🔥</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Avatar */}
|
||||||
|
{user.avatar_url ? (
|
||||||
|
<img src={user.avatar_url} alt="" className="h-9 w-9 rounded-full ring-1 ring-border/50" loading="lazy" />
|
||||||
|
) : (
|
||||||
|
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-muted text-xs font-bold">
|
||||||
|
{user.username.charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="truncate font-semibold text-sm">{user.username}</span>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
"text-[9px] px-1.5 py-0 font-mono tracking-wider",
|
||||||
|
danger.text, danger.border,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{danger.label}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 flex flex-wrap items-center gap-1.5 text-[11px] text-muted-foreground">
|
||||||
|
{user.worst_flags.length > 0 ? (
|
||||||
|
user.worst_flags.map((flag) => (
|
||||||
|
<span
|
||||||
|
key={flag}
|
||||||
|
className="inline-flex items-center rounded-md bg-red-500/10 px-1.5 py-0.5 text-red-300/80"
|
||||||
|
>
|
||||||
|
{flag}
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<span className="italic">no flags</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="flex items-center gap-4 text-right tabular-nums flex-shrink-0">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted-foreground">Pesan</div>
|
||||||
|
<div className="font-mono text-sm font-medium">{user.total_messages}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-amber-400/70">Warned</div>
|
||||||
|
<div className="font-mono text-sm font-medium text-amber-400">{user.warned_count}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-red-400/70">Flagged</div>
|
||||||
|
<div className="font-mono text-sm font-bold text-red-400">{user.flagged_count}</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-24">
|
||||||
|
<div className="text-xs text-muted-foreground mb-1">Skor</div>
|
||||||
|
<div className="h-2 overflow-hidden rounded-full bg-muted">
|
||||||
|
<motion.div
|
||||||
|
className={cn(
|
||||||
|
"h-full rounded-full",
|
||||||
|
user.violation_score >= 10
|
||||||
|
? "bg-gradient-to-r from-red-600 to-red-400"
|
||||||
|
: user.violation_score >= 5
|
||||||
|
? "bg-gradient-to-r from-amber-500 to-amber-400"
|
||||||
|
: "bg-gradient-to-r from-yellow-500 to-yellow-400",
|
||||||
|
)}
|
||||||
|
initial={{ width: 0 }}
|
||||||
|
animate={{ width: `${(user.violation_score / maxScore) * 100}%` }}
|
||||||
|
transition={{ delay: i * 0.05 + 0.2, duration: 0.6 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={cn("mt-0.5 text-xs font-bold font-mono", danger.text)}>
|
||||||
|
{user.violation_score}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Empty State ────────────────────────────────────────────────────────
|
||||||
|
function EmptyState({ icon: Icon, text }: { icon: typeof BarChart3; text: string }) {
|
||||||
|
return (
|
||||||
|
<Card className="border-dashed">
|
||||||
|
<CardContent className="flex min-h-[300px] flex-col items-center justify-center gap-3">
|
||||||
|
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-muted/50">
|
||||||
|
<Icon className="h-8 w-8 text-muted-foreground/40" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">{text}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Loading Skeleton ───────────────────────────────────────────────────
|
||||||
|
function LoadingSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="flex h-40 flex-col items-center justify-center gap-3">
|
||||||
|
<motion.div
|
||||||
|
className="h-10 w-10 rounded-full border-2 border-blue-500/20 border-t-blue-400"
|
||||||
|
animate={{ rotate: 360 }}
|
||||||
|
transition={{ repeat: Number.POSITIVE_INFINITY, duration: 1, ease: "linear" }}
|
||||||
|
/>
|
||||||
|
<motion.p
|
||||||
|
className="text-xs text-muted-foreground"
|
||||||
|
animate={{ opacity: [0.4, 1, 0.4] }}
|
||||||
|
transition={{ repeat: Number.POSITIVE_INFINITY, duration: 2 }}
|
||||||
|
>
|
||||||
|
Memuat data...
|
||||||
|
</motion.p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ────────────────────────────────────────────────────────────
|
||||||
|
function pct(part: number, total: number): number {
|
||||||
|
if (!total) return 0;
|
||||||
|
return Math.round((part / total) * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimeAgo(ts: number): string {
|
||||||
|
const diff = Date.now() - ts;
|
||||||
|
const minutes = Math.floor(diff / 60000);
|
||||||
|
if (minutes < 1) return "baru saja";
|
||||||
|
if (minutes < 60) return `${minutes}m lalu`;
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
if (hours < 24) return `${hours}j lalu`;
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
return `${days}h lalu`;
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ const titles: Record<DashboardTab, string> = {
|
|||||||
media: "Media Player",
|
media: "Media Player",
|
||||||
messages: "Messages",
|
messages: "Messages",
|
||||||
recordings: "Voice Recordings",
|
recordings: "Voice Recordings",
|
||||||
|
analytics: "Analytics & Insights",
|
||||||
review: "Moderation Review",
|
review: "Moderation Review",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Bot, MessageSquare, Music2, ShieldAlert, Volume2, Mic } from "lucide-react";
|
import { Bot, BarChart3, MessageSquare, Music2, ShieldAlert, Volume2, Mic } from "lucide-react";
|
||||||
import type { DashboardTab } from "../../types/ui";
|
import type { DashboardTab } from "../../types/ui";
|
||||||
import { cn } from "../../lib/utils";
|
import { cn } from "../../lib/utils";
|
||||||
import { Button } from "../ui/button";
|
import { Button } from "../ui/button";
|
||||||
@@ -8,6 +8,7 @@ const navItems: Array<{ id: DashboardTab; label: string; icon: typeof Volume2 }>
|
|||||||
{ id: "media", label: "Media", icon: Music2 },
|
{ id: "media", label: "Media", icon: Music2 },
|
||||||
{ id: "messages", label: "Messages", icon: MessageSquare },
|
{ id: "messages", label: "Messages", icon: MessageSquare },
|
||||||
{ id: "recordings", label: "Recordings", icon: Mic },
|
{ id: "recordings", label: "Recordings", icon: Mic },
|
||||||
|
{ id: "analytics", label: "Analytics", icon: BarChart3 },
|
||||||
{ id: "review", label: "Review", icon: ShieldAlert },
|
{ id: "review", label: "Review", icon: ShieldAlert },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { fetchAnalyticsOverview, type AnalyticsOverview, type HourlyBucket, type TopicTrend, type UserStat } from "../api/analytics";
|
||||||
|
|
||||||
|
interface UseAnalyticsOptions {
|
||||||
|
guildId: string;
|
||||||
|
channelId?: string;
|
||||||
|
hours?: number;
|
||||||
|
autoRefresh?: boolean;
|
||||||
|
refreshIntervalMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAnalytics({ guildId, channelId, hours = 24, autoRefresh = true, refreshIntervalMs = 60_000 }: UseAnalyticsOptions) {
|
||||||
|
const [overview, setOverview] = useState<AnalyticsOverview | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
if (!guildId) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const data = await fetchAnalyticsOverview({ guildId, channelId, hours });
|
||||||
|
setOverview(data);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to load analytics");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [guildId, channelId, hours]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
// Auto-refresh
|
||||||
|
useEffect(() => {
|
||||||
|
if (!autoRefresh || !guildId) return;
|
||||||
|
intervalRef.current = setInterval(load, refreshIntervalMs);
|
||||||
|
return () => {
|
||||||
|
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||||
|
};
|
||||||
|
}, [load, autoRefresh, refreshIntervalMs, guildId]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
overview,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
refresh: load,
|
||||||
|
// Convenience accessors
|
||||||
|
hourly: overview?.hourly ?? ([] as HourlyBucket[]),
|
||||||
|
topics: overview?.topics ?? ([] as TopicTrend[]),
|
||||||
|
topUsers: overview?.top_users ?? ([] as UserStat[]),
|
||||||
|
messages: overview?.messages ?? null,
|
||||||
|
period: overview?.period ?? null,
|
||||||
|
activeUsersCount: overview?.active_users_count ?? 0,
|
||||||
|
totalChannels: overview?.total_channels ?? 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
export type DashboardTab = "voice" | "media" | "messages" | "review" | "recordings";
|
export type DashboardTab = "voice" | "media" | "messages" | "review" | "recordings" | "analytics";
|
||||||
|
|
||||||
export interface UIState {
|
export interface UIState {
|
||||||
selectedGuild?: string;
|
selectedGuild?: string;
|
||||||
|
|||||||
@@ -45,6 +45,7 @@
|
|||||||
"indonesian-badwords": "^1.0.1",
|
"indonesian-badwords": "^1.0.1",
|
||||||
"libsodium-wrappers": "^0.8.4",
|
"libsodium-wrappers": "^0.8.4",
|
||||||
"lucide-react": "^1.16.0",
|
"lucide-react": "^1.16.0",
|
||||||
|
"motion": "^12.40.0",
|
||||||
"openai": "^6.38.0",
|
"openai": "^6.38.0",
|
||||||
"opusscript": "^0.0.8",
|
"opusscript": "^0.0.8",
|
||||||
"p-retry": "^8.0.0",
|
"p-retry": "^8.0.0",
|
||||||
|
|||||||
Generated
+71
-12
@@ -34,7 +34,7 @@ importers:
|
|||||||
version: 8.20.0
|
version: 8.20.0
|
||||||
'@vitejs/plugin-react':
|
'@vitejs/plugin-react':
|
||||||
specifier: ^6.0.2
|
specifier: ^6.0.2
|
||||||
version: 6.0.2(vite@8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0))
|
version: 6.0.2(vite@8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2))
|
||||||
better-sqlite3:
|
better-sqlite3:
|
||||||
specifier: ^12.10.0
|
specifier: ^12.10.0
|
||||||
version: 12.10.0
|
version: 12.10.0
|
||||||
@@ -65,6 +65,9 @@ importers:
|
|||||||
lucide-react:
|
lucide-react:
|
||||||
specifier: ^1.16.0
|
specifier: ^1.16.0
|
||||||
version: 1.16.0(react@19.2.6)
|
version: 1.16.0(react@19.2.6)
|
||||||
|
motion:
|
||||||
|
specifier: ^12.40.0
|
||||||
|
version: 12.40.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||||
openai:
|
openai:
|
||||||
specifier: ^6.38.0
|
specifier: ^6.38.0
|
||||||
version: 6.38.0(ws@8.20.1)(zod@4.4.3)
|
version: 6.38.0(ws@8.20.1)(zod@4.4.3)
|
||||||
@@ -100,7 +103,7 @@ importers:
|
|||||||
version: 3.6.0
|
version: 3.6.0
|
||||||
vite:
|
vite:
|
||||||
specifier: ^8.0.13
|
specifier: ^8.0.13
|
||||||
version: 8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0)
|
version: 8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)
|
||||||
winston:
|
winston:
|
||||||
specifier: ^3.19.0
|
specifier: ^3.19.0
|
||||||
version: 3.19.0
|
version: 3.19.0
|
||||||
@@ -155,7 +158,7 @@ importers:
|
|||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
vitest:
|
vitest:
|
||||||
specifier: latest
|
specifier: latest
|
||||||
version: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@25.9.0)(vite@8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0))
|
version: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@25.9.0)(vite@8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2))
|
||||||
|
|
||||||
vendor/discord-video-stream:
|
vendor/discord-video-stream:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -2925,6 +2928,20 @@ packages:
|
|||||||
fraction.js@5.3.4:
|
fraction.js@5.3.4:
|
||||||
resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==}
|
resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==}
|
||||||
|
|
||||||
|
framer-motion@12.40.0:
|
||||||
|
resolution: {integrity: sha512-uaBd3qC1v3KQqBEjwTUd183K6PbS+j0yR9w9VmEOLWA/tnUcSn8Xa3uck7t4dgpDoUss8xQTcj8W2L07lrnLFg==}
|
||||||
|
peerDependencies:
|
||||||
|
'@emotion/is-prop-valid': '*'
|
||||||
|
react: ^18.0.0 || ^19.0.0
|
||||||
|
react-dom: ^18.0.0 || ^19.0.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@emotion/is-prop-valid':
|
||||||
|
optional: true
|
||||||
|
react:
|
||||||
|
optional: true
|
||||||
|
react-dom:
|
||||||
|
optional: true
|
||||||
|
|
||||||
fresh@2.0.0:
|
fresh@2.0.0:
|
||||||
resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==}
|
resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
@@ -3556,6 +3573,26 @@ packages:
|
|||||||
mlly@1.8.2:
|
mlly@1.8.2:
|
||||||
resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==}
|
resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==}
|
||||||
|
|
||||||
|
motion-dom@12.40.0:
|
||||||
|
resolution: {integrity: sha512-HxU3ZaBwNPVQUBQf1xxgq+7JrPNZvjLVxgbpEZL7RrWJnsxOf0/OM+yrHG9ogLQ31Do/r57Oz2gQWPK+6q62mg==}
|
||||||
|
|
||||||
|
motion-utils@12.39.0:
|
||||||
|
resolution: {integrity: sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==}
|
||||||
|
|
||||||
|
motion@12.40.0:
|
||||||
|
resolution: {integrity: sha512-yjrHUrBFW6kQvjJwRsoiPSAhC5tRwRqNGJWmiJ4CrGnbKp0V88AdzkhBmDoqIsIPfarOe0Uddd37Xq43/gIocA==}
|
||||||
|
peerDependencies:
|
||||||
|
'@emotion/is-prop-valid': '*'
|
||||||
|
react: ^18.0.0 || ^19.0.0
|
||||||
|
react-dom: ^18.0.0 || ^19.0.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@emotion/is-prop-valid':
|
||||||
|
optional: true
|
||||||
|
react:
|
||||||
|
optional: true
|
||||||
|
react-dom:
|
||||||
|
optional: true
|
||||||
|
|
||||||
mp4box@0.5.4:
|
mp4box@0.5.4:
|
||||||
resolution: {integrity: sha512-GcCH0fySxBurJtvr0dfhz0IxHZjc1RP+F+I8xw+LIwkU1a+7HJx8NCDiww1I5u4Hz6g4eR1JlGADEGJ9r4lSfA==}
|
resolution: {integrity: sha512-GcCH0fySxBurJtvr0dfhz0IxHZjc1RP+F+I8xw+LIwkU1a+7HJx8NCDiww1I5u4Hz6g4eR1JlGADEGJ9r4lSfA==}
|
||||||
|
|
||||||
@@ -6266,10 +6303,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 25.8.0
|
'@types/node': 25.8.0
|
||||||
|
|
||||||
'@vitejs/plugin-react@6.0.2(vite@8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0))':
|
'@vitejs/plugin-react@6.0.2(vite@8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@rolldown/pluginutils': 1.0.1
|
'@rolldown/pluginutils': 1.0.1
|
||||||
vite: 8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0)
|
vite: 8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)
|
||||||
|
|
||||||
'@vitest/expect@4.1.7':
|
'@vitest/expect@4.1.7':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -6280,13 +6317,13 @@ snapshots:
|
|||||||
chai: 6.2.2
|
chai: 6.2.2
|
||||||
tinyrainbow: 3.1.0
|
tinyrainbow: 3.1.0
|
||||||
|
|
||||||
'@vitest/mocker@4.1.7(vite@8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0))':
|
'@vitest/mocker@4.1.7(vite@8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@vitest/spy': 4.1.7
|
'@vitest/spy': 4.1.7
|
||||||
estree-walker: 3.0.3
|
estree-walker: 3.0.3
|
||||||
magic-string: 0.30.21
|
magic-string: 0.30.21
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
vite: 8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0)
|
vite: 8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)
|
||||||
|
|
||||||
'@vitest/pretty-format@4.1.7':
|
'@vitest/pretty-format@4.1.7':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -7193,6 +7230,15 @@ snapshots:
|
|||||||
|
|
||||||
fraction.js@5.3.4: {}
|
fraction.js@5.3.4: {}
|
||||||
|
|
||||||
|
framer-motion@12.40.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
|
||||||
|
dependencies:
|
||||||
|
motion-dom: 12.40.0
|
||||||
|
motion-utils: 12.39.0
|
||||||
|
tslib: 2.8.1
|
||||||
|
optionalDependencies:
|
||||||
|
react: 19.2.6
|
||||||
|
react-dom: 19.2.6(react@19.2.6)
|
||||||
|
|
||||||
fresh@2.0.0: {}
|
fresh@2.0.0: {}
|
||||||
|
|
||||||
fs-constants@1.0.0: {}
|
fs-constants@1.0.0: {}
|
||||||
@@ -7794,6 +7840,20 @@ snapshots:
|
|||||||
pkg-types: 1.3.1
|
pkg-types: 1.3.1
|
||||||
ufo: 1.6.4
|
ufo: 1.6.4
|
||||||
|
|
||||||
|
motion-dom@12.40.0:
|
||||||
|
dependencies:
|
||||||
|
motion-utils: 12.39.0
|
||||||
|
|
||||||
|
motion-utils@12.39.0: {}
|
||||||
|
|
||||||
|
motion@12.40.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
|
||||||
|
dependencies:
|
||||||
|
framer-motion: 12.40.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||||
|
tslib: 2.8.1
|
||||||
|
optionalDependencies:
|
||||||
|
react: 19.2.6
|
||||||
|
react-dom: 19.2.6(react@19.2.6)
|
||||||
|
|
||||||
mp4box@0.5.4: {}
|
mp4box@0.5.4: {}
|
||||||
|
|
||||||
ms@2.1.3: {}
|
ms@2.1.3: {}
|
||||||
@@ -8866,7 +8926,7 @@ snapshots:
|
|||||||
|
|
||||||
vary@1.1.2: {}
|
vary@1.1.2: {}
|
||||||
|
|
||||||
vite@8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0):
|
vite@8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2):
|
||||||
dependencies:
|
dependencies:
|
||||||
lightningcss: 1.32.0
|
lightningcss: 1.32.0
|
||||||
picomatch: 4.0.4
|
picomatch: 4.0.4
|
||||||
@@ -8879,12 +8939,11 @@ snapshots:
|
|||||||
fsevents: 2.3.3
|
fsevents: 2.3.3
|
||||||
jiti: 2.7.0
|
jiti: 2.7.0
|
||||||
tsx: 4.22.2
|
tsx: 4.22.2
|
||||||
yaml: 2.9.0
|
|
||||||
|
|
||||||
vitest@4.1.7(@opentelemetry/api@1.9.1)(@types/node@25.9.0)(vite@8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0)):
|
vitest@4.1.7(@opentelemetry/api@1.9.1)(@types/node@25.9.0)(vite@8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@vitest/expect': 4.1.7
|
'@vitest/expect': 4.1.7
|
||||||
'@vitest/mocker': 4.1.7(vite@8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0))
|
'@vitest/mocker': 4.1.7(vite@8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2))
|
||||||
'@vitest/pretty-format': 4.1.7
|
'@vitest/pretty-format': 4.1.7
|
||||||
'@vitest/runner': 4.1.7
|
'@vitest/runner': 4.1.7
|
||||||
'@vitest/snapshot': 4.1.7
|
'@vitest/snapshot': 4.1.7
|
||||||
@@ -8901,7 +8960,7 @@ snapshots:
|
|||||||
tinyexec: 1.1.2
|
tinyexec: 1.1.2
|
||||||
tinyglobby: 0.2.16
|
tinyglobby: 0.2.16
|
||||||
tinyrainbow: 3.1.0
|
tinyrainbow: 3.1.0
|
||||||
vite: 8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0)
|
vite: 8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)
|
||||||
why-is-node-running: 2.3.0
|
why-is-node-running: 2.3.0
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@opentelemetry/api': 1.9.1
|
'@opentelemetry/api': 1.9.1
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import type { createChildLogger } from "../logger.js";
|
|||||||
import type { MediaController } from "../media/mediaController.js";
|
import type { MediaController } from "../media/mediaController.js";
|
||||||
import type { ModerationBroadcaster } from "../moderation/types.js";
|
import type { ModerationBroadcaster } from "../moderation/types.js";
|
||||||
import { createAnalysisRoutes } from "../routes/analysisRoutes.js";
|
import { createAnalysisRoutes } from "../routes/analysisRoutes.js";
|
||||||
|
import { createAnalyticsRoutes } from "../routes/analyticsRoutes.js";
|
||||||
import { createMediaRoutes } from "../routes/mediaRoutes.js";
|
import { createMediaRoutes } from "../routes/mediaRoutes.js";
|
||||||
import { createMessageRoutes } from "../routes/messageRoutes.js";
|
import { createMessageRoutes } from "../routes/messageRoutes.js";
|
||||||
import { createRecordingsRoutes } from "../routes/recordingsRoutes.js";
|
import { createRecordingsRoutes } from "../routes/recordingsRoutes.js";
|
||||||
@@ -113,6 +114,7 @@ export function createHttpApp(options: CreateHttpAppOptions) {
|
|||||||
);
|
);
|
||||||
app.use("/api", createMessageRoutes());
|
app.use("/api", createMessageRoutes());
|
||||||
app.use("/api", createAnalysisRoutes());
|
app.use("/api", createAnalysisRoutes());
|
||||||
|
app.use("/api", createAnalyticsRoutes());
|
||||||
app.use("/api", createSyncRoutes(options.client));
|
app.use("/api", createSyncRoutes(options.client));
|
||||||
app.use("/api", createRecordingsRoutes());
|
app.use("/api", createRecordingsRoutes());
|
||||||
app.use(
|
app.use(
|
||||||
|
|||||||
@@ -0,0 +1,591 @@
|
|||||||
|
import { and, asc, desc, eq, gte, isNull, or, type SQL } from "drizzle-orm";
|
||||||
|
import { getDatabase } from "../database/drizzle.js";
|
||||||
|
import { messagesTable } from "../database/schema.js";
|
||||||
|
import { createChildLogger } from "../logger.js";
|
||||||
|
import type { MessageRecord } from "./types.js";
|
||||||
|
|
||||||
|
const logger = createChildLogger("analytics-store");
|
||||||
|
|
||||||
|
// ── DB helper ──────────────────────────────────────────────────────────
|
||||||
|
function db() {
|
||||||
|
return getDatabase() as {
|
||||||
|
select(fields?: Record<string, unknown>): {
|
||||||
|
from(table: unknown): {
|
||||||
|
where(cond: SQL | undefined): {
|
||||||
|
orderBy(...cols: unknown[]): {
|
||||||
|
limit(n: number): Promise<unknown[]>;
|
||||||
|
} & Promise<unknown[]>;
|
||||||
|
groupBy(...cols: unknown[]): Promise<unknown[]>;
|
||||||
|
} & Promise<unknown[]>;
|
||||||
|
limit(n: number): Promise<unknown[]>;
|
||||||
|
} & Promise<unknown[]>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Shared condition helper ────────────────────────────────────────────
|
||||||
|
function channelFilter(channelId: string): SQL {
|
||||||
|
return or(
|
||||||
|
eq(messagesTable.channel_id, channelId),
|
||||||
|
eq(messagesTable.thread_id, channelId),
|
||||||
|
) as SQL;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Types ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface HourlyBucket {
|
||||||
|
hour: string;
|
||||||
|
count: number;
|
||||||
|
clean: number;
|
||||||
|
warned: number;
|
||||||
|
flagged: number;
|
||||||
|
error: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TopicTrend {
|
||||||
|
topic: string;
|
||||||
|
count: number;
|
||||||
|
score: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserStat {
|
||||||
|
user_id: string;
|
||||||
|
username: string;
|
||||||
|
avatar_url: string | null;
|
||||||
|
message_count: number;
|
||||||
|
edited_count: number;
|
||||||
|
deleted_count: number;
|
||||||
|
flagged_count: number;
|
||||||
|
last_active: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModerationBreakdown {
|
||||||
|
total: number;
|
||||||
|
clean: number;
|
||||||
|
warned: number;
|
||||||
|
flagged: number;
|
||||||
|
error: number;
|
||||||
|
pending: number;
|
||||||
|
average_score: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsOverview {
|
||||||
|
period: { start: number; end: number };
|
||||||
|
messages: ModerationBreakdown;
|
||||||
|
hourly: HourlyBucket[];
|
||||||
|
topics: TopicTrend[];
|
||||||
|
top_users: UserStat[];
|
||||||
|
active_users_count: number;
|
||||||
|
total_channels: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Hourly Message Stats ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function getHourlyStats(input: {
|
||||||
|
guildId: string;
|
||||||
|
channelId?: string;
|
||||||
|
hours?: number;
|
||||||
|
}): Promise<HourlyBucket[]> {
|
||||||
|
try {
|
||||||
|
const { guildId, channelId, hours = 24 } = input;
|
||||||
|
const since = Date.now() - hours * 3600_000;
|
||||||
|
const database = db();
|
||||||
|
|
||||||
|
const conditions: SQL[] = [
|
||||||
|
eq(messagesTable.guild_id, guildId),
|
||||||
|
gte(messagesTable.created_at, since),
|
||||||
|
isNull(messagesTable.deleted_at),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (channelId) {
|
||||||
|
conditions.push(channelFilter(channelId));
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = (await database
|
||||||
|
.select()
|
||||||
|
.from(messagesTable)
|
||||||
|
.where(and(...conditions) as SQL)
|
||||||
|
.orderBy(asc(messagesTable.created_at))) as MessageRecord[];
|
||||||
|
|
||||||
|
// Initialize all hour buckets
|
||||||
|
const buckets = new Map<
|
||||||
|
string,
|
||||||
|
{ count: number; clean: number; warned: number; flagged: number; error: number }
|
||||||
|
>();
|
||||||
|
|
||||||
|
for (let h = 0; h < hours; h++) {
|
||||||
|
const ts = new Date(since + h * 3600_000);
|
||||||
|
ts.setMinutes(0, 0, 0);
|
||||||
|
const key = ts.toISOString().slice(0, 13) + ":00:00Z";
|
||||||
|
buckets.set(key, { count: 0, clean: 0, warned: 0, flagged: 0, error: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const d = new Date(row.created_at);
|
||||||
|
d.setMinutes(0, 0, 0);
|
||||||
|
const key = d.toISOString().slice(0, 13) + ":00:00Z";
|
||||||
|
|
||||||
|
const bucket = buckets.get(key);
|
||||||
|
if (!bucket) continue;
|
||||||
|
|
||||||
|
bucket.count++;
|
||||||
|
const status = row.ai_status || "pending";
|
||||||
|
if (status === "clean") bucket.clean++;
|
||||||
|
else if (status === "warn") bucket.warned++;
|
||||||
|
else if (status === "flagged") bucket.flagged++;
|
||||||
|
else if (status === "error") bucket.error++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(buckets.entries())
|
||||||
|
.sort(([a], [b]) => a.localeCompare(b))
|
||||||
|
.map(([hour, data]) => ({ hour, ...data }));
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
{ error: error instanceof Error ? error.message : String(error) },
|
||||||
|
"Failed to get hourly stats",
|
||||||
|
);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Topic Trends ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const STOP_WORDS = new Set([
|
||||||
|
"yang", "dan", "itu", "ini", "dengan", "akan", "pada", "dari", "di", "ke",
|
||||||
|
"untuk", "tidak", "ada", "juga", "sudah", "saya", "kamu", "dia", "mereka",
|
||||||
|
"kami", "aku", "lo", "lu", "gua", "gue", "org", "orang", "aja", "sama",
|
||||||
|
"kalo", "kalau", "bisa", "karena", "gak", "nggak", "ga", "tak", "belum",
|
||||||
|
"udah", "dah", "lah", "kah", "pun", "nih", "tuh", "deh", "dong", "si",
|
||||||
|
"nya", "kan", "ya", "yah", "yuk", "kok", "loh", "nah", "wow", "eh",
|
||||||
|
"the", "a", "an", "is", "are", "was", "were", "be", "been", "being",
|
||||||
|
"have", "has", "had", "having", "do", "does", "did", "doing",
|
||||||
|
"will", "would", "could", "should", "may", "might", "must", "shall",
|
||||||
|
"i", "you", "he", "she", "it", "we", "they", "me", "him", "her",
|
||||||
|
"us", "them", "my", "your", "his", "its", "our", "their",
|
||||||
|
"and", "but", "or", "nor", "not", "so", "yet", "for", "if",
|
||||||
|
"to", "of", "in", "on", "at", "by", "as", "with", "about",
|
||||||
|
"just", "then", "now", "here", "there", "when", "where", "why",
|
||||||
|
"how", "all", "both", "each", "few", "more", "most", "other",
|
||||||
|
"some", "such", "only", "own", "same", "too", "very", "can",
|
||||||
|
"go", "ok", "okay", "yeah", "yes", "no",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function extractTopics(messages: MessageRecord[], topN = 15): TopicTrend[] {
|
||||||
|
const topicScores = new Map<string, { count: number; score: number }>();
|
||||||
|
const wordFreq = new Map<string, number>();
|
||||||
|
const flaggedWordFreq = new Map<string, number>();
|
||||||
|
|
||||||
|
for (const msg of messages) {
|
||||||
|
if (msg.ai_analysis) {
|
||||||
|
try {
|
||||||
|
const analysis = JSON.parse(msg.ai_analysis);
|
||||||
|
const topics = analysis.topics;
|
||||||
|
if (topics && Array.isArray(topics)) {
|
||||||
|
for (const topic of topics) {
|
||||||
|
const key = typeof topic === "string" ? topic : topic.name || topic.topic;
|
||||||
|
if (!key) continue;
|
||||||
|
const k = key.toLowerCase();
|
||||||
|
const score = msg.ai_moderation_score || 0;
|
||||||
|
const existing = topicScores.get(k);
|
||||||
|
if (existing) { existing.count++; existing.score += score; }
|
||||||
|
else { topicScores.set(k, { count: 1, score }); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (analysis.category) {
|
||||||
|
const cat = String(analysis.category).toLowerCase();
|
||||||
|
const existing = topicScores.get(cat);
|
||||||
|
if (existing) { existing.count++; existing.score += msg.ai_moderation_score || 0; }
|
||||||
|
else { topicScores.set(cat, { count: 1, score: msg.ai_moderation_score || 0 }); }
|
||||||
|
}
|
||||||
|
} catch { /* not valid JSON */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.content) {
|
||||||
|
const words = msg.content
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^\w\s]/g, " ")
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter((w) => w.length > 2 && !STOP_WORDS.has(w));
|
||||||
|
|
||||||
|
for (const word of words) {
|
||||||
|
wordFreq.set(word, (wordFreq.get(word) || 0) + 1);
|
||||||
|
if (msg.ai_status === "flagged" || msg.ai_status === "warn") {
|
||||||
|
flaggedWordFreq.set(word, (flaggedWordFreq.get(word) || 0) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const results: TopicTrend[] = [];
|
||||||
|
for (const [topic, data] of topicScores) {
|
||||||
|
results.push({ topic, count: data.count, score: data.score });
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedWords = Array.from(wordFreq.entries())
|
||||||
|
.sort(([, a], [, b]) => b - a)
|
||||||
|
.slice(0, topN);
|
||||||
|
|
||||||
|
for (const [word, count] of sortedWords) {
|
||||||
|
if (!topicScores.has(word)) {
|
||||||
|
results.push({ topic: word, count, score: flaggedWordFreq.get(word) || 0 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results.sort((a, b) => b.count - a.count).slice(0, topN);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTopicTrends(input: {
|
||||||
|
guildId: string;
|
||||||
|
channelId?: string;
|
||||||
|
hours?: number;
|
||||||
|
}): Promise<TopicTrend[]> {
|
||||||
|
try {
|
||||||
|
const { guildId, channelId, hours = 24 } = input;
|
||||||
|
const since = Date.now() - hours * 3600_000;
|
||||||
|
const database = db();
|
||||||
|
|
||||||
|
const conditions: SQL[] = [
|
||||||
|
eq(messagesTable.guild_id, guildId),
|
||||||
|
gte(messagesTable.created_at, since),
|
||||||
|
isNull(messagesTable.deleted_at),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (channelId) {
|
||||||
|
conditions.push(channelFilter(channelId));
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = (await database
|
||||||
|
.select()
|
||||||
|
.from(messagesTable)
|
||||||
|
.where(and(...conditions) as SQL)
|
||||||
|
.orderBy(desc(messagesTable.created_at))
|
||||||
|
.limit(1000)) as MessageRecord[];
|
||||||
|
|
||||||
|
return extractTopics(rows);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
{ error: error instanceof Error ? error.message : String(error) },
|
||||||
|
"Failed to get topic trends",
|
||||||
|
);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── User Leaderboard ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function getUserLeaderboard(input: {
|
||||||
|
guildId: string;
|
||||||
|
channelId?: string;
|
||||||
|
hours?: number;
|
||||||
|
limit?: number;
|
||||||
|
}): Promise<UserStat[]> {
|
||||||
|
try {
|
||||||
|
const { guildId, channelId, hours = 24, limit = 20 } = input;
|
||||||
|
const since = Date.now() - hours * 3600_000;
|
||||||
|
const database = db();
|
||||||
|
|
||||||
|
const conditions: SQL[] = [
|
||||||
|
eq(messagesTable.guild_id, guildId),
|
||||||
|
gte(messagesTable.created_at, since),
|
||||||
|
isNull(messagesTable.deleted_at),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (channelId) {
|
||||||
|
conditions.push(channelFilter(channelId));
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = (await database
|
||||||
|
.select()
|
||||||
|
.from(messagesTable)
|
||||||
|
.where(and(...conditions) as SQL)
|
||||||
|
.orderBy(asc(messagesTable.created_at))) as MessageRecord[];
|
||||||
|
|
||||||
|
const userMap = new Map<string, UserStat>();
|
||||||
|
|
||||||
|
for (const msg of rows) {
|
||||||
|
const existing = userMap.get(msg.user_id);
|
||||||
|
if (existing) {
|
||||||
|
existing.message_count++;
|
||||||
|
if (msg.type === "edited") existing.edited_count++;
|
||||||
|
if (msg.type === "deleted") existing.deleted_count++;
|
||||||
|
if (msg.ai_status === "flagged" || msg.ai_status === "warn") existing.flagged_count++;
|
||||||
|
if (msg.created_at > existing.last_active) {
|
||||||
|
existing.last_active = msg.created_at;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
userMap.set(msg.user_id, {
|
||||||
|
user_id: msg.user_id,
|
||||||
|
username: msg.username,
|
||||||
|
avatar_url: msg.avatar_url,
|
||||||
|
message_count: 1,
|
||||||
|
edited_count: msg.type === "edited" ? 1 : 0,
|
||||||
|
deleted_count: msg.type === "deleted" ? 1 : 0,
|
||||||
|
flagged_count: msg.ai_status === "flagged" || msg.ai_status === "warn" ? 1 : 0,
|
||||||
|
last_active: msg.created_at,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(userMap.values())
|
||||||
|
.sort((a, b) => b.message_count - a.message_count)
|
||||||
|
.slice(0, limit);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
{ error: error instanceof Error ? error.message : String(error) },
|
||||||
|
"Failed to get user leaderboard",
|
||||||
|
);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Moderation Stats ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function getModerationStats(input: {
|
||||||
|
guildId: string;
|
||||||
|
channelId?: string;
|
||||||
|
hours?: number;
|
||||||
|
}): Promise<ModerationBreakdown> {
|
||||||
|
try {
|
||||||
|
const { guildId, channelId, hours = 24 } = input;
|
||||||
|
const since = Date.now() - hours * 3600_000;
|
||||||
|
const database = db();
|
||||||
|
|
||||||
|
const conditions: SQL[] = [
|
||||||
|
eq(messagesTable.guild_id, guildId),
|
||||||
|
gte(messagesTable.created_at, since),
|
||||||
|
isNull(messagesTable.deleted_at),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (channelId) {
|
||||||
|
conditions.push(channelFilter(channelId));
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = (await database
|
||||||
|
.select()
|
||||||
|
.from(messagesTable)
|
||||||
|
.where(and(...conditions) as SQL)) as MessageRecord[];
|
||||||
|
|
||||||
|
const breakdown: ModerationBreakdown = {
|
||||||
|
total: rows.length,
|
||||||
|
clean: 0, warned: 0, flagged: 0, error: 0, pending: 0,
|
||||||
|
average_score: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
let scoreSum = 0;
|
||||||
|
let scoreCount = 0;
|
||||||
|
|
||||||
|
for (const msg of rows) {
|
||||||
|
const status = msg.ai_status || "pending";
|
||||||
|
if (status === "clean") breakdown.clean++;
|
||||||
|
else if (status === "warn") breakdown.warned++;
|
||||||
|
else if (status === "flagged") breakdown.flagged++;
|
||||||
|
else if (status === "error") breakdown.error++;
|
||||||
|
else breakdown.pending++;
|
||||||
|
|
||||||
|
if (msg.ai_moderation_score != null) {
|
||||||
|
scoreSum += msg.ai_moderation_score;
|
||||||
|
scoreCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
breakdown.average_score =
|
||||||
|
scoreCount > 0 ? Math.round((scoreSum / scoreCount) * 100) / 100 : 0;
|
||||||
|
|
||||||
|
return breakdown;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
{ error: error instanceof Error ? error.message : String(error) },
|
||||||
|
"Failed to get moderation stats",
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
total: 0, clean: 0, warned: 0, flagged: 0, error: 0, pending: 0, average_score: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Active Channels Count ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function getActiveChannelCount(input: {
|
||||||
|
guildId: string;
|
||||||
|
hours?: number;
|
||||||
|
}): Promise<number> {
|
||||||
|
try {
|
||||||
|
const { guildId, hours = 24 } = input;
|
||||||
|
const since = Date.now() - hours * 3600_000;
|
||||||
|
const database = db();
|
||||||
|
|
||||||
|
const rows = (await database
|
||||||
|
.select({ channel_id: messagesTable.channel_id })
|
||||||
|
.from(messagesTable)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(messagesTable.guild_id, guildId),
|
||||||
|
gte(messagesTable.created_at, since),
|
||||||
|
isNull(messagesTable.deleted_at),
|
||||||
|
) as SQL,
|
||||||
|
)
|
||||||
|
.groupBy(messagesTable.channel_id)) as Array<{ channel_id: string }>;
|
||||||
|
|
||||||
|
return rows.length;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
{ error: error instanceof Error ? error.message : String(error) },
|
||||||
|
"Failed to get active channel count",
|
||||||
|
);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Top Violators ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface ViolatorStat {
|
||||||
|
user_id: string;
|
||||||
|
username: string;
|
||||||
|
avatar_url: string | null;
|
||||||
|
total_messages: number;
|
||||||
|
flagged_count: number;
|
||||||
|
warned_count: number;
|
||||||
|
violation_score: number; // weighted: flagged*3 + warned*1
|
||||||
|
worst_flags: string[]; // unique flag types
|
||||||
|
last_violation: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTopViolators(input: {
|
||||||
|
guildId: string;
|
||||||
|
channelId?: string;
|
||||||
|
hours?: number;
|
||||||
|
limit?: number;
|
||||||
|
}): Promise<ViolatorStat[]> {
|
||||||
|
try {
|
||||||
|
const { guildId, channelId, hours = 24, limit = 20 } = input;
|
||||||
|
const since = Date.now() - hours * 3600_000;
|
||||||
|
const database = db();
|
||||||
|
|
||||||
|
const conditions: SQL[] = [
|
||||||
|
eq(messagesTable.guild_id, guildId),
|
||||||
|
gte(messagesTable.created_at, since),
|
||||||
|
isNull(messagesTable.deleted_at),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (channelId) {
|
||||||
|
conditions.push(channelFilter(channelId));
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = (await database
|
||||||
|
.select()
|
||||||
|
.from(messagesTable)
|
||||||
|
.where(and(...conditions) as SQL)
|
||||||
|
.orderBy(asc(messagesTable.created_at))) as MessageRecord[];
|
||||||
|
|
||||||
|
const userMap = new Map<string, {
|
||||||
|
user_id: string;
|
||||||
|
username: string;
|
||||||
|
avatar_url: string | null;
|
||||||
|
total_messages: number;
|
||||||
|
flagged_count: number;
|
||||||
|
warned_count: number;
|
||||||
|
flags_set: Set<string>;
|
||||||
|
last_violation: number;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
for (const msg of rows) {
|
||||||
|
let entry = userMap.get(msg.user_id);
|
||||||
|
if (!entry) {
|
||||||
|
entry = {
|
||||||
|
user_id: msg.user_id,
|
||||||
|
username: msg.username,
|
||||||
|
avatar_url: msg.avatar_url,
|
||||||
|
total_messages: 0,
|
||||||
|
flagged_count: 0,
|
||||||
|
warned_count: 0,
|
||||||
|
flags_set: new Set(),
|
||||||
|
last_violation: 0,
|
||||||
|
};
|
||||||
|
userMap.set(msg.user_id, entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.total_messages++;
|
||||||
|
|
||||||
|
const isViolation = msg.ai_status === "flagged" || msg.ai_status === "warn";
|
||||||
|
|
||||||
|
if (msg.ai_status === "flagged") {
|
||||||
|
entry.flagged_count++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.ai_status === "warn") {
|
||||||
|
entry.warned_count++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isViolation && msg.ai_moderation_flags) {
|
||||||
|
try {
|
||||||
|
const flags = JSON.parse(msg.ai_moderation_flags);
|
||||||
|
if (Array.isArray(flags)) {
|
||||||
|
for (const f of flags) entry.flags_set.add(String(f));
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isViolation && msg.created_at > entry.last_violation) {
|
||||||
|
entry.last_violation = msg.created_at;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const violators: ViolatorStat[] = [];
|
||||||
|
|
||||||
|
for (const entry of userMap.values()) {
|
||||||
|
if (entry.flagged_count === 0 && entry.warned_count === 0) continue;
|
||||||
|
|
||||||
|
violators.push({
|
||||||
|
user_id: entry.user_id,
|
||||||
|
username: entry.username,
|
||||||
|
avatar_url: entry.avatar_url,
|
||||||
|
total_messages: entry.total_messages,
|
||||||
|
flagged_count: entry.flagged_count,
|
||||||
|
warned_count: entry.warned_count,
|
||||||
|
violation_score: entry.flagged_count * 3 + entry.warned_count * 1,
|
||||||
|
worst_flags: Array.from(entry.flags_set).slice(0, 5),
|
||||||
|
last_violation: entry.last_violation,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return violators
|
||||||
|
.sort((a, b) => b.violation_score - a.violation_score)
|
||||||
|
.slice(0, limit);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
{ error: error instanceof Error ? error.message : String(error) },
|
||||||
|
"Failed to get top violators",
|
||||||
|
);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Combined Overview ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function getAnalyticsOverview(input: {
|
||||||
|
guildId: string;
|
||||||
|
channelId?: string;
|
||||||
|
hours?: number;
|
||||||
|
}): Promise<AnalyticsOverview> {
|
||||||
|
const { guildId, hours = 24 } = input;
|
||||||
|
const now = Date.now();
|
||||||
|
const since = now - hours * 3600_000;
|
||||||
|
|
||||||
|
const [messages, hourly, topics, topUsers, totalChannels] = await Promise.all([
|
||||||
|
getModerationStats(input),
|
||||||
|
getHourlyStats(input),
|
||||||
|
getTopicTrends(input),
|
||||||
|
getUserLeaderboard(input),
|
||||||
|
getActiveChannelCount({ guildId, hours }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
period: { start: since, end: now },
|
||||||
|
messages,
|
||||||
|
hourly,
|
||||||
|
topics,
|
||||||
|
top_users: topUsers,
|
||||||
|
active_users_count: topUsers.length,
|
||||||
|
total_channels: totalChannels,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -57,7 +57,21 @@ export function detectIndonesianBadwords(text: string): string[] {
|
|||||||
try {
|
try {
|
||||||
const result = badwords.analyze?.(text);
|
const result = badwords.analyze?.(text);
|
||||||
if (Array.isArray(result?.badwords)) {
|
if (Array.isArray(result?.badwords)) {
|
||||||
return Array.from(new Set(result.badwords.map((word) => word.toLowerCase())));
|
let hits = Array.from(new Set(result.badwords.map((word) => word.toLowerCase())));
|
||||||
|
|
||||||
|
const lowerText = text.toLowerCase();
|
||||||
|
hits = hits.filter(hit => {
|
||||||
|
if (hit === "asu") {
|
||||||
|
const words = lowerText.match(/[\p{L}\p{N}_]+/gu) || [];
|
||||||
|
return words.some(w =>
|
||||||
|
w.includes("asu") &&
|
||||||
|
!["asus", "masuk", "termasuk", "dimasukkan", "memasukkan", "kasur", "asumsi", "asuransi", "asupan", "pasukan", "pasundan"].includes(w)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
return hits;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Keep moderation pipeline resilient if dependency changes shape.
|
// Keep moderation pipeline resilient if dependency changes shape.
|
||||||
|
|||||||
@@ -667,6 +667,8 @@ Ini adalah server Discord komunitas Indonesia. Kamu harus memahami:
|
|||||||
- Bahasa gaul/slang Indonesia: "anjay", "wkwk", "gws", "gaskeun", "santuy", "njir", "baka", "woy", "woi", "hadeh", dll.
|
- Bahasa gaul/slang Indonesia: "anjay", "wkwk", "gws", "gaskeun", "santuy", "njir", "baka", "woy", "woi", "hadeh", dll.
|
||||||
- Singkatan umum: "gw", "lo", "emg", "kyk", "tdk", "krn", "jgn", dll.
|
- Singkatan umum: "gw", "lo", "emg", "kyk", "tdk", "krn", "jgn", dll.
|
||||||
- Konteks budaya lokal: SARA (Suku, Agama, Ras, Antar-golongan), hoaks, ujaran kebencian berbasis konteks Indonesia.
|
- Konteks budaya lokal: SARA (Suku, Agama, Ras, Antar-golongan), hoaks, ujaran kebencian berbasis konteks Indonesia.
|
||||||
|
- Makian/kata kasar umum (seperti "anjing", "asu", "bangsat") BUKAN pelanggaran SARA. SARA khusus untuk diskriminasi/hinaan terhadap Suku, Agama, Ras, dan Antargolongan.
|
||||||
|
- Kata "asus" adalah merk teknologi, jangan pernah dianggap sebagai makian "asu".
|
||||||
- Perbedaan antara humor/banter biasa vs konten yang benar-benar melanggar.
|
- Perbedaan antara humor/banter biasa vs konten yang benar-benar melanggar.
|
||||||
- "woy"/"woi" adalah sapaan/interjeksi informal Indonesia dan tidak boleh dianggap SARA, hate speech, atau harassment tanpa target hinaan/ancaman jelas.
|
- "woy"/"woi" adalah sapaan/interjeksi informal Indonesia dan tidak boleh dianggap SARA, hate speech, atau harassment tanpa target hinaan/ancaman jelas.
|
||||||
- Discord custom emoji seperti <:hadeh:123> atau [emoji:hadeh] adalah ekspresi/emoji, bukan pelanggaran teks. Gunakan sebagai konteks ekspresi saja.
|
- Discord custom emoji seperti <:hadeh:123> atau [emoji:hadeh] adalah ekspresi/emoji, bukan pelanggaran teks. Gunakan sebagai konteks ekspresi saja.
|
||||||
|
|||||||
@@ -0,0 +1,241 @@
|
|||||||
|
import type { Router } from "express";
|
||||||
|
import express from "express";
|
||||||
|
import { AppError } from "../errors.js";
|
||||||
|
import {
|
||||||
|
getAnalyticsOverview,
|
||||||
|
getHourlyStats,
|
||||||
|
getModerationStats,
|
||||||
|
getTopViolators,
|
||||||
|
getTopicTrends,
|
||||||
|
getUserLeaderboard,
|
||||||
|
} from "../moderation/analyticsStore.js";
|
||||||
|
|
||||||
|
export function createAnalyticsRoutes(): Router {
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// GET /api/analytics/overview - Full analytics dashboard data
|
||||||
|
// Query params: guildId (required), channelId, hours (default 24)
|
||||||
|
router.get("/analytics/overview", async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
guildId,
|
||||||
|
channelId,
|
||||||
|
hours,
|
||||||
|
} = req.query as {
|
||||||
|
guildId?: string;
|
||||||
|
channelId?: string;
|
||||||
|
hours?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!guildId) {
|
||||||
|
throw new AppError(
|
||||||
|
"guildId query parameter is required",
|
||||||
|
"MISSING_GUILD_ID",
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hoursNum = hours ? Math.min(parseInt(hours) || 24, 168) : 24;
|
||||||
|
|
||||||
|
const overview = await getAnalyticsOverview({
|
||||||
|
guildId,
|
||||||
|
channelId,
|
||||||
|
hours: hoursNum,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(overview);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/analytics/hourly - Hourly message stats
|
||||||
|
// Query params: guildId (required), channelId, hours (default 24)
|
||||||
|
router.get("/analytics/hourly", async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
guildId,
|
||||||
|
channelId,
|
||||||
|
hours,
|
||||||
|
} = req.query as {
|
||||||
|
guildId?: string;
|
||||||
|
channelId?: string;
|
||||||
|
hours?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!guildId) {
|
||||||
|
throw new AppError(
|
||||||
|
"guildId query parameter is required",
|
||||||
|
"MISSING_GUILD_ID",
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hoursNum = hours ? Math.min(parseInt(hours) || 24, 168) : 24;
|
||||||
|
|
||||||
|
const stats = await getHourlyStats({
|
||||||
|
guildId,
|
||||||
|
channelId,
|
||||||
|
hours: hoursNum,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(stats);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/analytics/topics - Topic trends
|
||||||
|
// Query params: guildId (required), channelId, hours (default 24)
|
||||||
|
router.get("/analytics/topics", async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
guildId,
|
||||||
|
channelId,
|
||||||
|
hours,
|
||||||
|
} = req.query as {
|
||||||
|
guildId?: string;
|
||||||
|
channelId?: string;
|
||||||
|
hours?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!guildId) {
|
||||||
|
throw new AppError(
|
||||||
|
"guildId query parameter is required",
|
||||||
|
"MISSING_GUILD_ID",
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hoursNum = hours ? Math.min(parseInt(hours) || 24, 168) : 24;
|
||||||
|
|
||||||
|
const topics = await getTopicTrends({
|
||||||
|
guildId,
|
||||||
|
channelId,
|
||||||
|
hours: hoursNum,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(topics);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/analytics/leaderboard - User leaderboard
|
||||||
|
// Query params: guildId (required), channelId, hours (default 24), limit (default 20)
|
||||||
|
router.get("/analytics/leaderboard", async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
guildId,
|
||||||
|
channelId,
|
||||||
|
hours,
|
||||||
|
limit,
|
||||||
|
} = req.query as {
|
||||||
|
guildId?: string;
|
||||||
|
channelId?: string;
|
||||||
|
hours?: string;
|
||||||
|
limit?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!guildId) {
|
||||||
|
throw new AppError(
|
||||||
|
"guildId query parameter is required",
|
||||||
|
"MISSING_GUILD_ID",
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hoursNum = hours ? Math.min(parseInt(hours) || 24, 168) : 24;
|
||||||
|
const limitNum = limit ? Math.min(parseInt(limit) || 20, 100) : 20;
|
||||||
|
|
||||||
|
const users = await getUserLeaderboard({
|
||||||
|
guildId,
|
||||||
|
channelId,
|
||||||
|
hours: hoursNum,
|
||||||
|
limit: limitNum,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(users);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/analytics/stats - Moderation stats breakdown
|
||||||
|
// Query params: guildId (required), channelId, hours (default 24)
|
||||||
|
router.get("/analytics/stats", async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
guildId,
|
||||||
|
channelId,
|
||||||
|
hours,
|
||||||
|
} = req.query as {
|
||||||
|
guildId?: string;
|
||||||
|
channelId?: string;
|
||||||
|
hours?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!guildId) {
|
||||||
|
throw new AppError(
|
||||||
|
"guildId query parameter is required",
|
||||||
|
"MISSING_GUILD_ID",
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hoursNum = hours ? Math.min(parseInt(hours) || 24, 168) : 24;
|
||||||
|
|
||||||
|
const stats = await getModerationStats({
|
||||||
|
guildId,
|
||||||
|
channelId,
|
||||||
|
hours: hoursNum,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(stats);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/analytics/violators - Top violators leaderboard
|
||||||
|
// Query params: guildId (required), channelId, hours (default 24), limit (default 20)
|
||||||
|
router.get("/analytics/violators", async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
guildId,
|
||||||
|
channelId,
|
||||||
|
hours,
|
||||||
|
limit,
|
||||||
|
} = req.query as {
|
||||||
|
guildId?: string;
|
||||||
|
channelId?: string;
|
||||||
|
hours?: string;
|
||||||
|
limit?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!guildId) {
|
||||||
|
throw new AppError(
|
||||||
|
"guildId query parameter is required",
|
||||||
|
"MISSING_GUILD_ID",
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hoursNum = hours ? Math.min(parseInt(hours) || 24, 168) : 24;
|
||||||
|
const limitNum = limit ? Math.min(parseInt(limit) || 20, 100) : 20;
|
||||||
|
|
||||||
|
const violators = await getTopViolators({
|
||||||
|
guildId,
|
||||||
|
channelId,
|
||||||
|
hours: hoursNum,
|
||||||
|
limit: limitNum,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(violators);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
function filterHits(text, hits) {
|
||||||
|
const lowerText = text.toLowerCase();
|
||||||
|
return hits.filter(hit => {
|
||||||
|
if (hit === "asu") {
|
||||||
|
const words = lowerText.match(/[\p{L}\p{N}_]+/gu) || [];
|
||||||
|
return words.some(w =>
|
||||||
|
w.includes("asu") &&
|
||||||
|
!["asus", "masuk", "termasuk", "dimasukkan", "memasukkan", "kasur", "asumsi", "asuransi", "asupan", "pasukan", "pasundan"].includes(w)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(filterHits("Gua aja mau membeli asus", ["asu"])); // []
|
||||||
|
console.log(filterHits("asus asu", ["asu"])); // ["asu"]
|
||||||
|
console.log(filterHits("masuk", ["asu"])); // []
|
||||||
|
console.log(filterHits("asuuu", ["asu"])); // ["asu"]
|
||||||
|
console.log(filterHits("ngasu", ["asu"])); // ["asu"]
|
||||||
Reference in New Issue
Block a user