refactor(frontend): major rebuild — feature-sliced architecture + bug fixes + glass-morphism UI

Architecture:
- Feature-sliced directory: entities/, shared/, features/, widgets/
- Consolidated API client (shared/api/client.ts) — all endpoints in one file
- Single WebSocket manager (shared/ws/socket.ts) with typed event bus
- Extracted hooks: useAudioPlayback, useAudioTransmit, useLocalStorage, useUIState
- UI primitives moved to shared/ui/ with barrel export
- Added Skeleton, Toast, MobileTabBar components

Bug fixes (8/8):
1. useMemo→useEffect in RecordingsSubPanel (async side-effect anti-pattern)
2. ArrayBuffer.slice() before WebSocket send (shared buffer bug)
3. Proper useEffect dependency arrays throughout
4. Stable React keys (no index fallbacks)
5. onReanalyze properly awaited (Promise<void> return)
6. monitorGuild memoized with useMemo
7. localStorage validation with shape checking
8. Deleted duplicate socket logic (ws/client.ts removed)

UI polish:
- Glass-morphism design tokens (backdrop-blur, translucent cards)
- Gradient mesh background with subtle radial overlays
- Expandable sidebar + mobile bottom tab bar
- Skeleton loading placeholders
- Audio visualizer with CSS pulse animation

