feat(analytics): add daily trend and activity heatmap endpoints, and implement corresponding frontend components
- Added new API endpoints for daily trend data and activity heatmap in analyticsRoutes.ts. - Created new frontend components: ActivityChart, ControlBar, Heatmap, SummaryCards, TopicList, TrendChart, UserTable, and ViolatorTable for displaying analytics data. - Implemented loading and empty states in the new components. - Enhanced the existing moderation tests with remote fallback handling for Indonesian text normalization.
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
import { AreaChart, Area, XAxis, YAxis, Tooltip, ResponsiveContainer, Legend } from "recharts";
|
||||
import type { HourlyBucket } from "../../api/analytics";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
|
||||
|
||||
interface ActivityChartProps {
|
||||
hourly: HourlyBucket[];
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export function ActivityChart({ hourly, loading }: ActivityChartProps) {
|
||||
if (loading && !hourly?.length) {
|
||||
return <LoadingBox />;
|
||||
}
|
||||
|
||||
if (!hourly?.length) {
|
||||
return <EmptyBox text="Belum ada data untuk periode ini." />;
|
||||
}
|
||||
|
||||
const data = hourly.map((b) => {
|
||||
const utcHour = parseInt(b.hour.slice(11, 13), 10);
|
||||
const jakartaHour = (utcHour + 7) % 24;
|
||||
return {
|
||||
hour: `${String(jakartaHour).padStart(2, "0")}:00`,
|
||||
clean: b.clean,
|
||||
warned: b.warned,
|
||||
flagged: b.flagged,
|
||||
error: b.error,
|
||||
total: b.count,
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<Card className="col-span-2">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-semibold">Aktivitas per Jam</CardTitle>
|
||||
<CardDescription className="text-xs">Distribusi pesan per jam berdasarkan status moderasi.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<AreaChart data={data} margin={{ top: 4, right: 4, left: -16, bottom: 0 }}>
|
||||
<XAxis dataKey="hour" tick={{ fontSize: 10, fill: "#64748b" }} tickLine={false} axisLine={{ stroke: "#334155" }} />
|
||||
<YAxis tick={{ fontSize: 10, fill: "#64748b" }} tickLine={false} axisLine={false} width={28} />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: "#1e293b",
|
||||
border: "1px solid #334155",
|
||||
borderRadius: "6px",
|
||||
fontSize: "11px",
|
||||
color: "#e2e8f0",
|
||||
}}
|
||||
formatter={(value: unknown, name: unknown) => {
|
||||
const v = typeof value === "number" ? value : String(value);
|
||||
return [v, label(String(name))];
|
||||
}}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: "11px" }} />
|
||||
<Area type="monotone" dataKey="clean" stackId="1" stroke="#10b981" fill="#10b981" fillOpacity={0.6} name="clean" />
|
||||
<Area type="monotone" dataKey="warned" stackId="1" stroke="#f59e0b" fill="#f59e0b" fillOpacity={0.6} name="warned" />
|
||||
<Area type="monotone" dataKey="flagged" stackId="1" stroke="#ef4444" fill="#ef4444" fillOpacity={0.6} name="flagged" />
|
||||
<Area type="monotone" dataKey="error" stackId="1" stroke="#f97316" fill="#f97316" fillOpacity={0.4} name="error" />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function label(key: string): string {
|
||||
const map: Record<string, string> = { clean: "Clean", warned: "Warned", flagged: "Flagged", error: "Error" };
|
||||
return map[key] ?? key;
|
||||
}
|
||||
|
||||
function LoadingBox() {
|
||||
return (
|
||||
<Card className="col-span-2">
|
||||
<CardContent className="flex h-[260px] items-center justify-center text-sm text-muted-foreground">
|
||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
<span className="ml-2">Memuat data...</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyBox({ text }: { text: string }) {
|
||||
return (
|
||||
<Card className="col-span-2">
|
||||
<CardContent className="flex h-[260px] items-center justify-center text-sm text-muted-foreground">
|
||||
{text}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,993 +0,0 @@
|
||||
import { 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 "../../hooks/useAnalytics";
|
||||
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 {
|
||||
overview,
|
||||
isLoading,
|
||||
isFetching,
|
||||
error,
|
||||
refresh,
|
||||
violators,
|
||||
violatorsLoading,
|
||||
violatorsFetching,
|
||||
refreshViolators,
|
||||
} = useAnalytics({
|
||||
guildId: selectedGuild,
|
||||
channelId: selectedChannel || undefined,
|
||||
hours,
|
||||
});
|
||||
|
||||
// Loading is true only on first load (no cached data); fetching means background refresh
|
||||
const loading = isLoading && !isFetching;
|
||||
|
||||
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(); refreshViolators(); }}
|
||||
disabled={isFetching}
|
||||
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"
|
||||
>
|
||||
{isFetching ? (
|
||||
<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={cn(violatorsFetching && "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);
|
||||
// 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 (
|
||||
<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">
|
||||
{labels[hourly.indexOf(bucket)]} — {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 {
|
||||
// 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`;
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { Activity, BarChart3 } from "lucide-react";
|
||||
import type { Channel, Guild } from "../../types/voice";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
|
||||
import { Select } from "../ui/select";
|
||||
import { Button } from "../ui/button";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const TIME_RANGES = [
|
||||
{ label: "1j", value: 1 },
|
||||
{ label: "3j", value: 3 },
|
||||
{ label: "6j", value: 6 },
|
||||
{ label: "12j", value: 12 },
|
||||
{ label: "24j", value: 24 },
|
||||
{ label: "48j", value: 48 },
|
||||
{ label: "7h", value: 168 },
|
||||
];
|
||||
|
||||
interface ControlBarProps {
|
||||
guilds: Guild[];
|
||||
channels: Channel[];
|
||||
selectedGuild: string;
|
||||
selectedChannel: string;
|
||||
hours: number;
|
||||
isFetching: boolean;
|
||||
onGuildChange: (guildId: string) => void;
|
||||
onChannelChange: (channelId: string) => void;
|
||||
onHoursChange: (hours: number) => void;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
export function ControlBar({
|
||||
guilds,
|
||||
channels,
|
||||
selectedGuild,
|
||||
selectedChannel,
|
||||
hours,
|
||||
isFetching,
|
||||
onGuildChange,
|
||||
onChannelChange,
|
||||
onHoursChange,
|
||||
onRefresh,
|
||||
}: ControlBarProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<BarChart3 className="h-5 w-5 text-muted-foreground" />
|
||||
Analisis Moderasi
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Pantau statistik, tren topik, dan aktivitas user.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Select
|
||||
value={selectedGuild}
|
||||
onChange={(e) => onGuildChange(e.target.value)}
|
||||
placeholder="Pilih guild"
|
||||
options={guilds.map((g) => ({ value: g.id, label: g.name }))}
|
||||
className="min-w-[180px]"
|
||||
/>
|
||||
<Select
|
||||
value={selectedChannel}
|
||||
onChange={(e) => onChannelChange(e.target.value)}
|
||||
placeholder="Semua channel"
|
||||
options={[
|
||||
{ value: "", label: "Semua channel" },
|
||||
...channels.map((c) => ({ value: c.id, label: c.name })),
|
||||
]}
|
||||
className="min-w-[160px]"
|
||||
/>
|
||||
<div className="flex items-center gap-1 rounded-md bg-muted p-0.5">
|
||||
{TIME_RANGES.map((tr) => (
|
||||
<button
|
||||
key={tr.value}
|
||||
type="button"
|
||||
onClick={() => onHoursChange(tr.value)}
|
||||
className={cn(
|
||||
"rounded-sm px-2.5 py-1 text-xs font-medium transition-colors",
|
||||
hours === tr.value
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{tr.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
onClick={onRefresh}
|
||||
disabled={isFetching}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="ml-auto shrink-0"
|
||||
>
|
||||
{isFetching ? (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="h-3 w-3 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
Memuat...
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Activity className="h-3.5 w-3.5" />
|
||||
Refresh
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useMemo } from "react";
|
||||
import type { HeatmapCell } from "../../api/analytics";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const DAYS = ["Sen", "Sel", "Rab", "Kam", "Jum", "Sab", "Min"];
|
||||
|
||||
interface HeatmapProps {
|
||||
cells: HeatmapCell[];
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export function Heatmap({ cells, loading }: HeatmapProps) {
|
||||
const maxCount = useMemo(() => Math.max(1, ...cells.map((c) => c.count)), [cells]);
|
||||
|
||||
if (loading && !cells?.length) {
|
||||
return <LoadingBox />;
|
||||
}
|
||||
|
||||
if (!cells?.length) {
|
||||
return <EmptyBox />;
|
||||
}
|
||||
|
||||
const cellMap = new Map<string, HeatmapCell>();
|
||||
for (const c of cells) cellMap.set(`${c.dayOfWeek}-${c.hour}`, c);
|
||||
|
||||
function getIntensity(day: number, hour: number): number {
|
||||
return (cellMap.get(`${day}-${hour}`)?.count ?? 0) / maxCount;
|
||||
}
|
||||
|
||||
function getHeatClass(intensity: number): string {
|
||||
if (intensity === 0) return "bg-muted/30";
|
||||
if (intensity < 0.1) return "bg-blue-500/10";
|
||||
if (intensity < 0.2) return "bg-blue-500/20";
|
||||
if (intensity < 0.35) return "bg-blue-500/30";
|
||||
if (intensity < 0.5) return "bg-blue-500/45";
|
||||
if (intensity < 0.7) return "bg-blue-500/60";
|
||||
return "bg-blue-500/80";
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="col-span-2">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-semibold">Heatmap Aktivitas</CardTitle>
|
||||
<CardDescription className="text-xs">Hari × jam — area biru = lebih ramai.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<div className="min-w-[520px]">
|
||||
{/* Header row */}
|
||||
<div className="mb-1 ml-8 flex gap-[2px]">
|
||||
{Array.from({ length: 24 }, (_, h) => (
|
||||
<div key={h} className="flex-1 text-center text-[9px] text-muted-foreground tabular-nums">
|
||||
{h % 3 === 0 ? `${h}` : ""}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Rows */}
|
||||
{DAYS.map((day, d) => (
|
||||
<div key={d} className="mb-[2px] flex items-center gap-[2px]">
|
||||
<div className="w-8 shrink-0 text-right pr-1 text-[10px] text-muted-foreground">
|
||||
{day}
|
||||
</div>
|
||||
{Array.from({ length: 24 }, (_, h) => {
|
||||
const intensity = getIntensity(d, h);
|
||||
const cell = cellMap.get(`${d}-${h}`);
|
||||
return (
|
||||
<div
|
||||
key={h}
|
||||
className={cn("flex-1 rounded-sm aspect-square", getHeatClass(intensity))}
|
||||
title={`${day} ${h}:00 — ${cell?.count ?? 0} pesan`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* Legend */}
|
||||
<div className="mt-3 flex items-center gap-1.5 text-[10px] text-muted-foreground">
|
||||
<span>Sepi</span>
|
||||
<span className="inline-block h-2.5 w-2.5 rounded-sm bg-muted/30" />
|
||||
<span className="inline-block h-2.5 w-2.5 rounded-sm bg-blue-500/20" />
|
||||
<span className="inline-block h-2.5 w-2.5 rounded-sm bg-blue-500/45" />
|
||||
<span className="inline-block h-2.5 w-2.5 rounded-sm bg-blue-500/80" />
|
||||
<span>Ramai</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingBox() {
|
||||
return (
|
||||
<Card className="col-span-2">
|
||||
<CardContent className="flex h-[260px] items-center justify-center text-sm text-muted-foreground">
|
||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
<span className="ml-2">Memuat data...</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyBox() {
|
||||
return (
|
||||
<Card className="col-span-2">
|
||||
<CardContent className="flex h-[260px] items-center justify-center text-sm text-muted-foreground">
|
||||
Belum ada data heatmap.
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { ModerationBreakdown } from "../../api/analytics";
|
||||
import { Card, CardContent } from "../ui/card";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
interface SummaryCardsProps {
|
||||
messages: ModerationBreakdown | null;
|
||||
activeUsersCount: number;
|
||||
totalChannels: number;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export function SummaryCards({ messages, activeUsersCount, totalChannels, loading }: SummaryCardsProps) {
|
||||
const avgPerHour = messages ? Math.round(messages.total / Math.max(1, 24)) : 0;
|
||||
const cleanPct = messages && messages.total > 0 ? Math.round((messages.clean / messages.total) * 100) : 0;
|
||||
const warnedPct = messages && messages.total > 0 ? Math.round((messages.warned / messages.total) * 100) : 0;
|
||||
const flaggedPct = messages && messages.total > 0 ? Math.round((messages.flagged / messages.total) * 100) : 0;
|
||||
|
||||
const cards = [
|
||||
{ label: "Total Pesan", value: formatNum(messages?.total), accent: "text-foreground" },
|
||||
{ label: "Rata-rata/jam", value: formatNum(avgPerHour), accent: "text-muted-foreground" },
|
||||
{ label: "Clean", value: cleanPct > 0 ? `${cleanPct}%` : "—", accent: "text-emerald-400" },
|
||||
{ label: "Warned", value: warnedPct > 0 ? `${warnedPct}%` : "—", accent: "text-amber-400" },
|
||||
{ label: "Flagged", value: flaggedPct > 0 ? `${flaggedPct}%` : "—", accent: "text-red-400" },
|
||||
{ label: "Pending", value: formatNum(messages?.pending), accent: "text-slate-400" },
|
||||
{ label: "User Aktif", value: formatNum(activeUsersCount), accent: "text-violet-400" },
|
||||
{ label: "Channel", value: formatNum(totalChannels), accent: "text-blue-400" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-4 gap-2 lg:grid-cols-8">
|
||||
{cards.map((card) => (
|
||||
<Card key={card.label} className="overflow-hidden">
|
||||
<CardContent className="p-3">
|
||||
<div className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{card.label}
|
||||
</div>
|
||||
<div className={cn("mt-1 font-mono text-lg font-bold tabular-nums", card.accent)}>
|
||||
{loading ? (
|
||||
<span className="animate-pulse">…</span>
|
||||
) : (
|
||||
card.value
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatNum(v: number | undefined | null): string {
|
||||
if (v == null || v === 0) return "—";
|
||||
return v.toLocaleString("id-ID");
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Flame } from "lucide-react";
|
||||
import type { TopicTrend } from "../../api/analytics";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
|
||||
import { ScrollArea } from "../ui/scroll-area";
|
||||
|
||||
interface TopicListProps {
|
||||
topics: TopicTrend[];
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export function TopicList({ topics, loading }: TopicListProps) {
|
||||
if (loading && !topics?.length) {
|
||||
return <LoadingBox />;
|
||||
}
|
||||
|
||||
if (!topics?.length) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex h-[260px] items-center justify-center text-sm text-muted-foreground">
|
||||
Topik akan muncul setelah AI selesai menganalisis.
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const maxCount = Math.max(...topics.map((t) => t.count), 1);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-sm font-semibold">
|
||||
<Flame className="h-4 w-4 text-orange-400" />
|
||||
Topik Trending
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
Yang paling ramai dibicarakan.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<ScrollArea className="max-h-[260px]">
|
||||
<div className="divide-y divide-border/30">
|
||||
{topics.map((topic, i) => (
|
||||
<div key={topic.topic} className="flex items-center gap-3 px-5 py-2 text-sm">
|
||||
<span className="w-5 shrink-0 text-right font-mono text-[10px] text-muted-foreground">
|
||||
{i + 1}
|
||||
</span>
|
||||
<span className="flex-1 truncate font-medium">{topic.topic}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-1.5 w-12 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className="h-full rounded-full bg-blue-500/60"
|
||||
style={{ width: `${(topic.count / maxCount) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="w-8 text-right font-mono text-xs tabular-nums text-muted-foreground">
|
||||
{topic.count}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingBox() {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex h-[260px] items-center justify-center text-sm text-muted-foreground">
|
||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
<span className="ml-2">Memuat data...</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, Legend } from "recharts";
|
||||
import type { TrendBucket } from "../../api/analytics";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
|
||||
|
||||
interface TrendChartProps {
|
||||
trend: TrendBucket[];
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export function TrendChart({ trend, loading }: TrendChartProps) {
|
||||
if (loading && !trend?.length) {
|
||||
return <LoadingBox />;
|
||||
}
|
||||
|
||||
if (!trend?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = trend.map((b) => ({
|
||||
date: b.date,
|
||||
clean: b.clean,
|
||||
warned: b.warned,
|
||||
flagged: b.flagged,
|
||||
error: b.error,
|
||||
total: b.count,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Card className="col-span-3">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-semibold">Tren Harian</CardTitle>
|
||||
<CardDescription className="text-xs">Volume pesan per hari dengan status moderasi.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<LineChart data={data} margin={{ top: 4, right: 4, left: -16, bottom: 0 }}>
|
||||
<XAxis dataKey="date" tick={{ fontSize: 10, fill: "#64748b" }} tickLine={false} axisLine={{ stroke: "#334155" }} tickFormatter={(d: string) => d.slice(5)} />
|
||||
<YAxis tick={{ fontSize: 10, fill: "#64748b" }} tickLine={false} axisLine={false} width={28} />
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: "#1e293b", border: "1px solid #334155", borderRadius: "6px", fontSize: "11px", color: "#e2e8f0" }}
|
||||
formatter={(value: unknown, name: unknown) => {
|
||||
const v = typeof value === "number" ? value : String(value);
|
||||
return [v, label(String(name))];
|
||||
}}
|
||||
labelFormatter={(l: unknown) => String(l)}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: "11px" }} />
|
||||
<Line type="monotone" dataKey="total" stroke="#3b82f6" strokeWidth={2} dot={false} name="Total" />
|
||||
<Line type="monotone" dataKey="clean" stroke="#10b981" strokeWidth={1.5} dot={false} name="Clean" />
|
||||
<Line type="monotone" dataKey="warned" stroke="#f59e0b" strokeWidth={1.5} dot={false} name="Warned" />
|
||||
<Line type="monotone" dataKey="flagged" stroke="#ef4444" strokeWidth={1.5} dot={false} name="Flagged" />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function label(key: string): string {
|
||||
const map: Record<string, string> = { total: "Total", clean: "Clean", warned: "Warned", flagged: "Flagged" };
|
||||
return map[key] ?? key;
|
||||
}
|
||||
|
||||
function LoadingBox() {
|
||||
return (
|
||||
<Card className="col-span-3">
|
||||
<CardContent className="flex h-[260px] items-center justify-center text-sm text-muted-foreground">
|
||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
<span className="ml-2">Memuat data...</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { Users } from "lucide-react";
|
||||
import type { UserStat } from "../../api/analytics";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
|
||||
import { Badge } from "../ui/badge";
|
||||
import { ScrollArea } from "../ui/scroll-area";
|
||||
|
||||
interface UserTableProps {
|
||||
users: UserStat[];
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export function UserTable({ users, loading }: UserTableProps) {
|
||||
if (loading && !users?.length) {
|
||||
return <LoadingBox />;
|
||||
}
|
||||
|
||||
if (!users?.length) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex h-[260px] items-center justify-center text-sm text-muted-foreground">
|
||||
Belum ada aktivitas user.
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const maxMsgs = Math.max(...users.map((u) => u.message_count), 1);
|
||||
const medals = ["🥇", "🥈", "🥉"];
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-sm font-semibold">
|
||||
<Users className="h-4 w-4 text-violet-400" />
|
||||
User Paling Aktif
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
Leaderboard berdasarkan jumlah pesan.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<ScrollArea className="max-h-[260px]">
|
||||
<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-[10px] uppercase tracking-wider text-muted-foreground">
|
||||
<th className="py-2 pl-4 pr-2 font-semibold">#</th>
|
||||
<th className="py-2 pr-2 font-semibold">User</th>
|
||||
<th className="py-2 pr-2 font-semibold text-right">Pesan</th>
|
||||
<th className="py-2 pr-2 font-semibold text-right">Edit</th>
|
||||
<th className="py-2 pr-2 font-semibold text-right">Hapus</th>
|
||||
<th className="py-2 pr-4 font-semibold text-right">Flag</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/20">
|
||||
{users.map((user, i) => (
|
||||
<tr key={user.user_id} className="hover:bg-muted/20 transition-colors">
|
||||
<td className="py-1.5 pl-4 pr-2 font-mono text-[10px] text-muted-foreground tabular-nums">
|
||||
{medals[i] ?? i + 1}
|
||||
</td>
|
||||
<td className="py-1.5 pr-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{user.avatar_url ? (
|
||||
<img src={user.avatar_url} alt="" className="h-6 w-6 rounded-full" loading="lazy" />
|
||||
) : (
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-muted text-[10px] font-bold">
|
||||
{user.username.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<span className="max-w-[100px] truncate text-xs font-medium">{user.username}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-1.5 pr-2 text-right">
|
||||
<div className="flex items-center justify-end gap-1.5">
|
||||
<div className="h-1 w-8 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className="h-full rounded-full bg-blue-500/60"
|
||||
style={{ width: `${(user.message_count / maxMsgs) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="font-mono text-xs tabular-nums">{user.message_count}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-1.5 pr-2 text-right font-mono text-[10px] text-muted-foreground tabular-nums">
|
||||
{user.edited_count > 0 ? user.edited_count : "—"}
|
||||
</td>
|
||||
<td className="py-1.5 pr-2 text-right font-mono text-[10px] text-muted-foreground tabular-nums">
|
||||
{user.deleted_count > 0 ? user.deleted_count : "—"}
|
||||
</td>
|
||||
<td className="py-1.5 pr-4 text-right">
|
||||
{user.flagged_count > 0 ? (
|
||||
<Badge variant="destructive" className="text-[9px] px-1 py-0">
|
||||
{user.flagged_count}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-[10px] text-muted-foreground">—</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingBox() {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex h-[260px] items-center justify-center text-sm text-muted-foreground">
|
||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
<span className="ml-2">Memuat data...</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { Siren } from "lucide-react";
|
||||
import type { ViolatorStat } from "../../api/analytics";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
|
||||
import { Badge } from "../ui/badge";
|
||||
import { ScrollArea } from "../ui/scroll-area";
|
||||
|
||||
interface ViolatorTableProps {
|
||||
users: ViolatorStat[];
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export function ViolatorTable({ users, loading }: ViolatorTableProps) {
|
||||
if (loading && !users?.length) {
|
||||
return <LoadingBox />;
|
||||
}
|
||||
|
||||
if (!users?.length) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex h-[260px] items-center justify-center text-sm text-muted-foreground">
|
||||
Tidak ada pelanggaran terdeteksi.
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const maxScore = Math.max(...users.map((u) => u.violation_score), 1);
|
||||
|
||||
function dangerLabel(score: number) {
|
||||
if (score >= 10) return { variant: "destructive" as const, text: "HIGH" };
|
||||
if (score >= 5) return { variant: "warning" as const, text: "MED" };
|
||||
return { variant: "secondary" as const, text: "LOW" };
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2 text-sm font-semibold">
|
||||
<Siren className="h-4 w-4 text-red-400" />
|
||||
Pelanggar Terbanyak
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
Skor: flagged × 3 + warned × 1.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge variant="destructive">{users.length} pelanggar</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<ScrollArea className="max-h-[260px]">
|
||||
<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-[10px] uppercase tracking-wider text-muted-foreground">
|
||||
<th className="py-2 pl-4 pr-2 font-semibold">#</th>
|
||||
<th className="py-2 pr-2 font-semibold">User</th>
|
||||
<th className="py-2 pr-2 font-semibold text-right">Warned</th>
|
||||
<th className="py-2 pr-2 font-semibold text-right">Flagged</th>
|
||||
<th className="py-2 pr-4 font-semibold text-right">Skor</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/20">
|
||||
{users.map((user, i) => {
|
||||
const danger = dangerLabel(user.violation_score);
|
||||
return (
|
||||
<tr key={user.user_id} className="hover:bg-red-500/5 transition-colors">
|
||||
<td className="py-1.5 pl-4 pr-2 font-mono text-[10px] text-muted-foreground tabular-nums">
|
||||
{i + 1}
|
||||
</td>
|
||||
<td className="py-1.5 pr-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{user.avatar_url ? (
|
||||
<img src={user.avatar_url} alt="" className="h-6 w-6 rounded-full" loading="lazy" />
|
||||
) : (
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-muted text-[10px] font-bold">
|
||||
{user.username.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<span className="max-w-[100px] truncate text-xs font-medium">{user.username}</span>
|
||||
<Badge variant={danger.variant} className="text-[9px] px-1 py-0">
|
||||
{danger.text}
|
||||
</Badge>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-1.5 pr-2 text-right font-mono text-xs text-amber-400 tabular-nums">
|
||||
{user.warned_count}
|
||||
</td>
|
||||
<td className="py-1.5 pr-2 text-right font-mono text-xs text-red-400 tabular-nums">
|
||||
{user.flagged_count}
|
||||
</td>
|
||||
<td className="py-1.5 pr-4 text-right">
|
||||
<div className="flex items-center justify-end gap-1.5">
|
||||
<div className="h-1.5 w-14 overflow-hidden rounded-full bg-muted">
|
||||
<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",
|
||||
)}
|
||||
style={{ width: `${(user.violation_score / maxScore) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="font-mono text-xs font-bold tabular-nums">{user.violation_score}</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
function LoadingBox() {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex h-[260px] items-center justify-center text-sm text-muted-foreground">
|
||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
<span className="ml-2">Memuat data...</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { useState } from "react";
|
||||
import type { Channel, Guild } from "../../types/voice";
|
||||
import { useAnalytics } from "../../hooks/useAnalytics";
|
||||
import { ControlBar } from "./ControlBar";
|
||||
import { SummaryCards } from "./SummaryCards";
|
||||
import { ActivityChart } from "./ActivityChart";
|
||||
import { TrendChart } from "./TrendChart";
|
||||
import { Heatmap } from "./Heatmap";
|
||||
import { TopicList } from "./TopicList";
|
||||
import { UserTable } from "./UserTable";
|
||||
import { ViolatorTable } from "./ViolatorTable";
|
||||
|
||||
interface AnalyticsPanelProps {
|
||||
guilds: Guild[];
|
||||
channels: Channel[];
|
||||
selectedGuild: string;
|
||||
selectedChannel: string;
|
||||
onGuildChange: (guildId: string) => void;
|
||||
onChannelChange: (channelId: string) => void;
|
||||
}
|
||||
|
||||
export function AnalyticsPanel({
|
||||
guilds,
|
||||
channels,
|
||||
selectedGuild,
|
||||
selectedChannel,
|
||||
onGuildChange,
|
||||
onChannelChange,
|
||||
}: AnalyticsPanelProps) {
|
||||
const [hours, setHours] = useState(24);
|
||||
|
||||
const {
|
||||
messages,
|
||||
hourly,
|
||||
topics,
|
||||
topUsers,
|
||||
activeUsersCount,
|
||||
totalChannels,
|
||||
violators,
|
||||
trend,
|
||||
heatmap,
|
||||
isLoading,
|
||||
isFetching,
|
||||
error,
|
||||
refresh,
|
||||
refreshViolators,
|
||||
} = useAnalytics({ guildId: selectedGuild, channelId: selectedChannel || undefined, hours });
|
||||
|
||||
const loading = isLoading && !isFetching;
|
||||
|
||||
if (error && !messages) {
|
||||
return (
|
||||
<div className="rounded-lg border border-red-500/30 bg-red-500/5 p-4 text-sm text-red-300">
|
||||
{error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!selectedGuild) {
|
||||
return (
|
||||
<div className="flex min-h-[300px] flex-col items-center justify-center gap-3 rounded-lg border border-dashed p-8">
|
||||
<p className="text-sm text-muted-foreground">Pilih guild untuk melihat analitik.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Control bar */}
|
||||
<ControlBar
|
||||
guilds={guilds}
|
||||
channels={channels}
|
||||
selectedGuild={selectedGuild}
|
||||
selectedChannel={selectedChannel}
|
||||
hours={hours}
|
||||
isFetching={isFetching}
|
||||
onGuildChange={onGuildChange}
|
||||
onChannelChange={onChannelChange}
|
||||
onHoursChange={setHours}
|
||||
onRefresh={() => { refresh(); refreshViolators(); }}
|
||||
/>
|
||||
|
||||
{/* Summary cards */}
|
||||
<SummaryCards
|
||||
messages={messages}
|
||||
activeUsersCount={activeUsersCount}
|
||||
totalChannels={totalChannels}
|
||||
loading={loading}
|
||||
/>
|
||||
|
||||
{/* Hourly chart */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<ActivityChart hourly={hourly} loading={loading} />
|
||||
<div className="col-span-1">
|
||||
<TopicList topics={topics} loading={loading} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Trend chart — only show when enough data */}
|
||||
{hours >= 48 && (
|
||||
<TrendChart trend={trend} loading={loading} />
|
||||
)}
|
||||
|
||||
{/* Heatmap + leaderboard */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<Heatmap cells={heatmap} loading={loading} />
|
||||
<div className="col-span-1">
|
||||
<UserTable users={topUsers} loading={loading} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Violators */}
|
||||
<ViolatorTable users={violators} loading={loading} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user