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([]); 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 (
{/* ── Control Bar ─────────────────────────────────────────────── */}
Analytics & Insights Pantau statistik moderasi, topik trending, dan aktivitas user dalam satu dasbor.
onChannelChange(e.target.value)} placeholder="All channels" options={[ { value: "", label: "All channels" }, ...channels.map((c) => ({ value: c.id, label: c.name })), ]} />
{TIME_RANGES.map((tr) => ( ))}
{error && (

{error}

)} {!selectedGuild ? ( ) : ( {/* ── KPI Stat Cards ─────────────────────────────────────── */} {/* ── Hourly Activity Chart ──────────────────────────────── */} Aktivitas Pesan Per Jam Distribusi pesan per jam dengan breakdown status moderasi. {/* ── Topics + Leaderboard row ───────────────────────────── */}
Topik Trending Yang paling ramai dibicarakan orang. User Paling Aktif Leaderboard berdasarkan jumlah pesan.
{/* ── VIOLATORS LEADERBOARD ──────────────────────────────── */}
Pelanggar Terbanyak User dengan skor pelanggaran tertinggi (flagged × 3 + warned × 1).
{violators.length} pelanggar
{/* ── Full User Leaderboard ──────────────────────────────── */} Leaderboard Lengkap Detail aktivitas user: pesan, edit, hapus, flag, dan waktu aktif terakhir.
)}
); } // ══════════════════════════════════════════════════════════════════════════ // 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 ( {/* Animated border glow on hover */}
{children} ); } // ── 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 (
{cards.map((card, i) => { const Icon = card.icon; return ( {/* Background pulse */}

{card.label}

{loading ? ( ) : ( card.value ?? "—" )} {card.trend && ( {card.trend === "up" ? ( ) : ( )} )}
{card.sub && (

{card.sub}

)}
); })}
); } // ── Hourly Chart ─────────────────────────────────────────────────────── function HourlyChart({ hourly, loading }: { hourly: HourlyBucket[] | undefined; loading: boolean }) { const containerRef = useRef(null); if (loading && !hourly?.length) { return ; } if (!hourly?.length) { return (

Belum ada data untuk periode ini.

); } const maxCount = Math.max(...hourly.map((b) => b.count), 1); // Convert UTC hour buckets to Jakarta time (UTC+7) const labels = hourly.map((b) => { const utcHour = parseInt(b.hour.slice(11, 13), 10); const jakartaHour = (utcHour + 7) % 24; return `${String(jakartaHour).padStart(2, "0")}:00`; }); return (
{/* Grid lines */} {[0.25, 0.5, 0.75, 1].map((pct) => (
))} {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 ( {/* Stacked segments */}
{/* Hover tooltip */}
{labels[hourly.indexOf(bucket)]} — {bucket.count} msgs
); })}
{/* X-axis labels */}
{labels.filter((_, i) => i % Math.max(1, Math.floor(labels.length / 6)) === 0 || i === labels.length - 1).map((label, i) => ( {label} ))}
{/* Legend */}
); } function Legend({ color, label }: { color: string; label: string }) { return ( {label} ); } // ── 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 ; } if (!topics?.length) { return (

Topik akan muncul setelah AI selesai menganalisis.

); } const maxCount = Math.max(...topics.map((t) => t.count), 1); return (
{topics.map((topic, i) => { const scale = 0.65 + (topic.count / maxCount) * 1.35; return ( 0 ? ` · Skor: ${topic.score}` : ""}`} > {/* Sparkle dot */} {topic.topic} {topic.count} ); })}
); } // ── User Leaderboard ─────────────────────────────────────────────────── function UserLeaderboard({ users, loading, compact, }: { users: UserStat[] | undefined; loading: boolean; compact?: boolean; }) { if (loading && !users?.length) { return ; } if (!users?.length) { return (

Belum ada aktivitas user.

); } const maxMsgs = Math.max(...users.map((u) => u.message_count), 1); const medals = ["🥇", "🥈", "🥉"]; const displayUsers = compact ? users.slice(0, 5) : users; return ( {!compact && ( <> )} {compact && ( )} {displayUsers.map((user, i) => ( {!compact && ( <> )} {compact && ( )} ))}
# UserPesan Edit Hapus Flag AktifPesan
{medals[i] ?? i + 1}
{user.avatar_url ? ( ) : (
{user.username.charAt(0).toUpperCase()}
)} {user.username}
{user.message_count}
{user.edited_count > 0 ? user.edited_count : "—"} {user.deleted_count > 0 ? user.deleted_count : "—"} {user.flagged_count > 0 ? ( {user.flagged_count} ) : ( )} {formatTimeAgo(user.last_active)} {user.message_count} msg
{compact && users.length > 5 && (
+{users.length - 5} user lainnya — lihat leaderboard lengkap di bawah
)}
); } // ── Violators Leaderboard ────────────────────────────────────────────── function ViolatorsLeaderboard({ users, loading, }: { users: ViolatorStat[] | undefined; loading: boolean; }) { if (loading && !users?.length) { return ; } if (!users?.length) { return (

Tidak ada pelanggaran terdeteksi. 🎉

); } 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 (
{users.map((user, i) => { const danger = dangerLevel(user.violation_score); return ( {/* Rank + Danger indicator */}
{i + 1}
{i === 0 && ( 🔥 )}
{/* Avatar */} {user.avatar_url ? ( ) : (
{user.username.charAt(0).toUpperCase()}
)} {/* Info */}
{user.username} {danger.label}
{user.worst_flags.length > 0 ? ( user.worst_flags.map((flag) => ( {flag} )) ) : ( no flags )}
{/* Stats */}
Pesan
{user.total_messages}
Warned
{user.warned_count}
Flagged
{user.flagged_count}
Skor
= 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 }} />
{user.violation_score}
); })}
); } // ── Empty State ──────────────────────────────────────────────────────── function EmptyState({ icon: Icon, text }: { icon: typeof BarChart3; text: string }) { return (

{text}

); } // ── Loading Skeleton ─────────────────────────────────────────────────── function LoadingSkeleton() { return (
Memuat data...
); } // ── Helpers ──────────────────────────────────────────────────────────── function pct(part: number, total: number): number { if (!total) return 0; return Math.round((part / total) * 100); } function formatTimeAgo(ts: number): string { // Use Jakarta time as reference for "ago" calculations const jakartaNow = new Date(new Date().toLocaleString("en-US", { timeZone: "Asia/Jakarta" })); const diff = jakartaNow.getTime() - 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`; }