Deleted: src/api/, src/components/, src/hooks/, src/types/, src/ws/, src/lib/
Added: 40 new files across feature-sliced structure

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
MythEclipse
2026-06-01 16:42:36 +07:00
co-authored by Claude Opus 4.8
parent 1aab0d1df1
commit f5507e01f6
84 changed files with 2054 additions and 2658 deletions
@@ -0,0 +1,89 @@
import type { HourlyBucket } from "../../../shared/api/client";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../../../shared/ui";
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-1 lg:col-span-2 glass border-white/5">
<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>
<div className="space-y-3">
<div className="grid grid-cols-4 gap-2 text-[10px] uppercase tracking-wider text-muted-foreground">
<span>Clean</span>
<span>Warned</span>
<span>Flagged</span>
<span>Error</span>
</div>
<div className="max-h-55 space-y-2 overflow-auto pr-1">
{data.map((bucket) => {
const total = Math.max(bucket.total, 1);
const clean = bucket.clean / total;
const warned = bucket.warned / total;
const flagged = bucket.flagged / total;
const error = bucket.error / total;
return (
<div key={bucket.hour} className="grid gap-1 rounded-xl border border-border bg-background/50 p-3">
<div className="flex items-center justify-between text-[11px] text-muted-foreground">
<span className="font-medium text-foreground">{bucket.hour}</span>
<span>{bucket.total} pesan</span>
</div>
<div className="flex h-3 overflow-hidden rounded-full bg-muted">
<div className="bg-emerald-500/80" style={{ width: `${clean * 100}%` }} />
<div className="bg-amber-500/80" style={{ width: `${warned * 100}%` }} />
<div className="bg-red-500/80" style={{ width: `${flagged * 100}%` }} />
<div className="bg-orange-500/80" style={{ width: `${error * 100}%` }} />
</div>
</div>
);
})}
</div>
</div>
</CardContent>
</Card>
);
}
function LoadingBox() {
return (
<Card className="col-span-1 flex h-65 items-center justify-center text-sm text-muted-foreground lg:col-span-2 glass border-white/5">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
<span className="ml-2">Memuat data...</span>
</Card>
);
}
function EmptyBox({ text }: { text: string }) {
return (
<Card className="col-span-1 flex h-65 items-center justify-center text-sm text-muted-foreground lg:col-span-2 glass border-white/5">
{text}
</Card>
);
}
@@ -0,0 +1,111 @@
import { Activity, BarChart3 } from "lucide-react";
import type { Channel, Guild } from "../../../shared/api/client";
import { Card, CardContent, CardDescription, CardHeader, CardTitle, Select, Button } from "../../../shared/ui";
import { cn } from "../../../shared/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 "../../../shared/api/client";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../../../shared/ui";
import { cn } from "../../../shared/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 "../../../shared/api/client";
import { Card, CardContent, Skeleton } from "../../../shared/ui";
import { cn } from "../../../shared/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-2 gap-2 sm:grid-cols-4 lg:grid-cols-8">
{cards.map((card) => (
<Card key={card.label} className="overflow-hidden glass border-white/5">
<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 ? (
<Skeleton className="h-7 w-12 mt-1" />
) : (
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,76 @@
import { Flame } from "lucide-react";
import type { TopicTrend } from "../../../shared/api/client";
import { Card, CardContent, CardDescription, CardHeader, CardTitle, ScrollArea } from "../../../shared/ui";
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,173 @@
import type { TrendBucket } from "../../../shared/api/client";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../../../shared/ui";
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((bucket) => ({
date: bucket.date,
clean: bucket.clean,
warned: bucket.warned,
flagged: bucket.flagged,
error: bucket.error,
total: bucket.count,
}));
const totalMessages = data.reduce((sum, item) => sum + item.total, 0);
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>
<div className="space-y-4">
<div className="flex flex-wrap gap-3 text-[10px] uppercase tracking-wider text-muted-foreground">
<LegendDot color="bg-blue-500" label="Total" />
<LegendDot color="bg-emerald-500" label="Clean" />
<LegendDot color="bg-amber-500" label="Warned" />
<LegendDot color="bg-red-500" label="Flagged" />
</div>
<div className="overflow-hidden rounded-2xl border border-border bg-background/50 p-4">
<div className="mb-3 flex items-center justify-between text-[11px] text-muted-foreground">
<span>Rangkuman 7 hari terakhir</span>
<span>{totalMessages} total pesan</span>
</div>
<div className="overflow-x-auto">
<svg viewBox={`0 0 ${Math.max((data.length - 1) * 56, 56)} 220`} className="h-55 min-w-130 w-full overflow-visible">
<defs>
<linearGradient id="trendFill" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stopColor="#3b82f6" stopOpacity="0.35" />
<stop offset="100%" stopColor="#3b82f6" stopOpacity="0.02" />
</linearGradient>
</defs>
<g stroke="#334155" strokeWidth="1" opacity="0.35">
{Array.from({ length: 4 }, (_, index) => {
const y = 40 + index * 45;
return <line key={index} x1="0" x2={Math.max((data.length - 1) * 56, 56)} y1={y} y2={y} />;
})}
</g>
<TrendArea data={data} keyName="total" fill="url(#trendFill)" stroke="#3b82f6" />
<TrendLine data={data} keyName="total" color="#3b82f6" strokeWidth={2.5} />
<TrendLine data={data} keyName="clean" color="#10b981" strokeWidth={1.8} />
<TrendLine data={data} keyName="warned" color="#f59e0b" strokeWidth={1.8} />
<TrendLine data={data} keyName="flagged" color="#ef4444" strokeWidth={1.8} />
{data.map((item, index) => {
const x = data.length <= 1 ? 0 : (index / (data.length - 1)) * Math.max((data.length - 1) * 56, 56);
return (
<g key={item.date} transform={`translate(${x}, 188)`}>
<circle cx="0" cy="0" r="2.5" fill="#e2e8f0" />
<text x="0" y="18" textAnchor="middle" className="fill-muted-foreground text-[10px]">
{item.date.slice(5)}
</text>
</g>
);
})}
</svg>
</div>
</div>
</div>
</CardContent>
</Card>
);
}
function LegendDot({ color, label }: { color: string; label: string }) {
return (
<span className="flex items-center gap-1">
<span className={`h-2 w-2 rounded-full ${color}`} /> {label}
</span>
);
}
function TrendLine({
data,
color,
strokeWidth,
keyName,
}: {
data: Array<Record<string, number | string>>;
color: string;
strokeWidth: number;
keyName: string;
}) {
const path = buildPath(data, keyName, 220, false);
return <path d={path} fill="none" stroke={color} strokeWidth={strokeWidth} strokeLinejoin="round" strokeLinecap="round" />;
}
function TrendArea({
data,
keyName,
fill,
stroke,
}: {
data: Array<Record<string, number | string>>;
keyName: string;
fill: string;
stroke: string;
}) {
const path = buildPath(data, keyName, 220, true);
return <path d={path} fill={fill} stroke={stroke} strokeOpacity={0.2} />;
}
function buildPath(data: Array<Record<string, number | string>>, keyName: string, height: number, closePath: boolean): string {
const values = data.map((item) => Number(item[keyName] ?? 0));
const maxValue = Math.max(...values, 1);
const width = Math.max((data.length - 1) * 56, 56);
const points = values.map((value, index) => {
const x = data.length <= 1 ? 0 : (index / (data.length - 1)) * width;
const y = height - 35 - (value / maxValue) * 130;
return { x, y };
});
if (points.length === 0) {
return "";
}
const segments: string[] = [`M ${points[0].x} ${points[0].y}`];
for (let index = 1; index < points.length; index++) {
const previous = points[index - 1];
const current = points[index];
const controlX = (previous.x + current.x) / 2;
segments.push(`Q ${controlX} ${previous.y} ${current.x} ${current.y}`);
}
if (closePath) {
const lastPoint = points[points.length - 1];
const firstPoint = points[0];
segments.push(`L ${lastPoint.x} ${height - 24}`);
segments.push(`L ${firstPoint.x} ${height - 24}`);
segments.push("Z");
}
return segments.join(" ");
}
function LoadingBox() {
return (
<Card className="col-span-3">
<CardContent className="flex h-65 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,115 @@
import { Users } from "lucide-react";
import type { UserStat } from "../../../shared/api/client";
import { Badge, Card, CardContent, CardDescription, CardHeader, CardTitle, ScrollArea } from "../../../shared/ui";
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,130 @@
import { Siren } from "lucide-react";
import type { ViolatorStat } from "../../../shared/api/client";
import { Badge, Card, CardContent, CardDescription, CardHeader, CardTitle, ScrollArea } from "../../../shared/ui";
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 "../../../shared/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>
);
}